From e9fcf9780a86d746561231973f47e8ed82d281fa Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:06:39 +0200 Subject: [PATCH 1/3] feat(topology): Add typed project graph --- topology/graph.go | 411 +++++++++++++++++++++++++++++++++++++++++ topology/graph_test.go | 297 +++++++++++++++++++++++++++++ topology/types.go | 94 ++++++++++ 3 files changed, 802 insertions(+) create mode 100644 topology/graph.go create mode 100644 topology/graph_test.go create mode 100644 topology/types.go diff --git a/topology/graph.go b/topology/graph.go new file mode 100644 index 0000000..caf1e6b --- /dev/null +++ b/topology/graph.go @@ -0,0 +1,411 @@ +package topology + +import ( + "fmt" + "path/filepath" + "reflect" + "sort" + "strings" +) + +const hubThreshold = 3 + +func MergeFragments(root string, fragments []Fragment) *Graph { + graph := &Graph{ + Nodes: make(map[ID]Node), + Dependencies: make(map[ID][]Edge), + Dependents: make(map[ID][]Edge), + Members: make(map[ID][]string), + Owners: make(map[string][]ID), + Coverage: Coverage{Status: CoverageUnavailable}, + } + if len(fragments) == 0 { + return graph + } + + fragments = append([]Fragment(nil), fragments...) + sort.Slice(fragments, func(i, j int) bool { + if fragments[i].Provider != fragments[j].Provider { + return fragments[i].Provider < fragments[j].Provider + } + return fragmentKey(fragments[i]) < fragmentKey(fragments[j]) + }) + + var issues []Issue + invalidIDs := make(map[ID]bool) + complete := true + for _, fragment := range fragments { + if fragment.Coverage.Status != CoverageComplete { + complete = false + } + for _, issue := range fragment.Coverage.Issues { + issue.Candidates = uniqueSortedIDs(issue.Candidates) + issues = append(issues, issue) + } + + nodes := append([]Node(nil), fragment.Nodes...) + sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID }) + for _, node := range nodes { + node = normalizeNode(node, fragment.Provider) + if node.ID == "" { + issues = append(issues, Issue{Provider: fragment.Provider, Code: "missing-node-id", Message: "topology node has no ID"}) + complete = false + continue + } + if err := validateNodePath(root, node); err != nil { + issues = append(issues, Issue{Provider: fragment.Provider, Code: "invalid-node-path", Message: fmt.Sprintf("%s: %v", node.ID, err)}) + invalidIDs[node.ID] = true + delete(graph.Nodes, node.ID) + complete = false + continue + } + if existing, ok := graph.Nodes[node.ID]; ok && !reflect.DeepEqual(existing, node) { + issues = append(issues, Issue{Provider: fragment.Provider, Code: "conflicting-node-id", Message: fmt.Sprintf("conflicting definitions for %s", node.ID)}) + invalidIDs[node.ID] = true + delete(graph.Nodes, node.ID) + complete = false + continue + } + if !invalidIDs[node.ID] { + graph.Nodes[node.ID] = node + } + } + } + + for _, fragment := range fragments { + memberIDs := make([]ID, 0, len(fragment.Members)) + for id := range fragment.Members { + memberIDs = append(memberIDs, id) + } + sort.Slice(memberIDs, func(i, j int) bool { return memberIDs[i] < memberIDs[j] }) + for _, id := range memberIDs { + if _, ok := graph.Nodes[id]; !ok { + issues = append(issues, Issue{Provider: fragment.Provider, Code: "unknown-member-node", Message: fmt.Sprintf("members reference unknown node %s", id)}) + complete = false + continue + } + for _, member := range uniqueSortedStrings(fragment.Members[id]) { + normalized, err := normalizeRepoPath(root, member) + if err != nil { + issues = append(issues, Issue{Provider: fragment.Provider, Code: "invalid-member-path", Message: fmt.Sprintf("%s: %v", member, err)}) + complete = false + continue + } + graph.Members[id] = appendUniqueString(graph.Members[id], normalized) + } + sort.Strings(graph.Members[id]) + } + } + + for id, members := range graph.Members { + for _, member := range members { + graph.Owners[member] = append(graph.Owners[member], id) + } + } + for member, owners := range graph.Owners { + graph.Owners[member] = uniqueSortedIDs(owners) + } + + seenEdges := make(map[string]bool) + for _, fragment := range fragments { + edges := append([]Edge(nil), fragment.Edges...) + sort.Slice(edges, func(i, j int) bool { return edgeKey(edges[i]) < edgeKey(edges[j]) }) + for _, edge := range edges { + if !validEdgeKind(edge.Kind) { + issues = append(issues, Issue{ + Provider: fragment.Provider, + Code: "invalid-edge-kind", + Message: fmt.Sprintf("edge %s -> %s has unsupported kind %q", edge.From, edge.To, edge.Kind), + }) + complete = false + continue + } + if _, ok := graph.Nodes[edge.From]; !ok { + issues = append(issues, Issue{Provider: fragment.Provider, Code: "unknown-edge-source", Message: fmt.Sprintf("edge source %s does not exist", edge.From)}) + complete = false + continue + } + if _, ok := graph.Nodes[edge.To]; !ok { + issues = append(issues, Issue{Provider: fragment.Provider, Code: "unknown-edge-target", Message: fmt.Sprintf("edge target %s does not exist", edge.To)}) + complete = false + continue + } + manifest, err := normalizeRepoPath(root, edge.Evidence.Manifest) + if err != nil { + issues = append(issues, Issue{Provider: fragment.Provider, Code: "invalid-edge-evidence", Message: fmt.Sprintf("%s: %v", edge.Evidence.Manifest, err)}) + complete = false + continue + } + edge.Evidence.Manifest = manifest + key := edgeKey(edge) + if seenEdges[key] { + continue + } + seenEdges[key] = true + graph.Dependencies[edge.From] = append(graph.Dependencies[edge.From], edge) + graph.Dependents[edge.To] = append(graph.Dependents[edge.To], edge) + } + } + for id := range graph.Dependencies { + sortEdges(graph.Dependencies[id]) + } + for id := range graph.Dependents { + sortEdges(graph.Dependents[id]) + } + + issues = uniqueSortedIssues(issues) + switch { + case len(graph.Nodes) == 0 && !complete: + graph.Coverage.Status = CoverageUnavailable + case complete && len(issues) == 0: + graph.Coverage.Status = CoverageComplete + default: + graph.Coverage.Status = CoveragePartial + } + graph.Coverage.Issues = issues + return graph +} + +func validEdgeKind(kind EdgeKind) bool { + switch kind { + case EdgeDependency, EdgeInheritance, EdgeBuildBoundary: + return true + default: + return false + } +} + +func (g *Graph) OwnersForFile(path string) []ID { + if g == nil { + return nil + } + clean := filepath.Clean(path) + return append([]ID(nil), g.Owners[clean]...) +} + +func (g *Graph) SelectModule(query string) (Node, []ID, bool) { + if g == nil { + return Node{}, nil, false + } + if node, ok := g.Nodes[ID(query)]; ok { + return node, nil, true + } + var candidates []ID + for id, node := range g.Nodes { + if node.Name == query { + candidates = append(candidates, id) + } + } + candidates = uniqueSortedIDs(candidates) + if len(candidates) != 1 { + return Node{}, candidates, false + } + return g.Nodes[candidates[0]], nil, true +} + +func (g *Graph) IsHub(id ID) bool { + if g == nil || len(g.Members[id]) == 0 { + return false + } + dependents := make(map[ID]bool) + for _, edge := range g.Dependents[id] { + if edge.Kind == EdgeDependency { + dependents[edge.From] = true + } + } + return len(dependents) >= hubThreshold +} + +func (g *Graph) HubNodes() []ID { + if g == nil { + return nil + } + var hubs []ID + for id := range g.Nodes { + if g.IsHub(id) { + hubs = append(hubs, id) + } + } + sort.Slice(hubs, func(i, j int) bool { return hubs[i] < hubs[j] }) + return hubs +} + +func ExpandReference(from ID, template Edge, resolution ReferenceResolution) ([]Edge, *Issue) { + switch resolution.Status { + case ResolutionResolved: + targets := uniqueSortedIDs(resolution.Targets) + if len(targets) == 0 { + return nil, &Issue{Code: "unresolved-reference", Message: resolutionMessage(resolution, "reference resolved without targets")} + } + edges := make([]Edge, 0, len(targets)) + for _, target := range targets { + edge := template + edge.From = from + edge.To = target + edges = append(edges, edge) + } + return edges, nil + case ResolutionUnresolved: + return nil, &Issue{Code: "unresolved-reference", Message: resolutionMessage(resolution, "reference did not resolve")} + case ResolutionAmbiguous: + return nil, &Issue{ + Code: "ambiguous-reference", + Message: resolutionMessage(resolution, "reference is ambiguous"), + Candidates: uniqueSortedIDs(resolution.Candidates), + } + default: + return nil, &Issue{Code: "invalid-reference-resolution", Message: resolutionMessage(resolution, "reference has an invalid resolution status")} + } +} + +func normalizeNode(node Node, provider string) Node { + if node.Provider == "" { + node.Provider = provider + } + if node.Manifest != "" { + node.Manifest = filepath.Clean(node.Manifest) + } + if node.Root != "" { + node.Root = filepath.Clean(node.Root) + } + node.SourceRoots = normalizePathList(node.SourceRoots) + node.TestSourceRoots = normalizePathList(node.TestSourceRoots) + return node +} + +func validateNodePath(root string, node Node) error { + for label, path := range map[string]string{ + "manifest": node.Manifest, + "root": node.Root, + } { + if _, err := normalizeRepoPath(root, path); err != nil { + return fmt.Errorf("%s %q: %w", label, path, err) + } + } + for _, path := range append(append([]string(nil), node.SourceRoots...), node.TestSourceRoots...) { + if _, err := normalizeRepoPath(root, path); err != nil { + return fmt.Errorf("source root %q: %w", path, err) + } + } + return nil +} + +func normalizeRepoPath(root, path string) (string, error) { + if path == "" || filepath.IsAbs(path) { + return "", fmt.Errorf("path must be non-empty and repository-relative") + } + clean := filepath.Clean(path) + if clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("path escapes repository") + } + absRoot, err := filepath.Abs(root) + if err != nil { + return "", err + } + joined, err := filepath.Abs(filepath.Join(absRoot, clean)) + if err != nil { + return "", err + } + rel, err := filepath.Rel(absRoot, joined) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", fmt.Errorf("path escapes repository") + } + return clean, nil +} + +func normalizePathList(paths []string) []string { + normalized := make([]string, 0, len(paths)) + for _, path := range paths { + if path == "" { + normalized = append(normalized, path) + continue + } + normalized = append(normalized, filepath.Clean(path)) + } + return uniqueSortedStrings(normalized) +} + +func resolutionMessage(resolution ReferenceResolution, fallback string) string { + if resolution.Note != "" { + return resolution.Note + } + return fallback +} + +func appendUniqueString(items []string, item string) []string { + for _, existing := range items { + if existing == item { + return items + } + } + return append(items, item) +} + +func uniqueSortedIDs(ids []ID) []ID { + seen := make(map[ID]bool, len(ids)) + result := make([]ID, 0, len(ids)) + for _, id := range ids { + if id != "" && !seen[id] { + seen[id] = true + result = append(result, id) + } + } + sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + return result +} + +func uniqueSortedStrings(items []string) []string { + seen := make(map[string]bool, len(items)) + result := make([]string, 0, len(items)) + for _, item := range items { + if !seen[item] { + seen[item] = true + result = append(result, item) + } + } + sort.Strings(result) + return result +} + +func uniqueSortedIssues(issues []Issue) []Issue { + for i := range issues { + issues[i].Candidates = uniqueSortedIDs(issues[i].Candidates) + } + sort.Slice(issues, func(i, j int) bool { + return issueKey(issues[i]) < issueKey(issues[j]) + }) + result := issues[:0] + for _, issue := range issues { + if len(result) == 0 || issueKey(result[len(result)-1]) != issueKey(issue) { + result = append(result, issue) + } + } + return result +} + +func sortEdges(edges []Edge) { + sort.Slice(edges, func(i, j int) bool { return edgeKey(edges[i]) < edgeKey(edges[j]) }) +} + +func edgeKey(edge Edge) string { + return fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%s\x00%09d\x00%t\x00%t", + edge.From, edge.To, edge.Kind, edge.Scope, edge.Evidence.Manifest, + edge.Evidence.Line, edge.Conditional, edge.Incomplete) +} + +func issueKey(issue Issue) string { + ids := make([]string, len(issue.Candidates)) + for i, id := range issue.Candidates { + ids[i] = string(id) + } + return issue.Provider + "\x00" + issue.Code + "\x00" + issue.Message + "\x00" + strings.Join(ids, "\x00") +} + +func fragmentKey(fragment Fragment) string { + var ids []string + for _, node := range fragment.Nodes { + ids = append(ids, string(node.ID)) + } + sort.Strings(ids) + return strings.Join(ids, "\x00") +} diff --git a/topology/graph_test.go b/topology/graph_test.go new file mode 100644 index 0000000..0e07c8a --- /dev/null +++ b/topology/graph_test.go @@ -0,0 +1,297 @@ +package topology + +import ( + "path/filepath" + "reflect" + "testing" +) + +func testNode(id, name string) Node { + return Node{ + ID: ID(id), + Kind: NodeKind("module"), + Name: name, + Manifest: "settings.gradle.kts", + Root: filepath.FromSlash("modules/" + name), + Provider: "test", + } +} + +func TestMergeFragmentsBuildsDeterministicIndexes(t *testing.T) { + root := t.TempDir() + a := testNode("test:settings.gradle.kts:a", "a") + b := testNode("test:settings.gradle.kts:b", "b") + graph := MergeFragments(root, []Fragment{{ + Provider: "test", + Nodes: []Node{b, a}, + Edges: []Edge{{ + From: b.ID, + To: a.ID, + Kind: EdgeDependency, + Evidence: Evidence{Manifest: "settings.gradle.kts", Line: 7}, + }}, + Members: map[ID][]string{ + b.ID: {filepath.FromSlash("modules/b/src/B.kt")}, + a.ID: {filepath.FromSlash("modules/a/src/A.kt")}, + }, + Coverage: Coverage{Status: CoverageComplete}, + }}) + + if graph.Coverage.Status != CoverageComplete { + t.Fatalf("coverage = %q, want complete: %#v", graph.Coverage.Status, graph.Coverage.Issues) + } + if got := graph.Dependencies[b.ID]; len(got) != 1 || got[0].To != a.ID { + t.Fatalf("dependencies[%q] = %#v", b.ID, got) + } + if got := graph.Dependents[a.ID]; len(got) != 1 || got[0].From != b.ID { + t.Fatalf("dependents[%q] = %#v", a.ID, got) + } + wantOwners := []ID{a.ID} + if got := graph.OwnersForFile(filepath.FromSlash("modules/a/src/A.kt")); !reflect.DeepEqual(got, wantOwners) { + t.Fatalf("owners = %#v, want %#v", got, wantOwners) + } + + reversed := MergeFragments(root, []Fragment{{ + Provider: "test", + Nodes: []Node{a, b}, + Edges: []Edge{{ + From: b.ID, + To: a.ID, + Kind: EdgeDependency, + Evidence: Evidence{Manifest: "settings.gradle.kts", Line: 7}, + }}, + Members: map[ID][]string{ + a.ID: {filepath.FromSlash("modules/a/src/A.kt")}, + b.ID: {filepath.FromSlash("modules/b/src/B.kt")}, + }, + Coverage: Coverage{Status: CoverageComplete}, + }}) + if !reflect.DeepEqual(graph, reversed) { + t.Fatalf("merge is order-dependent:\nfirst: %#v\nsecond: %#v", graph, reversed) + } +} + +func TestMergeFragmentsRejectsEscapingPathsAndUnknownEndpoints(t *testing.T) { + root := t.TempDir() + valid := testNode("test:settings.gradle.kts:valid", "valid") + escaping := testNode("test:settings.gradle.kts:escape", "escape") + escaping.Root = filepath.Join("..", "escape") + + graph := MergeFragments(root, []Fragment{{ + Provider: "test", + Nodes: []Node{valid, escaping}, + Edges: []Edge{{ + From: valid.ID, + To: ID("test:settings.gradle.kts:missing"), + Kind: EdgeDependency, + Evidence: Evidence{Manifest: "settings.gradle.kts"}, + }}, + Members: map[ID][]string{ + valid.ID: {filepath.Join("..", "outside.kt")}, + }, + Coverage: Coverage{Status: CoverageComplete}, + }}) + + if graph.Coverage.Status != CoveragePartial { + t.Fatalf("coverage = %q, want partial", graph.Coverage.Status) + } + if _, ok := graph.Nodes[escaping.ID]; ok { + t.Fatalf("escaping node %q was retained", escaping.ID) + } + if len(graph.Dependencies[valid.ID]) != 0 { + t.Fatalf("unknown endpoint edge was retained: %#v", graph.Dependencies[valid.ID]) + } + if len(graph.Coverage.Issues) < 3 { + t.Fatalf("issues = %#v, want path, member, and endpoint issues", graph.Coverage.Issues) + } +} + +func TestMergeFragmentsRejectsMissingNodePaths(t *testing.T) { + root := t.TempDir() + missing := testNode("test:missing", "missing") + missing.Manifest = "" + missing.Root = "" + + graph := MergeFragments(root, []Fragment{{ + Provider: "test", + Nodes: []Node{missing}, + Coverage: Coverage{Status: CoverageComplete}, + }}) + + if graph.Coverage.Status != CoverageUnavailable { + t.Fatalf("coverage = %q, want unavailable", graph.Coverage.Status) + } + if _, ok := graph.Nodes[missing.ID]; ok { + t.Fatalf("node with missing paths was retained: %#v", graph.Nodes[missing.ID]) + } + if !hasIssueCode(graph.Coverage.Issues, "invalid-node-path") { + t.Fatalf("issues = %#v, want invalid-node-path", graph.Coverage.Issues) + } +} + +func TestMergeFragmentsRejectsUnknownEdgeKinds(t *testing.T) { + root := t.TempDir() + a := testNode("test:a", "a") + b := testNode("test:b", "b") + graph := MergeFragments(root, []Fragment{{ + Provider: "test", + Nodes: []Node{a, b}, + Edges: []Edge{ + {From: a.ID, To: b.ID, Kind: EdgeKind("dependecy"), Evidence: Evidence{Manifest: "settings.gradle.kts"}}, + {From: b.ID, To: a.ID, Evidence: Evidence{Manifest: "settings.gradle.kts"}}, + }, + Members: map[ID][]string{ + a.ID: {"a/Main.kt"}, + b.ID: {"b/Main.kt"}, + }, + Coverage: Coverage{Status: CoverageComplete}, + }}) + + if graph.Coverage.Status != CoveragePartial { + t.Fatalf("coverage = %q, want partial", graph.Coverage.Status) + } + if len(graph.Dependencies) != 0 || len(graph.Dependents) != 0 { + t.Fatalf("invalid edge kinds were retained: deps=%#v dependents=%#v", graph.Dependencies, graph.Dependents) + } + if !hasIssueCode(graph.Coverage.Issues, "invalid-edge-kind") { + t.Fatalf("issues = %#v, want invalid-edge-kind", graph.Coverage.Issues) + } +} + +func TestDependencyEdgesAloneCreateHubs(t *testing.T) { + root := t.TempDir() + hub := testNode("test:settings.gradle.kts:hub", "hub") + nodes := []Node{hub} + members := map[ID][]string{hub.ID: {"modules/hub/src/Hub.kt"}} + var edges []Edge + for _, suffix := range []string{"a", "b", "c"} { + node := testNode("test:settings.gradle.kts:"+suffix, suffix) + nodes = append(nodes, node) + members[node.ID] = []string{"modules/" + suffix + "/src/Main.kt"} + edges = append(edges, Edge{ + From: node.ID, + To: hub.ID, + Kind: EdgeDependency, + Evidence: Evidence{Manifest: "settings.gradle.kts"}, + }) + } + parent := testNode("test:pom.xml:parent", "parent") + boundary := testNode("test:settings.gradle.kts:boundary", "boundary") + nodes = append(nodes, parent, boundary) + members[parent.ID] = []string{"parent/src/Parent.java"} + members[boundary.ID] = []string{"boundary/src/Main.kt"} + edges = append(edges, + Edge{From: nodes[1].ID, To: parent.ID, Kind: EdgeInheritance, Evidence: Evidence{Manifest: "pom.xml"}}, + Edge{From: nodes[2].ID, To: boundary.ID, Kind: EdgeBuildBoundary, Evidence: Evidence{Manifest: "settings.gradle.kts"}}, + ) + + graph := MergeFragments(root, []Fragment{{ + Provider: "test", + Nodes: nodes, + Edges: edges, + Members: members, + Coverage: Coverage{Status: CoverageComplete}, + }}) + + if !graph.IsHub(hub.ID) { + t.Fatalf("%q should be a hub", hub.ID) + } + if graph.IsHub(parent.ID) || graph.IsHub(boundary.ID) { + t.Fatalf("non-dependency edges created hubs: parent=%v boundary=%v", graph.IsHub(parent.ID), graph.IsHub(boundary.ID)) + } + if got := graph.HubNodes(); !reflect.DeepEqual(got, []ID{hub.ID}) { + t.Fatalf("hubs = %#v, want %#v", got, []ID{hub.ID}) + } +} + +func TestSelectModuleFailsClosedForDuplicateDisplayNames(t *testing.T) { + root := t.TempDir() + left := testNode("test:left:common", "common") + right := testNode("test:right:common", "common") + graph := MergeFragments(root, []Fragment{{ + Provider: "test", + Nodes: []Node{right, left}, + Members: map[ID][]string{ + left.ID: {"left/Main.kt"}, + right.ID: {"right/Main.kt"}, + }, + Coverage: Coverage{Status: CoverageComplete}, + }}) + + if _, candidates, ok := graph.SelectModule("common"); ok || !reflect.DeepEqual(candidates, []ID{left.ID, right.ID}) { + t.Fatalf("ambiguous selection = ok:%v candidates:%#v", ok, candidates) + } + if node, candidates, ok := graph.SelectModule(string(right.ID)); !ok || len(candidates) != 0 || node.ID != right.ID { + t.Fatalf("exact selection = node:%#v candidates:%#v ok:%v", node, candidates, ok) + } +} + +func TestOwnersReturnEveryCandidateWithoutSelectingOne(t *testing.T) { + root := t.TempDir() + a := testNode("test:a", "a") + b := testNode("test:b", "b") + graph := MergeFragments(root, []Fragment{{ + Provider: "test", + Nodes: []Node{b, a}, + Members: map[ID][]string{ + a.ID: {"shared/Main.kt"}, + b.ID: {"shared/Main.kt"}, + }, + Coverage: Coverage{Status: CoverageComplete}, + }}) + + if got := graph.OwnersForFile("shared/Main.kt"); !reflect.DeepEqual(got, []ID{a.ID, b.ID}) { + t.Fatalf("owners = %#v, want both candidates", got) + } +} + +func TestExpandReferenceEmitsEveryResolvedTargetOnce(t *testing.T) { + from := ID("swiftpm:Package.swift:Client") + a := ID("swiftpm:Package.swift:A") + b := ID("swiftpm:Package.swift:B") + template := Edge{ + Kind: EdgeDependency, + Scope: EdgeScope("production"), + Evidence: Evidence{Manifest: "Package.swift", Line: 12}, + Conditional: true, + } + + edges, issue := ExpandReference(from, template, ReferenceResolution{ + Status: ResolutionResolved, + Targets: []ID{b, a, b}, + }) + + if issue != nil { + t.Fatalf("unexpected issue: %#v", issue) + } + want := []Edge{ + {From: from, To: a, Kind: EdgeDependency, Scope: EdgeScope("production"), Evidence: template.Evidence, Conditional: true}, + {From: from, To: b, Kind: EdgeDependency, Scope: EdgeScope("production"), Evidence: template.Evidence, Conditional: true}, + } + if !reflect.DeepEqual(edges, want) { + t.Fatalf("edges = %#v, want %#v", edges, want) + } +} + +func TestExpandReferenceRejectsAmbiguityWithoutEdges(t *testing.T) { + a := ID("swiftpm:Package.swift:A") + b := ID("swiftpm:Package.swift:B") + edges, issue := ExpandReference(ID("swiftpm:Package.swift:Client"), Edge{ + Kind: EdgeDependency, + Evidence: Evidence{Manifest: "Package.swift", Line: 12}, + }, ReferenceResolution{ + Status: ResolutionAmbiguous, + Candidates: []ID{b, a, b}, + Note: "product Core matches multiple local declarations", + }) + + if len(edges) != 0 { + t.Fatalf("ambiguous resolution emitted edges: %#v", edges) + } + if issue == nil || issue.Code != "ambiguous-reference" { + t.Fatalf("issue = %#v, want ambiguous-reference", issue) + } + if !reflect.DeepEqual(issue.Candidates, []ID{a, b}) { + t.Fatalf("candidates = %#v, want sorted unique IDs", issue.Candidates) + } +} diff --git a/topology/types.go b/topology/types.go new file mode 100644 index 0000000..d0d79d9 --- /dev/null +++ b/topology/types.go @@ -0,0 +1,94 @@ +package topology + +import ( + "codemap/scanner" +) + +type ID string +type NodeKind string +type EdgeKind string +type EdgeScope string +type CoverageStatus string +type ResolutionStatus string + +const ( + EdgeDependency EdgeKind = "dependency" + EdgeInheritance EdgeKind = "inheritance" + EdgeBuildBoundary EdgeKind = "build-boundary" + + CoverageComplete CoverageStatus = "complete" + CoveragePartial CoverageStatus = "partial" + CoverageUnavailable CoverageStatus = "unavailable" + + ResolutionResolved ResolutionStatus = "resolved" + ResolutionUnresolved ResolutionStatus = "unresolved" + ResolutionAmbiguous ResolutionStatus = "ambiguous" +) + +type Node struct { + ID ID `json:"id"` + Kind NodeKind `json:"kind"` + Name string `json:"name"` + Manifest string `json:"manifest"` + Root string `json:"root"` + SourceRoots []string `json:"source_roots,omitempty"` + TestSourceRoots []string `json:"test_source_roots,omitempty"` + Provider string `json:"provider"` +} + +type Evidence struct { + Manifest string `json:"manifest"` + Line int `json:"line,omitempty"` +} + +type Edge struct { + From ID `json:"from"` + To ID `json:"to"` + Kind EdgeKind `json:"kind"` + Scope EdgeScope `json:"scope,omitempty"` + Evidence Evidence `json:"evidence"` + Conditional bool `json:"conditional,omitempty"` + Incomplete bool `json:"incomplete,omitempty"` +} + +type Issue struct { + Provider string `json:"provider,omitempty"` + Code string `json:"code"` + Message string `json:"message"` + Candidates []ID `json:"candidates,omitempty"` +} + +type Coverage struct { + Status CoverageStatus `json:"status"` + Issues []Issue `json:"issues,omitempty"` +} + +type Graph struct { + Nodes map[ID]Node `json:"nodes"` + Dependencies map[ID][]Edge `json:"dependencies"` + Dependents map[ID][]Edge `json:"dependents"` + Members map[ID][]string `json:"members"` + Owners map[string][]ID `json:"owners"` + Coverage Coverage `json:"coverage"` +} + +type Fragment struct { + Provider string + Nodes []Node + Edges []Edge + Members map[ID][]string + Coverage Coverage +} + +type ReferenceResolution struct { + Status ResolutionStatus + Targets []ID + Candidates []ID + Note string +} + +type ProjectGraph struct { + Files *scanner.FileGraph `json:"files"` + Topology *Graph `json:"topology,omitempty"` + TopologyIdentity CacheIdentity `json:"-"` +} From 2e56ff5631c5816c30685d95d3902cb49350605d Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:06:44 +0200 Subject: [PATCH 2/3] feat(topology): Add provider cache --- topology/cache.go | 224 ++++++++++++++++++++++++ topology/cache_test.go | 349 ++++++++++++++++++++++++++++++++++++++ topology/provider.go | 307 +++++++++++++++++++++++++++++++++ topology/provider_test.go | 334 ++++++++++++++++++++++++++++++++++++ 4 files changed, 1214 insertions(+) create mode 100644 topology/cache.go create mode 100644 topology/cache_test.go create mode 100644 topology/provider.go create mode 100644 topology/provider_test.go diff --git a/topology/cache.go b/topology/cache.go new file mode 100644 index 0000000..0435f3b --- /dev/null +++ b/topology/cache.go @@ -0,0 +1,224 @@ +package topology + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "codemap/config" + "codemap/scanner" +) + +const CacheSchemaVersion = 1 + +type CacheIdentity struct { + Filters string `json:"filters"` + Manifests string `json:"manifests"` + ConfiguredFiles string `json:"configured_files"` + ProviderVersions string `json:"provider_versions"` +} + +type CacheEnvelope struct { + Schema int `json:"schema"` + GeneratedAt time.Time `json:"generated_at"` + Identity CacheIdentity `json:"identity"` + Graph *Graph `json:"graph"` +} + +func CachePath(root string) string { + return CachePathAt(filepath.Join(root, ".codemap")) +} + +// CachePathAt returns the topology cache path inside cacheDir. +func CachePathAt(cacheDir string) string { + return filepath.Join(cacheDir, "topology-state.json") +} + +func BuildCacheIdentity(root string, files []scanner.FileInfo, manifests []string, providers []Provider) (CacheIdentity, error) { + cfg := config.Load(root) + filterData, err := json.Marshal(cfg) + if err != nil { + return CacheIdentity{}, err + } + + manifestPaths := append([]string(nil), manifests...) + sort.Strings(manifestPaths) + manifestHash := sha256.New() + for _, manifest := range manifestPaths { + rel, err := normalizeRepoPath(root, manifest) + if err != nil { + return CacheIdentity{}, fmt.Errorf("manifest %q: %w", manifest, err) + } + data, err := os.ReadFile(filepath.Join(root, rel)) + if err != nil { + return CacheIdentity{}, err + } + writeHashPart(manifestHash, filepath.ToSlash(rel)) + writeHashPart(manifestHash, string(data)) + } + + fileParts := make([]string, 0, len(files)) + for _, file := range files { + rel, err := normalizeRepoPath(root, file.Path) + if err != nil { + return CacheIdentity{}, fmt.Errorf("configured file %q: %w", file.Path, err) + } + fileParts = append(fileParts, fmt.Sprintf("%s\x00%d\x00%s\x00%t\x00%d\x00%d", + filepath.ToSlash(rel), file.Size, file.Ext, file.IsNew, file.Added, file.Removed)) + } + sort.Strings(fileParts) + + providerParts := make([]string, 0, len(providers)) + for _, provider := range sortedProviders(providers) { + providerParts = append(providerParts, provider.Name()+"="+provider.Version()) + } + + return CacheIdentity{ + Filters: hashStrings(string(filterData)), + Manifests: hex.EncodeToString(manifestHash.Sum(nil)), + ConfiguredFiles: hashStrings(fileParts...), + ProviderVersions: hashStrings(providerParts...), + }, nil +} + +func ReadCache(root string, expected CacheIdentity) (*Graph, bool) { + return ReadCacheAt(filepath.Join(root, ".codemap"), root, expected) +} + +// ReadCacheAt reads a cache from cacheDir and validates graph paths against root. +func ReadCacheAt(cacheDir, root string, expected CacheIdentity) (*Graph, bool) { + data, err := os.ReadFile(CachePathAt(cacheDir)) + if err != nil { + return nil, false + } + var envelope CacheEnvelope + if json.Unmarshal(data, &envelope) != nil || + envelope.Schema != CacheSchemaVersion || + envelope.Identity != expected || + envelope.Graph == nil { + return nil, false + } + graph, ok := canonicalizeCachedGraph(root, envelope.Graph) + if !ok || !IsCacheable(graph) { + return nil, false + } + return graph, true +} + +func IsCacheable(graph *Graph) bool { + if graph == nil || graph.Coverage.Status == CoverageUnavailable { + return false + } + for _, issue := range graph.Coverage.Issues { + if issue.Code == "provider-failed" { + return false + } + } + return true +} + +func WriteCache(root string, envelope CacheEnvelope) error { + return WriteCacheAt(filepath.Join(root, ".codemap"), envelope) +} + +// WriteCacheAt atomically writes a topology cache inside cacheDir. +func WriteCacheAt(cacheDir string, envelope CacheEnvelope) error { + if envelope.Schema != CacheSchemaVersion { + return fmt.Errorf("topology cache schema %d is unsupported", envelope.Schema) + } + if envelope.Graph == nil { + return errors.New("topology cache graph is required") + } + data, err := json.MarshalIndent(envelope, "", " ") + if err != nil { + return err + } + data = append(data, '\n') + + if err := os.MkdirAll(cacheDir, 0o755); err != nil { + return err + } + temp, err := os.CreateTemp(cacheDir, ".topology-state-*.tmp") + if err != nil { + return err + } + tempName := temp.Name() + defer func() { _ = os.Remove(tempName) }() + + if err := temp.Chmod(0o644); err != nil { + _ = temp.Close() + return err + } + if _, err := temp.Write(data); err != nil { + _ = temp.Close() + return err + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return err + } + if err := temp.Close(); err != nil { + return err + } + if err := os.Rename(tempName, CachePathAt(cacheDir)); err != nil { + return err + } + if directory, err := os.Open(cacheDir); err == nil { + _ = directory.Sync() + _ = directory.Close() + } + return nil +} + +func canonicalizeCachedGraph(root string, cached *Graph) (*Graph, bool) { + nodes := make([]Node, 0, len(cached.Nodes)) + for _, node := range cached.Nodes { + nodes = append(nodes, node) + } + var edges []Edge + for _, outgoing := range cached.Dependencies { + edges = append(edges, outgoing...) + } + members := make(map[ID][]string, len(cached.Members)) + for id, paths := range cached.Members { + members[id] = append([]string(nil), paths...) + } + canonical := MergeFragments(root, []Fragment{ + { + Provider: "cache", + Nodes: nodes, + Edges: edges, + Members: members, + Coverage: cached.Coverage, + }, + }) + for _, issue := range canonical.Coverage.Issues { + if strings.HasPrefix(issue.Code, "invalid-") || + strings.HasPrefix(issue.Code, "unknown-") || + issue.Code == "conflicting-node-id" || + issue.Code == "missing-node-id" { + return nil, false + } + } + return canonical, true +} + +func hashStrings(parts ...string) string { + hash := sha256.New() + for _, part := range parts { + writeHashPart(hash, part) + } + return hex.EncodeToString(hash.Sum(nil)) +} + +func writeHashPart(hash interface{ Write([]byte) (int, error) }, part string) { + _, _ = hash.Write([]byte(part)) + _, _ = hash.Write([]byte{0}) +} diff --git a/topology/cache_test.go b/topology/cache_test.go new file mode 100644 index 0000000..2d7821c --- /dev/null +++ b/topology/cache_test.go @@ -0,0 +1,349 @@ +package topology + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "codemap/internal/projectpath" + "codemap/scanner" +) + +func TestReadCacheAcceptsExactIdentity(t *testing.T) { + root := t.TempDir() + graph := cachedTestGraph(root) + identity := CacheIdentity{ + Filters: "filters", + Manifests: "manifests", + ConfiguredFiles: "files", + ProviderVersions: "providers", + } + if err := WriteCache(root, CacheEnvelope{ + Schema: CacheSchemaVersion, + GeneratedAt: time.Unix(10, 0).UTC(), + Identity: identity, + Graph: graph, + }); err != nil { + t.Fatal(err) + } + + got, ok := ReadCache(root, identity) + if !ok { + t.Fatal("exact cache identity was not accepted") + } + if !reflect.DeepEqual(got, graph) { + t.Fatalf("cached graph = %#v, want %#v", got, graph) + } +} + +func TestReadCacheMissesMalformedStaleAndNewerSchemas(t *testing.T) { + identity := CacheIdentity{Filters: "expected"} + tests := []struct { + name string + data string + }{ + {name: "malformed", data: `{"schema":`}, + {name: "stale", data: `{"schema":1,"identity":{"filters":"old"},"graph":{"nodes":{}}}`}, + {name: "newer", data: `{"schema":2,"identity":{"filters":"expected"},"graph":{"nodes":{}}}`}, + {name: "missing graph", data: `{"schema":1,"identity":{"filters":"expected"}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, ".codemap/topology-state.json", tt.data) + before := readFixture(t, CachePath(root)) + + if graph, ok := ReadCache(root, identity); ok || graph != nil { + t.Fatalf("cache hit = %v graph = %#v, want miss", ok, graph) + } + after := readFixture(t, CachePath(root)) + if !reflect.DeepEqual(after, before) { + t.Fatalf("cache read rewrote fixture: before %q after %q", before, after) + } + }) + } +} + +func TestReadCacheDoesNotTouchFileState(t *testing.T) { + root := t.TempDir() + fileState := []byte(`{"updated_at":"legacy","hubs":["main.go"]}`) + writeTopologyFixture(t, root, ".codemap/state.json", string(fileState)) + graph := cachedTestGraph(root) + identity := CacheIdentity{Filters: "filters"} + + if err := WriteCache(root, CacheEnvelope{ + Schema: CacheSchemaVersion, + GeneratedAt: time.Unix(10, 0).UTC(), + Identity: identity, + Graph: graph, + }); err != nil { + t.Fatal(err) + } + if _, ok := ReadCache(root, identity); !ok { + t.Fatal("expected topology cache hit") + } + if got := readFixture(t, filepath.Join(root, ".codemap", "state.json")); !reflect.DeepEqual(got, fileState) { + t.Fatalf("legacy state changed: got %q want %q", got, fileState) + } +} + +func TestReadCacheMissesTransientProviderFailures(t *testing.T) { + root := t.TempDir() + graph := cachedTestGraph(root) + graph.Coverage.Status = CoveragePartial + graph.Coverage.Issues = []Issue{{Provider: "jvm", Code: "provider-failed", Message: "temporary read failure"}} + identity := CacheIdentity{Filters: "filters"} + if err := WriteCache(root, CacheEnvelope{ + Schema: CacheSchemaVersion, + GeneratedAt: time.Now().UTC(), + Identity: identity, + Graph: graph, + }); err != nil { + t.Fatal(err) + } + + if cached, ok := ReadCache(root, identity); ok || cached != nil { + t.Fatalf("transient provider failure was accepted from cache: %#v", cached) + } +} + +func TestWriteCacheAtomicallyReplacesTopologyState(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, ".codemap/topology-state.json", `{"schema":1,"old":true}`) + graph := cachedTestGraph(root) + identity := CacheIdentity{Filters: "new"} + + if err := WriteCache(root, CacheEnvelope{ + Schema: CacheSchemaVersion, + GeneratedAt: time.Unix(20, 0).UTC(), + Identity: identity, + Graph: graph, + }); err != nil { + t.Fatal(err) + } + + data := readFixture(t, CachePath(root)) + var envelope CacheEnvelope + if err := json.Unmarshal(data, &envelope); err != nil { + t.Fatalf("written cache is invalid JSON: %v", err) + } + if envelope.Identity != identity || envelope.Graph == nil { + t.Fatalf("written envelope = %#v", envelope) + } + matches, err := filepath.Glob(filepath.Join(root, ".codemap", ".topology-state-*.tmp")) + if err != nil { + t.Fatal(err) + } + if len(matches) != 0 { + t.Fatalf("temporary cache files remain: %#v", matches) + } +} + +func TestCacheIdentityChangesForFiltersManifestsFilesAndProviderVersions(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, ".codemap/config.json", `{"only":["go"],"exclude":["generated"]}`) + writeTopologyFixture(t, root, "go.mod", "module example.com/cache\n") + files := []scanner.FileInfo{{Path: "main.go"}} + providers := []Provider{stubProvider{name: "go", version: "1"}} + + baseline, err := BuildCacheIdentity(root, files, []string{"go.mod"}, providers) + if err != nil { + t.Fatal(err) + } + + writeTopologyFixture(t, root, ".codemap/config.json", `{"only":["go"],"exclude":["vendor"]}`) + filtered, err := BuildCacheIdentity(root, files, []string{"go.mod"}, providers) + if err != nil { + t.Fatal(err) + } + if baseline.Filters == filtered.Filters { + t.Fatal("filter change did not change identity") + } + + writeTopologyFixture(t, root, ".codemap/config.json", `{"only":["go"],"exclude":["generated"]}`) + writeTopologyFixture(t, root, "go.mod", "module example.com/changed\n") + manifest, err := BuildCacheIdentity(root, files, []string{"go.mod"}, providers) + if err != nil { + t.Fatal(err) + } + if baseline.Manifests == manifest.Manifests { + t.Fatal("manifest change did not change identity") + } + + writeTopologyFixture(t, root, "go.mod", "module example.com/cache\n") + fileSet, err := BuildCacheIdentity(root, append(files, scanner.FileInfo{Path: "other.go"}), []string{"go.mod"}, providers) + if err != nil { + t.Fatal(err) + } + if baseline.ConfiguredFiles == fileSet.ConfiguredFiles { + t.Fatal("configured file-set change did not change identity") + } + + metadata, err := BuildCacheIdentity(root, []scanner.FileInfo{{Path: "main.go", Size: 42, Ext: ".go"}}, []string{"go.mod"}, providers) + if err != nil { + t.Fatal(err) + } + if baseline.ConfiguredFiles == metadata.ConfiguredFiles { + t.Fatal("configured file metadata change did not change identity") + } + + writeTopologyFixture(t, root, ".codemap/config.json", `{"only":["go"],"exclude":["generated"],"depth":2}`) + configChanged, err := BuildCacheIdentity(root, files, []string{"go.mod"}, providers) + if err != nil { + t.Fatal(err) + } + if baseline.Filters == configChanged.Filters { + t.Fatal("provider-visible config change did not change identity") + } + + version, err := BuildCacheIdentity(root, files, []string{"go.mod"}, []Provider{stubProvider{name: "go", version: "2"}}) + if err != nil { + t.Fatal(err) + } + if baseline.ProviderVersions == version.ProviderVersions { + t.Fatal("provider-version change did not change identity") + } +} + +func TestBuildProjectGraphReadsOnlyAnExactCacheIdentity(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, "main.go", "package main\n") + writeTopologyFixture(t, root, "go.mod", "module example.com/cache\n") + node := testNode("cache:go.mod:app", "app") + node.Manifest = "go.mod" + node.Root = "." + calls := 0 + provider := stubProvider{ + name: "cache", + version: "1", + languages: []string{"go"}, + manifests: []string{"go.mod"}, + build: func(context.Context, Inventory) (Fragment, error) { + calls++ + return Fragment{ + Provider: "cache", + Nodes: []Node{node}, + Members: map[ID][]string{node.ID: {"main.go"}}, + Coverage: Coverage{Status: CoverageComplete}, + }, nil + }, + } + + first, err := BuildProjectGraphWithProviders(context.Background(), root, []Provider{provider}) + if err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Fatalf("provider calls = %d, want 1", calls) + } + runtimeDir, err := projectpath.CheckedRuntimeCodemapDir(root) + if err != nil { + t.Fatal(err) + } + if err := WriteCacheAt(runtimeDir, CacheEnvelope{ + Schema: CacheSchemaVersion, + GeneratedAt: time.Now().UTC(), + Identity: first.TopologyIdentity, + Graph: first.Topology, + }); err != nil { + t.Fatal(err) + } + second, err := BuildProjectGraphWithProviders(context.Background(), root, []Provider{provider}) + if err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Fatalf("exact cache hit called provider; calls = %d", calls) + } + if !reflect.DeepEqual(second.Topology, first.Topology) { + t.Fatalf("cache graph = %#v, want %#v", second.Topology, first.Topology) + } + + writeTopologyFixture(t, root, "other.go", "package main\n") + if _, err := BuildProjectGraphWithProviders(context.Background(), root, []Provider{provider}); err != nil { + t.Fatal(err) + } + if calls != 2 { + t.Fatalf("file-set cache miss calls = %d, want 2", calls) + } +} + +func TestBuildGraphRetriesAfterTransientProviderFailure(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, "main.go", "package main\n") + writeTopologyFixture(t, root, "go.mod", "module example.com/retry\n") + node := testNode("retry:go.mod:app", "app") + node.Manifest = "go.mod" + node.Root = "." + calls := 0 + provider := stubProvider{ + name: "retry", + version: "1", + languages: []string{"go"}, + manifests: []string{"go.mod"}, + build: func(context.Context, Inventory) (Fragment, error) { + calls++ + if calls == 1 { + return Fragment{}, errors.New("temporary manifest read failure") + } + return Fragment{ + Provider: "retry", + Nodes: []Node{node}, + Members: map[ID][]string{node.ID: {"main.go"}}, + Coverage: Coverage{Status: CoverageComplete}, + }, nil + }, + } + + first, identity, err := BuildGraphWithProviders(context.Background(), root, []Provider{provider}) + if err != nil { + t.Fatal(err) + } + if first.Coverage.Status != CoverageUnavailable && first.Coverage.Status != CoveragePartial { + t.Fatalf("first coverage = %q", first.Coverage.Status) + } + if err := WriteCache(root, CacheEnvelope{ + Schema: CacheSchemaVersion, + GeneratedAt: time.Now().UTC(), + Identity: identity, + Graph: first, + }); err != nil { + t.Fatal(err) + } + + second, _, err := BuildGraphWithProviders(context.Background(), root, []Provider{provider}) + if err != nil { + t.Fatal(err) + } + if calls != 2 { + t.Fatalf("provider calls = %d, want retry after transient failure", calls) + } + if second.Coverage.Status != CoverageComplete { + t.Fatalf("retry coverage = %q, want complete", second.Coverage.Status) + } +} + +func cachedTestGraph(root string) *Graph { + node := testNode("cache:settings.gradle.kts:app", "app") + return MergeFragments(root, []Fragment{{ + Provider: "cache", + Nodes: []Node{node}, + Members: map[ID][]string{node.ID: {"modules/app/src/Main.kt"}}, + Coverage: Coverage{Status: CoverageComplete}, + }}) +} + +func readFixture(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return data +} diff --git a/topology/provider.go b/topology/provider.go new file mode 100644 index 0000000..64ecbbb --- /dev/null +++ b/topology/provider.go @@ -0,0 +1,307 @@ +package topology + +import ( + "context" + "errors" + "fmt" + "io/fs" + "path/filepath" + "sort" + "strings" + "sync" + + "codemap/config" + "codemap/internal/projectpath" + "codemap/scanner" +) + +const maxManifestWalkEntries = 100_000 + +type ManifestSelector struct { + Names []string +} + +type Inventory struct { + Root string + Files []scanner.FileInfo + Manifests []string + Config config.ProjectConfig +} + +type Provider interface { + Name() string + Version() string + Languages() []string + Manifests() ManifestSelector + Build(context.Context, Inventory) (Fragment, error) +} + +var ( + providerRegistryMu sync.RWMutex + providerRegistry []Provider +) + +func RegisterProvider(provider Provider) { + if provider == nil || strings.TrimSpace(provider.Name()) == "" { + panic("topology: provider name is required") + } + providerRegistryMu.Lock() + defer providerRegistryMu.Unlock() + for _, existing := range providerRegistry { + if existing.Name() == provider.Name() { + panic("topology: duplicate provider " + provider.Name()) + } + } + providerRegistry = append(providerRegistry, provider) +} + +func RegisteredProviders() []Provider { + providerRegistryMu.RLock() + defer providerRegistryMu.RUnlock() + return sortedProviders(providerRegistry) +} + +func IsManifestPath(path string) bool { + base := filepath.Base(path) + for _, provider := range RegisteredProviders() { + for _, name := range provider.Manifests().Names { + if base == name { + return true + } + } + } + return false +} + +func BuildProjectGraph(ctx context.Context, root string) (*ProjectGraph, error) { + return BuildProjectGraphWithProviders(ctx, root, RegisteredProviders()) +} + +func BuildProjectGraphWithProviders(ctx context.Context, root string, providers []Provider) (*ProjectGraph, error) { + // The file scan completes even when the caller already cancelled, so the + // successful graph is still returned alongside the cancellation error and + // the provider topology step is skipped. + files, err := scanner.BuildFileGraph(context.WithoutCancel(ctx), root, scanner.ConfiguredFilters(root)) + if err != nil { + return nil, err + } + project := &ProjectGraph{Files: files} + if err := ctx.Err(); err != nil { + return project, err + } + graph, identity, err := BuildGraphWithProviders(ctx, root, providers) + project.Topology = graph + project.TopologyIdentity = identity + return project, err +} + +func BuildGraph(ctx context.Context, root string) (*Graph, CacheIdentity, error) { + return BuildGraphWithProviders(ctx, root, RegisteredProviders()) +} + +func BuildGraphWithProviders(ctx context.Context, root string, providers []Provider) (*Graph, CacheIdentity, error) { + if err := ctx.Err(); err != nil { + return nil, CacheIdentity{}, err + } + cfg := config.Load(root) + selected := enabledProviders(sortedProviders(providers), cfg.Only) + if len(selected) == 0 { + return MergeFragments(root, nil), CacheIdentity{}, nil + } + + cache := scanner.NewGitIgnoreCache(root) + inventoryFiles, err := scanner.ScanConfiguredFiles(ctx, root, cache) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, CacheIdentity{}, err + } + return unavailableGraph("inventory-failed", err.Error()), CacheIdentity{}, nil + } + if err := ctx.Err(); err != nil { + return nil, CacheIdentity{}, err + } + inventoryFiles = filterInventoryFiles(inventoryFiles, selected) + manifests, err := discoverManifests(ctx, root, selected, cfg) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + return nil, CacheIdentity{}, err + } + return unavailableGraph("manifest-discovery-failed", err.Error()), CacheIdentity{}, nil + } + identity, err := BuildCacheIdentity(root, inventoryFiles, manifests, selected) + if err != nil { + return unavailableGraph("cache-identity-failed", err.Error()), CacheIdentity{}, nil + } + runtimeDir, err := projectpath.CheckedRuntimeCodemapDir(root) + if err != nil { + return unavailableGraph("cache-path-failed", err.Error()), CacheIdentity{}, nil + } + if cached, ok := ReadCacheAt(runtimeDir, root, identity); ok { + return cached, identity, nil + } + + inventory := Inventory{ + Root: root, + Files: inventoryFiles, + Manifests: manifests, + Config: cfg, + } + fragments := make([]Fragment, 0, len(selected)) + for _, provider := range selected { + if err := ctx.Err(); err != nil { + return nil, identity, err + } + fragment, buildErr := provider.Build(ctx, inventory) + if buildErr != nil { + if errors.Is(buildErr, context.Canceled) || errors.Is(buildErr, context.DeadlineExceeded) { + return nil, identity, buildErr + } + fragments = append(fragments, Fragment{ + Provider: provider.Name(), + Coverage: Coverage{ + Status: CoverageUnavailable, + Issues: []Issue{{ + Provider: provider.Name(), + Code: "provider-failed", + Message: buildErr.Error(), + }}, + }, + }) + continue + } + if fragment.Provider == "" { + fragment.Provider = provider.Name() + } + if fragment.Coverage.Status == "" { + fragment.Coverage.Status = CoverageComplete + } + fragments = append(fragments, fragment) + } + return MergeFragments(root, fragments), identity, nil +} + +func discoverManifests(ctx context.Context, root string, providers []Provider, cfg config.ProjectConfig) ([]string, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + names := make(map[string]bool) + for _, provider := range providers { + for _, name := range provider.Manifests().Names { + name = strings.TrimSpace(name) + if name != "" { + names[name] = true + } + } + } + if len(names) == 0 { + return nil, nil + } + + absRoot, err := filepath.Abs(root) + if err != nil { + return nil, err + } + ignoreCache := scanner.NewGitIgnoreCache(absRoot) + entries := 0 + var manifests []string + err = filepath.WalkDir(absRoot, func(path string, entry fs.DirEntry, walkErr error) error { + if err := ctx.Err(); err != nil { + return err + } + if walkErr != nil { + return walkErr + } + entries++ + if entries > maxManifestWalkEntries { + return fmt.Errorf("manifest walk exceeded %d entries", maxManifestWalkEntries) + } + if path == absRoot { + return nil + } + rel, err := filepath.Rel(absRoot, path) + if err != nil { + return err + } + if entry.IsDir() { + if entry.Name() == ".git" || entry.Name() == ".codemap" || ignoreCache.ShouldIgnore(path) || + !scanner.MatchesFilters(filepath.ToSlash(rel), "", nil, cfg.Exclude) { + return filepath.SkipDir + } + ignoreCache.EnsureDir(path) + return nil + } + if !names[entry.Name()] || ignoreCache.ShouldIgnore(path) || + !scanner.MatchesFilters(filepath.ToSlash(rel), filepath.Ext(rel), nil, cfg.Exclude) { + return nil + } + manifests = append(manifests, filepath.Clean(rel)) + return nil + }) + if err != nil { + return nil, err + } + sort.Strings(manifests) + return manifests, nil +} + +func enabledProviders(providers []Provider, only []string) []Provider { + if len(only) == 0 { + return providers + } + allowed := make(map[string]bool, len(only)) + for _, language := range only { + language = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(language)), ".") + if language != "" { + allowed[language] = true + } + } + var enabled []Provider + for _, provider := range providers { + for _, language := range provider.Languages() { + language = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(language)), ".") + if allowed[language] { + enabled = append(enabled, provider) + break + } + } + } + return enabled +} + +func filterInventoryFiles(files []scanner.FileInfo, providers []Provider) []scanner.FileInfo { + languages := make(map[string]bool) + for _, provider := range providers { + for _, language := range provider.Languages() { + language = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(language)), ".") + if language != "" { + languages[language] = true + } + } + } + filtered := make([]scanner.FileInfo, 0, len(files)) + for _, file := range files { + language := strings.ToLower(scanner.DetectLanguage(file.Path)) + extension := strings.TrimPrefix(strings.ToLower(file.Ext), ".") + if languages[language] || languages[extension] { + filtered = append(filtered, file) + } + } + return filtered +} + +func sortedProviders(providers []Provider) []Provider { + result := append([]Provider(nil), providers...) + sort.Slice(result, func(i, j int) bool { + if result[i].Name() != result[j].Name() { + return result[i].Name() < result[j].Name() + } + return result[i].Version() < result[j].Version() + }) + return result +} + +func unavailableGraph(code, message string) *Graph { + graph := MergeFragments("", nil) + graph.Coverage.Issues = []Issue{{Code: code, Message: message}} + return graph +} diff --git a/topology/provider_test.go b/topology/provider_test.go new file mode 100644 index 0000000..0afd260 --- /dev/null +++ b/topology/provider_test.go @@ -0,0 +1,334 @@ +package topology + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "codemap/config" + "codemap/internal/projectpath" + "codemap/scanner" +) + +type stubProvider struct { + name string + version string + languages []string + manifests []string + build func(context.Context, Inventory) (Fragment, error) +} + +type cancelAfterTopologyChecks struct { + context.Context + remaining int +} + +func (c *cancelAfterTopologyChecks) Err() error { + if c.remaining <= 0 { + return context.Canceled + } + c.remaining-- + return nil +} + +func (p stubProvider) Name() string { return p.name } +func (p stubProvider) Version() string { return p.version } +func (p stubProvider) Languages() []string { return append([]string(nil), p.languages...) } +func (p stubProvider) Manifests() ManifestSelector { + return ManifestSelector{Names: append([]string(nil), p.manifests...)} +} +func (p stubProvider) Build(ctx context.Context, inventory Inventory) (Fragment, error) { + if p.build == nil { + return Fragment{Provider: p.name, Coverage: Coverage{Status: CoverageComplete}}, nil + } + return p.build(ctx, inventory) +} + +func TestBuildProjectGraphPreservesEveryFileGraphField(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, "go.mod", "module example.com/topology\n\ngo 1.24.0\n") + writeTopologyFixture(t, root, "dep/dep.go", "package dep\n\nfunc Value() int { return 1 }\n") + writeTopologyFixture(t, root, "main.go", "package main\n\nimport \"example.com/topology/dep\"\n\nfunc main() { _ = dep.Value() }\n") + + direct, err := buildFileGraph(root) + if err != nil { + t.Fatal(err) + } + project, err := BuildProjectGraphWithProviders(context.Background(), root, []Provider{stubProvider{ + name: "empty", + version: "1", + languages: []string{"go"}, + manifests: []string{"go.mod"}, + }}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(project.Files, direct) { + t.Fatalf("file graph changed after topology construction:\ndirect: %#v\nproject: %#v", direct, project.Files) + } +} + +func TestBuildGraphUsesProjectRuntimeCache(t *testing.T) { + root, setup := t.TempDir(), t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + writeTopologyFixture(t, root, "main.go", "package main\n") + projectpath.SetSetupRoot(setup) + t.Cleanup(projectpath.ResetSetupRoot) + + calls := 0 + provider := stubProvider{ + name: "cache", + version: "1", + languages: []string{"go"}, + build: func(context.Context, Inventory) (Fragment, error) { + calls++ + return Fragment{Provider: "cache", Coverage: Coverage{Status: CoverageComplete}}, nil + }, + } + first, identity, err := BuildGraphWithProviders(context.Background(), root, []Provider{provider}) + if err != nil { + t.Fatal(err) + } + runtimeDir, err := projectpath.CheckedRuntimeCodemapDir(root) + if err != nil { + t.Fatal(err) + } + if err := WriteCacheAt(runtimeDir, CacheEnvelope{ + Schema: CacheSchemaVersion, + GeneratedAt: time.Now().UTC(), + Identity: identity, + Graph: first, + }); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(CachePathAt(runtimeDir)); err != nil { + t.Fatalf("runtime topology cache missing: %v", err) + } + if _, err := os.Stat(CachePath(root)); !os.IsNotExist(err) { + t.Fatalf("project-local topology cache = %v, want no legacy cache", err) + } + if _, _, err := BuildGraphWithProviders(context.Background(), root, []Provider{provider}); err != nil { + t.Fatal(err) + } + if calls != 1 { + t.Fatalf("provider calls = %d, want one build followed by a runtime-cache hit", calls) + } +} + +func TestBuildProjectGraphIsolatesProviderFailures(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, "main.go", "package main\n") + goodNode := testNode("good:settings.gradle.kts:app", "app") + good := stubProvider{ + name: "good", + version: "1", + languages: []string{"go"}, + build: func(context.Context, Inventory) (Fragment, error) { + return Fragment{ + Provider: "good", + Nodes: []Node{goodNode}, + Members: map[ID][]string{goodNode.ID: {"main.go"}}, + Coverage: Coverage{Status: CoverageComplete}, + }, nil + }, + } + bad := stubProvider{ + name: "bad", + version: "1", + languages: []string{"go"}, + build: func(context.Context, Inventory) (Fragment, error) { + return Fragment{}, errors.New("malformed manifest") + }, + } + + project, err := BuildProjectGraphWithProviders(context.Background(), root, []Provider{good, bad}) + if err != nil { + t.Fatal(err) + } + if project.Files == nil { + t.Fatal("provider failure removed the file graph") + } + if _, ok := project.Topology.Nodes[goodNode.ID]; !ok { + t.Fatalf("successful provider node missing: %#v", project.Topology) + } + if project.Topology.Coverage.Status != CoveragePartial { + t.Fatalf("coverage = %q, want partial", project.Topology.Coverage.Status) + } + if !hasIssueCode(project.Topology.Coverage.Issues, "provider-failed") { + t.Fatalf("coverage issues = %#v, want provider-failed", project.Topology.Coverage.Issues) + } +} + +func TestBuildProjectGraphPropagatesCancellation(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, "main.go", "package main\n") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + provider := stubProvider{ + name: "cancel", + version: "1", + languages: []string{"go"}, + build: func(context.Context, Inventory) (Fragment, error) { + t.Fatal("provider should not run after cancellation") + return Fragment{}, nil + }, + } + + project, err := BuildProjectGraphWithProviders(ctx, root, []Provider{provider}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", err) + } + if project == nil || project.Files == nil { + t.Fatal("cancellation after file scan should return the successful file graph") + } +} + +func TestBuildGraphPropagatesInventoryCancellation(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, "main.go", "package main\n") + ctx := &cancelAfterTopologyChecks{Context: context.Background(), remaining: 1} + + _, _, err := BuildGraphWithProviders(ctx, root, []Provider{stubProvider{ + name: "cancel", + version: "1", + languages: []string{"go"}, + }}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", err) + } +} + +func TestBuildProjectGraphGatesProvidersByOnlyLanguages(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, ".codemap/config.json", `{"only":["go"]}`) + writeTopologyFixture(t, root, "main.go", "package main\n") + calls := 0 + provider := stubProvider{ + name: "swiftpm", + version: "1", + languages: []string{"swift"}, + manifests: []string{"Package.swift"}, + build: func(context.Context, Inventory) (Fragment, error) { + calls++ + return Fragment{}, nil + }, + } + + project, err := BuildProjectGraphWithProviders(context.Background(), root, []Provider{provider}) + if err != nil { + t.Fatal(err) + } + if calls != 0 { + t.Fatalf("swift provider calls = %d, want zero", calls) + } + if project.Topology.Coverage.Status != CoverageUnavailable { + t.Fatalf("coverage = %q, want unavailable", project.Topology.Coverage.Status) + } +} + +func TestDiscoverManifestsHonorsGitignoreAndExclude(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, ".gitignore", "ignored/\n") + writeTopologyFixture(t, root, ".codemap/config.json", `{"exclude":["excluded"]}`) + writeTopologyFixture(t, root, "pom.xml", "") + writeTopologyFixture(t, root, "nested/pom.xml", "") + writeTopologyFixture(t, root, "ignored/pom.xml", "") + writeTopologyFixture(t, root, "excluded/pom.xml", "") + + got, err := discoverManifests(context.Background(), root, []Provider{stubProvider{ + name: "jvm", + version: "1", + languages: []string{"java"}, + manifests: []string{"pom.xml"}, + }}, config.Load(root)) + if err != nil { + t.Fatal(err) + } + want := []string{"nested/pom.xml", "pom.xml"} + for i := range want { + want[i] = filepath.FromSlash(want[i]) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("manifests = %#v, want %#v", got, want) + } +} + +func TestDiscoverManifestsStopsOnCancellation(t *testing.T) { + root := t.TempDir() + writeTopologyFixture(t, root, "pom.xml", "") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := discoverManifests(ctx, root, []Provider{stubProvider{ + name: "jvm", + version: "1", + languages: []string{"java"}, + manifests: []string{"pom.xml"}, + }}, config.ProjectConfig{}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("error = %v, want context.Canceled", err) + } +} + +func TestRegisteredProvidersAreNameSorted(t *testing.T) { + providerRegistryMu.Lock() + original := append([]Provider(nil), providerRegistry...) + providerRegistry = nil + providerRegistryMu.Unlock() + t.Cleanup(func() { + providerRegistryMu.Lock() + providerRegistry = original + providerRegistryMu.Unlock() + }) + + RegisterProvider(stubProvider{name: "zeta", version: "1"}) + RegisterProvider(stubProvider{name: "alpha", version: "1"}) + got := RegisteredProviders() + if len(got) != 2 || got[0].Name() != "alpha" || got[1].Name() != "zeta" { + t.Fatalf("registered providers = %#v", providerNames(got)) + } + + got[0] = stubProvider{name: "mutated", version: "1"} + again := RegisteredProviders() + if again[0].Name() != "alpha" { + t.Fatalf("registered provider snapshot was mutable: %#v", providerNames(again)) + } +} + +func buildFileGraph(root string) (*scanner.FileGraph, error) { + return scanner.BuildFileGraph(context.Background(), root, scanner.ConfiguredFilters(root)) +} + +func writeTopologyFixture(t *testing.T, root, rel, content string) { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func hasIssueCode(issues []Issue, code string) bool { + for _, issue := range issues { + if issue.Code == code { + return true + } + } + return false +} + +func providerNames(providers []Provider) []string { + names := make([]string, len(providers)) + for i, provider := range providers { + names[i] = provider.Name() + } + return names +} From 275dae41bf92e97db1c8d207905ed6e59745a984 Mon Sep 17 00:00:00 2001 From: Rene Leonhardt <65483435+reneleonhardt@users.noreply.github.com> Date: Thu, 27 Aug 2026 23:06:50 +0200 Subject: [PATCH 3/3] feat(topology): Expose project topology --- blast_radius.go | 78 ++++++--- blast_radius_topology.go | 105 ++++++++++++ blast_radius_topology_test.go | 109 ++++++++++++ main.go | 32 ++++ main_test.go | 2 + mcp/main.go | 46 ++++- mcp/surface_hygiene_test.go | 2 + mcp/topology.go | 191 +++++++++++++++++++++ mcp/topology_test.go | 188 ++++++++++++++++++++ topology/format.go | 217 +++++++++++++++++++++++ topology/format_test.go | 147 ++++++++++++++++ topology_cli.go | 46 +++++ topology_cli_test.go | 124 +++++++++++++ watch/daemon.go | 71 ++++++-- watch/events.go | 43 ++++- watch/topology_test.go | 315 ++++++++++++++++++++++++++++++++++ watch/types.go | 37 ++-- 17 files changed, 1704 insertions(+), 49 deletions(-) create mode 100644 blast_radius_topology.go create mode 100644 blast_radius_topology_test.go create mode 100644 mcp/topology.go create mode 100644 mcp/topology_test.go create mode 100644 topology/format.go create mode 100644 topology/format_test.go create mode 100644 topology_cli.go create mode 100644 topology_cli_test.go create mode 100644 watch/topology_test.go diff --git a/blast_radius.go b/blast_radius.go index 3237da8..c952879 100644 --- a/blast_radius.go +++ b/blast_radius.go @@ -140,17 +140,18 @@ type blastRadiusRendered struct { } type blastRadiusBundle struct { - Root string `json:"root"` - Ref string `json:"ref"` - Summary blastRadiusSummary `json:"summary"` - Diff blastRadiusDiff `json:"diff"` - Deps blastRadiusDeps `json:"deps"` - Importers []blastRadiusImporters `json:"importers"` - Limits blastRadiusLimits `json:"limits"` - ImpactedOutsideDiff []blastRadiusRelation `json:"impacted_outside_diff"` - DependencyContextOutsideDiff []blastRadiusRelation `json:"dependency_context_outside_diff"` - Snippets []blastRadiusSnippet `json:"snippets"` - Rendered blastRadiusRendered `json:"rendered"` + Root string `json:"root"` + Ref string `json:"ref"` + Summary blastRadiusSummary `json:"summary"` + Diff blastRadiusDiff `json:"diff"` + Deps blastRadiusDeps `json:"deps"` + Importers []blastRadiusImporters `json:"importers"` + Limits blastRadiusLimits `json:"limits"` + ImpactedOutsideDiff []blastRadiusRelation `json:"impacted_outside_diff"` + DependencyContextOutsideDiff []blastRadiusRelation `json:"dependency_context_outside_diff"` + AffectedModules []blastRadiusModuleImpact `json:"affected_modules,omitempty"` + Snippets []blastRadiusSnippet `json:"snippets"` + Rendered blastRadiusRendered `json:"rendered"` } type blastChangedMeta struct { @@ -537,6 +538,7 @@ func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blas } summary := buildBlastRadiusSummary(diffCapped.Files, diffTotal, impacted, rawImpacted, ctxRelations, rawContext, allReports) + affectedModules := collectTopologyImpacts(absRoot, changedFiles) bundle := blastRadiusBundle{ Root: absRoot, @@ -568,6 +570,7 @@ func buildBlastRadiusBundle(absRoot, ref string, limits blastRadiusLimits) (blas Limits: limits, ImpactedOutsideDiff: impacted, DependencyContextOutsideDiff: ctxRelations, + AffectedModules: affectedModules, Snippets: snippets, } bundle.Rendered = buildBlastRadiusRendered(diffCapped, depsCapped, shownReports, diffTotal, limits) @@ -1057,18 +1060,18 @@ func renderBlastRadiusMarkdown(bundle blastRadiusBundle) string { var summary strings.Builder summary.WriteString("# Codemap Blast Radius\n\n") - summary.WriteString(fmt.Sprintf("- Root: `%s`\n", bundle.Root)) - summary.WriteString(fmt.Sprintf("- Base ref: `%s`\n\n", bundle.Ref)) + _, _ = fmt.Fprintf(&summary, "- Root: `%s`\n", bundle.Root) + _, _ = fmt.Fprintf(&summary, "- Base ref: `%s`\n\n", bundle.Ref) summary.WriteString("## Summary\n\n") - summary.WriteString(fmt.Sprintf("- Changed files: %d shown of %d\n", bundle.Summary.ChangedFiles, bundle.Summary.ChangedFilesTotal)) - summary.WriteString(fmt.Sprintf("- Changed files with direct dependents: %d\n", bundle.Summary.FilesWithDependents)) - summary.WriteString(fmt.Sprintf("- Affected files outside diff: %d shown of %d\n", bundle.Summary.ImpactedOutsideDiffShown, bundle.Summary.ImpactedOutsideDiffTotal)) - summary.WriteString(fmt.Sprintf("- Dependency context outside diff: %d shown of %d\n", bundle.Summary.DependencyContextOutsideDiffShown, bundle.Summary.DependencyContextOutsideDiffTotal)) + _, _ = fmt.Fprintf(&summary, "- Changed files: %d shown of %d\n", bundle.Summary.ChangedFiles, bundle.Summary.ChangedFilesTotal) + _, _ = fmt.Fprintf(&summary, "- Changed files with direct dependents: %d\n", bundle.Summary.FilesWithDependents) + _, _ = fmt.Fprintf(&summary, "- Affected files outside diff: %d shown of %d\n", bundle.Summary.ImpactedOutsideDiffShown, bundle.Summary.ImpactedOutsideDiffTotal) + _, _ = fmt.Fprintf(&summary, "- Dependency context outside diff: %d shown of %d\n", bundle.Summary.DependencyContextOutsideDiffShown, bundle.Summary.DependencyContextOutsideDiffTotal) if bundle.Summary.HighestBlastRadius != nil { - summary.WriteString(fmt.Sprintf("- Highest blast radius: `%s` (%d direct dependents)\n", bundle.Summary.HighestBlastRadius.File, bundle.Summary.HighestBlastRadius.ImporterCount)) + _, _ = fmt.Fprintf(&summary, "- Highest blast radius: `%s` (%d direct dependents)\n", bundle.Summary.HighestBlastRadius.File, bundle.Summary.HighestBlastRadius.ImporterCount) } - summary.WriteString(fmt.Sprintf("- Output budgets: total %d chars, diff %d, deps %d, importers %d\n", bundle.Limits.MaxTotalChars, bundle.Limits.MaxDiffChars, bundle.Limits.MaxDepsChars, bundle.Limits.MaxImportersChars)) - summary.WriteString(fmt.Sprintf("- Snippet limits: %d total, %d per changed file, %d chars max\n\n", bundle.Limits.MaxSnippets, bundle.Limits.MaxSnippetsPerChanged, bundle.Limits.MaxSnippetChars)) + _, _ = fmt.Fprintf(&summary, "- Output budgets: total %d chars, diff %d, deps %d, importers %d\n", bundle.Limits.MaxTotalChars, bundle.Limits.MaxDiffChars, bundle.Limits.MaxDepsChars, bundle.Limits.MaxImportersChars) + _, _ = fmt.Fprintf(&summary, "- Snippet limits: %d total, %d per changed file, %d chars max\n\n", bundle.Limits.MaxSnippets, bundle.Limits.MaxSnippetsPerChanged, bundle.Limits.MaxSnippetChars) if !builder.Append(summary.String(), "summary") { return builder.String() } @@ -1095,6 +1098,25 @@ func renderBlastRadiusMarkdown(bundle blastRadiusBundle) string { } } + if len(bundle.AffectedModules) > 0 { + var section strings.Builder + section.WriteString("## Affected Modules\n\n") + for _, item := range bundle.AffectedModules { + _, _ = fmt.Fprintf(§ion, "- `%s` (`%s`): %s", item.Name, item.ID, item.Relation) + if item.Via != "" { + _, _ = fmt.Fprintf(§ion, " via `%s`", item.Via) + } + if item.Dependents > 0 { + _, _ = fmt.Fprintf(§ion, " [%d dependent modules]", item.Dependents) + } + section.WriteString("\n") + } + section.WriteString("\n") + if !builder.Append(section.String(), "affected modules") { + return builder.String() + } + } + if len(bundle.DependencyContextOutsideDiff) > 0 { var section strings.Builder section.WriteString("## Dependency Context Outside Diff\n\n") @@ -1190,6 +1212,22 @@ func renderBlastRadiusText(bundle blastRadiusBundle) string { } } + if len(bundle.AffectedModules) > 0 { + var section strings.Builder + section.WriteString("[affected_modules]\n") + for _, item := range bundle.AffectedModules { + _, _ = fmt.Fprintf(§ion, "%s (%s): %s", item.Name, item.ID, item.Relation) + if item.Via != "" { + _, _ = fmt.Fprintf(§ion, " <= %s", item.Via) + } + section.WriteString("\n") + } + section.WriteString("\n") + if !builder.Append(section.String(), "affected modules") { + return builder.String() + } + } + if len(bundle.DependencyContextOutsideDiff) > 0 { var section strings.Builder section.WriteString("[dependency_context_outside_diff]\n") diff --git a/blast_radius_topology.go b/blast_radius_topology.go new file mode 100644 index 0000000..cab4bd8 --- /dev/null +++ b/blast_radius_topology.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "path/filepath" + "sort" + + "codemap/scanner" + "codemap/topology" +) + +type blastRadiusModuleImpact struct { + ID topology.ID `json:"id"` + Name string `json:"name"` + Relation string `json:"relation"` + Via topology.ID `json:"via,omitempty"` + Dependents int `json:"dependents,omitempty"` +} + +var buildBlastTopology = topology.BuildGraph + +func collectTopologyImpacts(root string, changed []scanner.FileInfo) []blastRadiusModuleImpact { + if len(changed) == 0 { + return nil + } + graph, _, err := buildBlastTopology(context.Background(), root) + if err != nil || graph == nil || graph.Coverage.Status == topology.CoverageUnavailable { + return nil + } + return buildTopologyImpacts(graph, changed) +} + +func buildTopologyImpacts(graph *topology.Graph, changed []scanner.FileInfo) []blastRadiusModuleImpact { + if graph == nil { + return nil + } + owners := make(map[topology.ID]bool) + for _, file := range changed { + candidates := graph.OwnersForFile(filepath.Clean(file.Path)) + if len(candidates) == 1 { + owners[candidates[0]] = true + } + } + + impacts := make(map[topology.ID]blastRadiusModuleImpact) + for ownerID := range owners { + node, ok := graph.Nodes[ownerID] + if !ok { + continue + } + impacts[ownerID] = blastRadiusModuleImpact{ + ID: ownerID, + Name: node.Name, + Relation: "owns-changed-file", + Dependents: topologyDependentCount(graph, ownerID), + } + for _, edge := range graph.Dependents[ownerID] { + if edge.Kind != topology.EdgeDependency || owners[edge.From] { + continue + } + dependent, ok := graph.Nodes[edge.From] + if !ok { + continue + } + impacts[dependent.ID] = blastRadiusModuleImpact{ + ID: dependent.ID, + Name: dependent.Name, + Relation: "depends-on-changed-module", + Via: ownerID, + Dependents: topologyDependentCount(graph, dependent.ID), + } + } + } + + result := make([]blastRadiusModuleImpact, 0, len(impacts)) + for _, impact := range impacts { + result = append(result, impact) + } + sort.Slice(result, func(i, j int) bool { + leftRank := moduleImpactRank(result[i].Relation) + rightRank := moduleImpactRank(result[j].Relation) + if leftRank != rightRank { + return leftRank < rightRank + } + return result[i].ID < result[j].ID + }) + return result +} + +func topologyDependentCount(graph *topology.Graph, id topology.ID) int { + dependents := make(map[topology.ID]bool) + for _, edge := range graph.Dependents[id] { + if edge.Kind == topology.EdgeDependency { + dependents[edge.From] = true + } + } + return len(dependents) +} + +func moduleImpactRank(relation string) int { + if relation == "owns-changed-file" { + return 0 + } + return 1 +} diff --git a/blast_radius_topology_test.go b/blast_radius_topology_test.go new file mode 100644 index 0000000..b596a42 --- /dev/null +++ b/blast_radius_topology_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "strings" + "testing" + + "codemap/scanner" + "codemap/topology" +) + +func TestBuildTopologyImpactsKeepsModulesSeparateFromFiles(t *testing.T) { + core := topology.Node{ + ID: topology.ID("jvm:settings.gradle.kts:core"), + Name: "core", + Manifest: "settings.gradle.kts", + Root: "core", + Provider: "jvm", + } + app := topology.Node{ + ID: topology.ID("jvm:settings.gradle.kts:app"), + Name: "app", + Manifest: "settings.gradle.kts", + Root: "app", + Provider: "jvm", + } + graph := &topology.Graph{ + Nodes: map[topology.ID]topology.Node{core.ID: core, app.ID: app}, + Dependencies: map[topology.ID][]topology.Edge{ + app.ID: {{From: app.ID, To: core.ID, Kind: topology.EdgeDependency}}, + }, + Dependents: map[topology.ID][]topology.Edge{ + core.ID: {{From: app.ID, To: core.ID, Kind: topology.EdgeDependency}}, + }, + Members: map[topology.ID][]string{ + core.ID: {"core/src/Core.kt"}, + app.ID: {"app/src/Main.kt", "app/src/Other.kt"}, + }, + Owners: map[string][]topology.ID{ + "core/src/Core.kt": {core.ID}, + "app/src/Main.kt": {app.ID}, + "app/src/Other.kt": {app.ID}, + }, + Coverage: topology.Coverage{Status: topology.CoverageComplete}, + } + changed := []scanner.FileInfo{{Path: "core/src/Core.kt"}} + + got := buildTopologyImpacts(graph, changed) + if len(got) != 2 { + t.Fatalf("module impacts = %#v, want owner and dependent", got) + } + if got[0].ID != core.ID || got[0].Relation != "owns-changed-file" { + t.Fatalf("owner impact = %#v", got[0]) + } + if got[1].ID != app.ID || got[1].Relation != "depends-on-changed-module" { + t.Fatalf("dependent impact = %#v", got[1]) + } + for _, impact := range got { + if strings.Contains(impact.Name, ".kt") { + t.Fatalf("module impact contains an expanded member file: %#v", impact) + } + } +} + +func TestCollectTopologyImpactsSkipsEmptyDiff(t *testing.T) { + original := buildBlastTopology + called := false + buildBlastTopology = func(context.Context, string) (*topology.Graph, topology.CacheIdentity, error) { + called = true + return nil, topology.CacheIdentity{}, nil + } + t.Cleanup(func() { buildBlastTopology = original }) + + if got := collectTopologyImpacts(t.TempDir(), nil); got != nil { + t.Fatalf("empty diff impacts = %#v, want nil", got) + } + if called { + t.Fatal("empty diff built topology") + } +} + +func TestRenderBlastRadiusIncludesSeparateModuleSection(t *testing.T) { + bundle := blastRadiusBundle{ + Root: "/repo", + Ref: "main", + Limits: defaultBlastRadiusLimits(), + Summary: blastRadiusSummary{ + ChangedFiles: 1, + ChangedFilesTotal: 1, + }, + AffectedModules: []blastRadiusModuleImpact{{ + ID: topology.ID("jvm:settings.gradle.kts:app"), + Name: "app", + Relation: "depends-on-changed-module", + Via: topology.ID("jvm:settings.gradle.kts:core"), + }}, + } + + markdown := renderBlastRadiusMarkdown(bundle) + if !strings.Contains(markdown, "## Affected Modules") || + !strings.Contains(markdown, "`app`") || + strings.Contains(markdown, "app/src/") { + t.Fatalf("unexpected markdown module section:\n%s", markdown) + } + text := renderBlastRadiusText(bundle) + if !strings.Contains(text, "[affected_modules]") || !strings.Contains(text, "app") { + t.Fatalf("unexpected text module section:\n%s", text) + } +} diff --git a/main.go b/main.go index 973e6d7..d535e07 100644 --- a/main.go +++ b/main.go @@ -224,6 +224,10 @@ func main() { watchMode := flag.Bool("watch", false, "Live file watcher daemon (experimental)") stdinMode := flag.Bool("stdin", false, "Read file manifest from stdin (use with --deps)") importersMode := flag.String("importers", "", "Check file impact: who imports it, is it a hub?") + topologyMode := flag.Bool("topology", false, "Show project/module dependency topology") + moduleMode := flag.String("module", "", "Show context for one exact module ID or unique name") + moduleFileMode := flag.String("module-file", "", "Show context for the module owning a repository-relative file") + ecosystemMode := flag.String("ecosystem", "", "Filter topology to one provider/ecosystem") helpMode := flag.Bool("help", false, "Show help") flag.BoolVar(helpMode, "h", false, "Show help (shorthand)") // Short flag aliases @@ -253,6 +257,10 @@ func main() { fmt.Println(" --json Output machine-readable JSON") fmt.Println(" --debug Show scanner and path diagnostics") fmt.Println(" --watch Run the live file watcher daemon") + fmt.Println(" --topology Project/module dependency topology") + fmt.Println(" --module Context for one module ID or unique name") + fmt.Println(" --module-file Context for the module owning a file") + fmt.Println(" --ecosystem Filter topology by provider") fmt.Println() fmt.Println("Examples:") fmt.Println(" codemap . # Basic tree view") @@ -265,6 +273,8 @@ func main() { fmt.Println(" codemap --only swift . # Just Swift files") fmt.Println(" codemap --exclude .xcassets,Fonts,.png # Hide assets") fmt.Println(" codemap --importers scanner/types.go # Check file impact") + fmt.Println(" codemap --topology /path/to/repo # Project/module topology") + fmt.Println(" codemap --module app /path/to/repo # Context for module app") fmt.Println(" echo '{...}' | codemap --deps --stdin # Deps from file manifest") fmt.Println() fmt.Println("Remote repos (clones temporarily):") @@ -405,6 +415,28 @@ func main() { return } + if *moduleMode != "" && *moduleFileMode != "" { + fmt.Fprintln(os.Stderr, "Error: specify either --module or --module-file, not both") + os.Exit(2) + } + requestedTopology := *topologyMode || *moduleMode != "" || *moduleFileMode != "" + if *ecosystemMode != "" && !requestedTopology { + fmt.Fprintln(os.Stderr, "Error: --ecosystem requires --topology, --module, or --module-file") + os.Exit(2) + } + if requestedTopology { + topologyRoot, err := canonicalTopologyRoot(absRoot) + if err != nil { + fmt.Fprintf(os.Stderr, "Error selecting topology project: %v\n", err) + os.Exit(1) + } + if err := runTopologyMode(context.Background(), topologyRoot, *moduleMode, *moduleFileMode, *ecosystemMode, *jsonMode, os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "Error building topology: %v\n", err) + os.Exit(1) + } + return + } + // Get changed files if --diff is specified var diffInfo *scanner.DiffInfo if *diffMode { diff --git a/main_test.go b/main_test.go index 39da365..f84d07f 100644 --- a/main_test.go +++ b/main_test.go @@ -62,6 +62,8 @@ func TestHelpFlag(t *testing.T) { "blast-radius", "-C, --project-root Operate on code in .", "--setup-root Reuse state from /.codemap.", + "--ecosystem ", + "--module-file ", } for _, expected := range expectedStrings { diff --git a/mcp/main.go b/mcp/main.go index 3e57b3b..cff97ca 100644 --- a/mcp/main.go +++ b/mcp/main.go @@ -64,6 +64,32 @@ const ( IntegrationCodexPlugin = "codex-plugin" ) +type statusTool struct { + name string + description string +} + +var statusTools = []statusTool{ + {"list_projects", "Discover projects in a directory"}, + {"get_structure", "Project tree view"}, + {"get_dependencies", "Import/function analysis"}, + {"get_diff", "Changed files vs branch"}, + {"find_file", "Search by filename"}, + {"get_importers", "Find what imports a file"}, + {"status", "Verify MCP connection"}, + {"start_watch", "Start watching a project"}, + {"stop_watch", "Stop watching a project"}, + {"get_activity", "See recent coding activity"}, + {"get_hubs", "Find files with wide dependency impact"}, + {"get_file_context", "Show imports and importers for one file"}, + {"get_handoff", "Build/read cross-agent handoff summary"}, + {"get_working_set", "Show files active in the current session"}, + {"list_skills", "List available Codemap skills"}, + {"get_skill", "Read one Codemap skill"}, + {"get_topology", "Show project and module dependency topology"}, + {"get_module_context", "Show context for one module or owning file"}, +} + type RuntimeOptions struct { ConfiguredVersion string Integration string @@ -268,6 +294,18 @@ func NewServer(options RuntimeOptions) *mcp.Server { Description: "Get complete dependency context for a specific file: what it imports, what imports it, whether it's a hub, and all connected files. Use this before editing a file to understand its role in the codebase.", }, handleGetFileContext) + mcp.AddTool(server, &mcp.Tool{ + Name: "get_topology", + Description: "Get project/module topology from build manifests, including module dependencies, dependents, hubs, membership, evidence, and coverage. Existing file dependency tools remain file-only.", + OutputSchema: topologyOutputSchema[TopologyOutput](), + }, handleGetTopology) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_module_context", + Description: "Get dependency context for one exact module ID, unique module name, or repository-relative file owner. Ambiguous names and ownership return candidate IDs without guessing.", + OutputSchema: topologyOutputSchema[ModuleContextOutput](), + }, handleGetModuleContext) + // Tool: get_handoff - Build/read cross-agent handoff artifact mcp.AddTool(server, &mcp.Tool{ Name: "get_handoff", @@ -607,6 +645,11 @@ func handleStatus(ctx context.Context, req *mcp.CallToolRequest, input EmptyInpu watchStatus = fmt.Sprintf("%d active: %s", activeWatchers, strings.Join(watchedPaths, ", ")) } + var inventory strings.Builder + for _, tool := range statusTools { + fmt.Fprintf(&inventory, " %-18s - %s\n", tool.name, tool.description) + } + return textResult(fmt.Sprintf(`codemap MCP server v%s Status: connected Local filesystem access: enabled @@ -614,7 +657,8 @@ Working directory: %s Home directory: %s Active watchers: %s -Tools: run tools/list for the full tool inventory.`, buildinfo.Current(), cwd, home, watchStatus)), nil, nil + Available tools: +%s`, buildinfo.Current(), cwd, home, watchStatus, strings.TrimSuffix(inventory.String(), "\n"))), nil, nil } func statusHandler(guidance string) func(context.Context, *mcp.CallToolRequest, EmptyInput) (*mcp.CallToolResult, any, error) { diff --git a/mcp/surface_hygiene_test.go b/mcp/surface_hygiene_test.go index 73b7779..ab07bec 100644 --- a/mcp/surface_hygiene_test.go +++ b/mcp/surface_hygiene_test.go @@ -198,8 +198,10 @@ func TestStatusInventoryExactlyMatchesRegisteredTools(t *testing.T) { "get_handoff", "get_hubs", "get_importers", + "get_module_context", "get_skill", "get_structure", + "get_topology", "get_working_set", "list_projects", "list_skills", diff --git a/mcp/topology.go b/mcp/topology.go new file mode 100644 index 0000000..d5dfefe --- /dev/null +++ b/mcp/topology.go @@ -0,0 +1,191 @@ +package codemapmcp + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + + "codemap/internal/projectpath" + "codemap/limits" + "codemap/topology" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type TopologyInput struct { + Path string `json:"path" jsonschema:"Path to the project directory"` + Ecosystem string `json:"ecosystem,omitempty" jsonschema:"Optional provider/ecosystem filter such as jvm or swiftpm"` +} + +type ModuleContextInput struct { + Path string `json:"path" jsonschema:"Path to the project directory"` + Module string `json:"module,omitempty" jsonschema:"Exact topology module ID or unique display name"` + File string `json:"file,omitempty" jsonschema:"Repository-relative file path whose owning module should be resolved"` +} + +type ModuleContextOutput struct { + ID topology.ID `json:"id,omitempty"` + Node *topology.Node `json:"node,omitempty"` + File string `json:"file,omitempty"` + Candidates []topology.ID `json:"candidates,omitempty"` + Dependencies []topology.Edge `json:"dependencies,omitempty"` + Dependents []topology.Edge `json:"dependents,omitempty"` + Members []string `json:"members,omitempty"` + Hub bool `json:"hub"` +} + +type TopologyOutput struct { + Nodes map[topology.ID]topology.Node `json:"nodes"` + Dependencies map[topology.ID][]topology.Edge `json:"dependencies"` + Dependents map[topology.ID][]topology.Edge `json:"dependents"` + Members map[topology.ID][]string `json:"members"` + Owners map[string][]topology.ID `json:"owners"` + Coverage topology.Coverage `json:"coverage"` + Truncated bool `json:"truncated,omitempty"` + TotalNodes int `json:"total_nodes,omitempty"` + TotalDependencies int `json:"total_dependencies,omitempty"` + TotalMembers int `json:"total_members,omitempty"` + TotalOwners int `json:"total_owners,omitempty"` + TotalCoverageIssues *int `json:"total_coverage_issues,omitempty"` +} + +var buildTopologyGraphOnly = topology.BuildGraph + +func handleGetTopology(ctx context.Context, req *mcp.CallToolRequest, input TopologyInput) (*mcp.CallToolResult, any, error) { + root, err := topologyProjectRoot(input.Path) + if err != nil { + return errorResult("Invalid path: " + err.Error()), nil, nil + } + graph, _, err := buildTopologyGraphOnly(ctx, root) + if err != nil { + return errorResult("Could not build topology: " + err.Error()), nil, nil + } + graph = topology.FilterGraph(graph, input.Ecosystem) + return textResult(topology.FormatGraph(graph, "")), boundedTopologyOutput(graph), nil +} + +func boundedTopologyOutput(graph *topology.Graph) TopologyOutput { + output := TopologyOutput{ + Nodes: graph.Nodes, Dependencies: graph.Dependencies, Dependents: graph.Dependents, + Members: graph.Members, Owners: graph.Owners, Coverage: graph.Coverage, + } + if encoded, err := json.Marshal(output); err == nil && len(encoded) <= limits.MaxContextOutputBytes { + return output + } + + coverageIssueCount := len(graph.Coverage.Issues) + output = TopologyOutput{ + Nodes: make(map[topology.ID]topology.Node), + Dependencies: make(map[topology.ID][]topology.Edge), + Dependents: make(map[topology.ID][]topology.Edge), + Members: make(map[topology.ID][]string), + Owners: make(map[string][]topology.ID), + Coverage: topology.Coverage{Status: graph.Coverage.Status}, + Truncated: true, + TotalNodes: len(graph.Nodes), + TotalDependencies: topologyEdgeCount(graph.Dependencies), + TotalMembers: topologyMemberCount(graph.Members), + TotalOwners: len(graph.Owners), + TotalCoverageIssues: &coverageIssueCount, + } + ids := make([]string, 0, len(graph.Nodes)) + for id := range graph.Nodes { + ids = append(ids, string(id)) + } + sort.Strings(ids) + for _, rawID := range ids { + id := topology.ID(rawID) + output.Nodes[id] = graph.Nodes[id] + encoded, err := json.Marshal(output) + if err != nil || len(encoded) > limits.MaxContextOutputBytes { + delete(output.Nodes, id) + break + } + } + return output +} + +func topologyEdgeCount(edges map[topology.ID][]topology.Edge) int { + total := 0 + for _, entries := range edges { + total += len(entries) + } + return total +} + +func topologyMemberCount(members map[topology.ID][]string) int { + total := 0 + for _, entries := range members { + total += len(entries) + } + return total +} + +func handleGetModuleContext(ctx context.Context, req *mcp.CallToolRequest, input ModuleContextInput) (*mcp.CallToolResult, any, error) { + if (input.Module == "") == (input.File == "") { + return errorResult("Specify either module or file, but not both."), nil, nil + } + root, err := topologyProjectRoot(input.Path) + if err != nil { + return errorResult("Invalid path: " + err.Error()), nil, nil + } + graph, _, err := buildTopologyGraphOnly(ctx, root) + if err != nil { + return errorResult("Could not build topology: " + err.Error()), nil, nil + } + text, err := topology.FormatModuleContext(graph, input.Module, input.File) + if err != nil { + return errorResult(err.Error()), nil, nil + } + return textResult(text), structuredModuleContext(graph, input), nil +} + +func topologyProjectRoot(path string) (string, error) { + if strings.HasPrefix(path, "~/") { + path = filepath.Join(os.Getenv("HOME"), path[2:]) + } + selection, err := projectpath.Select(path) + if err != nil { + return "", err + } + return selection.ProjectRoot, nil +} + +func structuredModuleContext(graph *topology.Graph, input ModuleContextInput) ModuleContextOutput { + result := ModuleContextOutput{} + var node topology.Node + var candidates []topology.ID + var ok bool + if input.File != "" { + result.File = filepath.Clean(input.File) + candidates = graph.OwnersForFile(result.File) + if len(candidates) == 1 { + node, ok = graph.Nodes[candidates[0]] + } + } else { + node, candidates, ok = graph.SelectModule(input.Module) + } + if !ok { + result.Candidates = candidates + return result + } + result.ID = node.ID + result.Node = &node + result.Dependencies = graph.Dependencies[node.ID] + result.Dependents = graph.Dependents[node.ID] + result.Members = graph.Members[node.ID] + result.Hub = graph.IsHub(node.ID) + return result +} + +func topologyOutputSchema[T any]() *jsonschema.Schema { + schema, err := jsonschema.For[T](nil) + if err != nil { + panic(err) + } + return schema +} diff --git a/mcp/topology_test.go b/mcp/topology_test.go new file mode 100644 index 0000000..bdcf996 --- /dev/null +++ b/mcp/topology_test.go @@ -0,0 +1,188 @@ +package codemapmcp + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "codemap/limits" + "codemap/topology" +) + +func TestHandleGetTopologyAndModuleContext(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + nested := filepath.Join(root, "nested") + if err := os.Mkdir(nested, 0o755); err != nil { + t.Fatal(err) + } + var builtRoot string + original := buildTopologyGraphOnly + buildTopologyGraphOnly = func(_ context.Context, gotRoot string) (*topology.Graph, topology.CacheIdentity, error) { + builtRoot = gotRoot + node := topology.Node{ + ID: topology.ID("jvm:settings.gradle.kts:app"), + Kind: topology.NodeKind("gradle-project"), + Name: "app", + Manifest: "settings.gradle.kts", + Root: "app", + Provider: "jvm", + } + return &topology.Graph{ + Nodes: map[topology.ID]topology.Node{node.ID: node}, + Dependencies: map[topology.ID][]topology.Edge{}, + Dependents: map[topology.ID][]topology.Edge{}, + Members: map[topology.ID][]string{node.ID: {"app/src/Main.kt"}}, + Owners: map[string][]topology.ID{"app/src/Main.kt": {node.ID}}, + Coverage: topology.Coverage{Status: topology.CoverageComplete}, + }, topology.CacheIdentity{}, nil + } + t.Cleanup(func() { buildTopologyGraphOnly = original }) + + graphResult, structured, err := handleGetTopology(context.Background(), nil, TopologyInput{Path: nested, Ecosystem: "jvm"}) + if err != nil { + t.Fatal(err) + } + wantRoot, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + if builtRoot != wantRoot { + t.Fatalf("built topology at %q, want %q", builtRoot, wantRoot) + } + encoded, err := json.Marshal(structured) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(encoded), "jvm:settings.gradle.kts:app") { + t.Fatalf("missing structured topology: %s", encoded) + } + if got := resultText(t, graphResult); !strings.Contains(got, "PROJECT TOPOLOGY") || + !strings.Contains(got, "jvm:settings.gradle.kts:app") { + t.Fatalf("unexpected get_topology output:\n%s", got) + } + + moduleResult, _, err := handleGetModuleContext(context.Background(), nil, ModuleContextInput{ + Path: root, + File: "app/src/Main.kt", + }) + if err != nil { + t.Fatal(err) + } + if got := resultText(t, moduleResult); !strings.Contains(got, "Module: app") { + t.Fatalf("unexpected get_module_context output:\n%s", got) + } + _, moduleStructured, err := handleGetModuleContext(context.Background(), nil, ModuleContextInput{Path: root, Module: "app"}) + if err != nil { + t.Fatal(err) + } + moduleJSON, err := json.Marshal(moduleStructured) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(moduleJSON), `"file"`) || !strings.Contains(string(moduleJSON), `"hub":false`) { + t.Fatalf("module structured fields are ambiguous: %s", moduleJSON) + } +} + +func TestTopologyHandlerRejectsUnsafeProjectBeforeBuilding(t *testing.T) { + root := t.TempDir() + if err := os.Symlink("missing", filepath.Join(root, ".git")); err != nil { + t.Fatal(err) + } + called := false + original := buildTopologyGraphOnly + buildTopologyGraphOnly = func(context.Context, string) (*topology.Graph, topology.CacheIdentity, error) { + called = true + return &topology.Graph{}, topology.CacheIdentity{}, nil + } + t.Cleanup(func() { buildTopologyGraphOnly = original }) + result, _, err := handleGetTopology(context.Background(), nil, TopologyInput{Path: root}) + if err != nil { + t.Fatal(err) + } + if !result.IsError || called { + t.Fatalf("unsafe project: IsError=%v builderCalled=%v", result.IsError, called) + } +} + +func TestTopologyStructuredContentIsBounded(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + graph := &topology.Graph{ + Nodes: make(map[topology.ID]topology.Node), + Dependencies: make(map[topology.ID][]topology.Edge), + Dependents: make(map[topology.ID][]topology.Edge), + Members: make(map[topology.ID][]string), + Owners: make(map[string][]topology.ID), + Coverage: topology.Coverage{Status: topology.CoveragePartial, Issues: []topology.Issue{ + {Provider: "go", Code: "metadata", Message: "module metadata was incomplete"}, + }}, + } + for i := 0; i < 256; i++ { + id := topology.ID(fmt.Sprintf("go:module-%03d", i)) + graph.Nodes[id] = topology.Node{ID: id, Name: strings.Repeat("module", 512), Provider: "go"} + for member := 0; member < 32; member++ { + file := fmt.Sprintf("module-%03d/%s-%03d.go", i, strings.Repeat("path", 64), member) + graph.Members[id] = append(graph.Members[id], file) + graph.Owners[file] = []topology.ID{id} + } + } + original := buildTopologyGraphOnly + buildTopologyGraphOnly = func(context.Context, string) (*topology.Graph, topology.CacheIdentity, error) { + return graph, topology.CacheIdentity{}, nil + } + t.Cleanup(func() { buildTopologyGraphOnly = original }) + + _, structured, err := handleGetTopology(context.Background(), nil, TopologyInput{Path: root}) + if err != nil { + t.Fatal(err) + } + encoded, err := json.Marshal(structured) + if err != nil { + t.Fatal(err) + } + if len(encoded) > limits.MaxContextOutputBytes { + t.Fatalf("structured topology is %d bytes, limit %d", len(encoded), limits.MaxContextOutputBytes) + } + if !strings.Contains(string(encoded), `"truncated":true`) || + !strings.Contains(string(encoded), `"total_nodes":256`) || + !strings.Contains(string(encoded), `"total_coverage_issues":1`) { + t.Fatalf("bounded topology omits truncation metadata: %s", encoded) + } +} + +func TestStatusListsTopologyTools(t *testing.T) { + result, _, err := handleStatus(context.Background(), nil, EmptyInput{}) + if err != nil { + t.Fatal(err) + } + text := resultText(t, result) + for _, name := range []string{"get_topology", "get_module_context"} { + if !strings.Contains(text, name) { + t.Fatalf("status omits %s:\n%s", name, text) + } + } +} + +func TestHandleGetModuleContextRejectsConflictingSelectors(t *testing.T) { + result, _, err := handleGetModuleContext(context.Background(), nil, ModuleContextInput{ + Path: "/repo", + Module: "app", + File: "app/src/Main.kt", + }) + if err != nil { + t.Fatal(err) + } + if !result.IsError || !strings.Contains(resultText(t, result), "either module or file") { + t.Fatalf("unexpected selector result: error=%v text=%q", result.IsError, resultText(t, result)) + } +} diff --git a/topology/format.go b/topology/format.go new file mode 100644 index 0000000..c060337 --- /dev/null +++ b/topology/format.go @@ -0,0 +1,217 @@ +package topology + +import ( + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strings" +) + +func FormatGraph(graph *Graph, ecosystem string) string { + graph = FilterGraph(graph, ecosystem) + if graph == nil { + return "PROJECT TOPOLOGY\nCoverage: unavailable\nModules: 0\n" + } + + var builder strings.Builder + builder.WriteString("PROJECT TOPOLOGY\n") + _, _ = fmt.Fprintf(&builder, "Coverage: %s\n", graph.Coverage.Status) + _, _ = fmt.Fprintf(&builder, "Modules: %d\n", len(graph.Nodes)) + _, _ = fmt.Fprintf(&builder, "Hubs: %d\n", len(graph.HubNodes())) + if len(graph.Coverage.Issues) > 0 { + _, _ = fmt.Fprintf(&builder, "Coverage notes: %d\n", len(graph.Coverage.Issues)) + } + builder.WriteString("\n") + + for _, id := range sortedNodeIDs(graph.Nodes) { + node := graph.Nodes[id] + _, _ = fmt.Fprintf(&builder, "- %s (%s) [%s]\n", node.Name, node.ID, node.Kind) + _, _ = fmt.Fprintf(&builder, " root: %s; members: %d", node.Root, len(graph.Members[id])) + if graph.IsHub(id) { + builder.WriteString("; hub") + } + builder.WriteString("\n") + for _, edge := range graph.Dependencies[id] { + target, ok := graph.Nodes[edge.To] + if !ok { + continue + } + qualifiers := []string{string(edge.Kind)} + if edge.Scope != "" { + qualifiers = append(qualifiers, string(edge.Scope)) + } + _, _ = fmt.Fprintf(&builder, " %s -> %s [%s]\n", node.Name, target.Name, strings.Join(qualifiers, ", ")) + } + } + return builder.String() +} + +func FormatGraphJSON(graph *Graph, ecosystem string) ([]byte, error) { + filtered := FilterGraph(graph, ecosystem) + return json.MarshalIndent(filtered, "", " ") +} + +func FormatModuleContext(graph *Graph, module, file string) (string, error) { + if graph == nil { + return "", fmt.Errorf("topology is unavailable") + } + if module != "" && file != "" { + return "", fmt.Errorf("select either module or file, not both") + } + if module == "" && file == "" { + return "", fmt.Errorf("module or file is required") + } + + selectedFile := "" + var node Node + if file != "" { + selectedFile = filepath.Clean(file) + owners := graph.OwnersForFile(selectedFile) + switch len(owners) { + case 0: + return "", fmt.Errorf("no topology module owns %s", selectedFile) + case 1: + node = graph.Nodes[owners[0]] + default: + var builder strings.Builder + _, _ = fmt.Fprintf(&builder, "Ambiguous ownership for %s\n", selectedFile) + builder.WriteString("Candidate module IDs:\n") + for _, id := range owners { + builder.WriteString("- " + string(id) + "\n") + } + builder.WriteString("Retry with an exact module ID.\n") + return builder.String(), nil + } + } else { + var candidates []ID + var ok bool + node, candidates, ok = graph.SelectModule(module) + if !ok { + if len(candidates) == 0 { + return "", fmt.Errorf("topology module %q was not found", module) + } + var builder strings.Builder + _, _ = fmt.Fprintf(&builder, "Ambiguous module %q\n", module) + builder.WriteString("Candidate module IDs:\n") + for _, id := range candidates { + builder.WriteString("- " + string(id) + "\n") + } + builder.WriteString("Retry with an exact module ID.\n") + return builder.String(), nil + } + } + + var builder strings.Builder + builder.WriteString("MODULE CONTEXT\n") + _, _ = fmt.Fprintf(&builder, "Module: %s\n", node.Name) + _, _ = fmt.Fprintf(&builder, "ID: %s\n", node.ID) + _, _ = fmt.Fprintf(&builder, "Kind: %s\n", node.Kind) + _, _ = fmt.Fprintf(&builder, "Provider: %s\n", node.Provider) + _, _ = fmt.Fprintf(&builder, "Root: %s\n", node.Root) + _, _ = fmt.Fprintf(&builder, "Manifest: %s\n", node.Manifest) + _, _ = fmt.Fprintf(&builder, "Members: %d\n", len(graph.Members[node.ID])) + _, _ = fmt.Fprintf(&builder, "Hub: %s\n", yesNo(graph.IsHub(node.ID))) + if selectedFile != "" { + _, _ = fmt.Fprintf(&builder, "File: %s\n", selectedFile) + _, _ = fmt.Fprintf(&builder, "Member of hub module: %s\n", yesNo(graph.IsHub(node.ID))) + } + _, _ = fmt.Fprintf(&builder, "Dependencies: %d\n", len(graph.Dependencies[node.ID])) + for _, edge := range graph.Dependencies[node.ID] { + builder.WriteString(formatContextEdge(graph, edge, "->")) + } + _, _ = fmt.Fprintf(&builder, "Dependents: %d\n", len(graph.Dependents[node.ID])) + for _, edge := range graph.Dependents[node.ID] { + builder.WriteString(formatContextEdge(graph, edge, "<-")) + } + return builder.String(), nil +} + +func FilterGraph(graph *Graph, ecosystem string) *Graph { + if graph == nil || strings.TrimSpace(ecosystem) == "" { + return graph + } + ecosystem = strings.ToLower(strings.TrimSpace(ecosystem)) + nodes := make([]Node, 0, len(graph.Nodes)) + selected := make(map[ID]bool) + for _, node := range graph.Nodes { + if strings.EqualFold(node.Provider, ecosystem) { + nodes = append(nodes, node) + selected[node.ID] = true + } + } + var edges []Edge + for from, outgoing := range graph.Dependencies { + if !selected[from] { + continue + } + for _, edge := range outgoing { + if selected[edge.To] { + edges = append(edges, edge) + } + } + } + members := make(map[ID][]string) + for id := range selected { + members[id] = append([]string(nil), graph.Members[id]...) + } + var issues []Issue + for _, issue := range graph.Coverage.Issues { + if issue.Provider == "" || strings.EqualFold(issue.Provider, ecosystem) { + issues = append(issues, issue) + } + } + return MergeFragments(".", []Fragment{{ + Provider: ecosystem, + Nodes: nodes, + Edges: edges, + Members: members, + Coverage: Coverage{Status: graph.Coverage.Status, Issues: issues}, + }}) +} + +func formatContextEdge(graph *Graph, edge Edge, arrow string) string { + from := graph.Nodes[edge.From] + to := graph.Nodes[edge.To] + if arrow == "<-" { + return fmt.Sprintf("- %s <- %s [%s]\n", to.Name, from.Name, edgeDescription(edge)) + } + return fmt.Sprintf("- %s -> %s [%s]\n", from.Name, to.Name, edgeDescription(edge)) +} + +func edgeDescription(edge Edge) string { + parts := []string{string(edge.Kind)} + if edge.Scope != "" { + parts = append(parts, string(edge.Scope)) + } + if edge.Evidence.Manifest != "" { + evidence := edge.Evidence.Manifest + if edge.Evidence.Line > 0 { + evidence += fmt.Sprintf(":%d", edge.Evidence.Line) + } + parts = append(parts, evidence) + } + if edge.Conditional { + parts = append(parts, "conditional") + } + if edge.Incomplete { + parts = append(parts, "incomplete") + } + return strings.Join(parts, ", ") +} + +func sortedNodeIDs(nodes map[ID]Node) []ID { + ids := make([]ID, 0, len(nodes)) + for id := range nodes { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} + +func yesNo(value bool) string { + if value { + return "yes" + } + return "no" +} diff --git a/topology/format_test.go b/topology/format_test.go new file mode 100644 index 0000000..ec82485 --- /dev/null +++ b/topology/format_test.go @@ -0,0 +1,147 @@ +package topology + +import ( + "strings" + "testing" +) + +func TestFormatTopologyIsDeterministicAndFilterable(t *testing.T) { + root := t.TempDir() + app := testNode("jvm:settings.gradle.kts:app", "app") + app.Provider = "jvm" + core := testNode("jvm:settings.gradle.kts:core", "core") + core.Provider = "jvm" + swift := testNode("swiftpm:Package.swift:Core", "Core") + swift.Provider = "swiftpm" + swift.Manifest = "Package.swift" + swift.Root = "Sources/Core" + graph := MergeFragments(root, []Fragment{{ + Provider: "mixed", + Nodes: []Node{swift, core, app}, + Edges: []Edge{{ + From: app.ID, + To: core.ID, + Kind: EdgeDependency, + Scope: EdgeScope("production"), + Evidence: Evidence{Manifest: "settings.gradle.kts", Line: 4}, + }}, + Members: map[ID][]string{ + app.ID: {"modules/app/src/Main.kt"}, + core.ID: {"modules/core/src/Core.kt"}, + swift.ID: {"Sources/Core/Core.swift"}, + }, + Coverage: Coverage{Status: CoverageComplete}, + }}) + + first := FormatGraph(graph, "") + second := FormatGraph(graph, "") + if first != second { + t.Fatalf("format changed between calls:\nfirst:\n%s\nsecond:\n%s", first, second) + } + for _, want := range []string{ + "PROJECT TOPOLOGY", + "Coverage: complete", + "jvm:settings.gradle.kts:app", + "jvm:settings.gradle.kts:core", + "swiftpm:Package.swift:Core", + "app -> core [dependency, production]", + } { + if !strings.Contains(first, want) { + t.Fatalf("formatted graph missing %q:\n%s", want, first) + } + } + + jvm := FormatGraph(graph, "jvm") + if strings.Contains(jvm, string(swift.ID)) || !strings.Contains(jvm, string(app.ID)) { + t.Fatalf("ecosystem filter produced:\n%s", jvm) + } + + jsonA, err := FormatGraphJSON(graph, "") + if err != nil { + t.Fatal(err) + } + jsonB, err := FormatGraphJSON(graph, "") + if err != nil { + t.Fatal(err) + } + if string(jsonA) != string(jsonB) { + t.Fatalf("JSON is nondeterministic:\n%s\n%s", jsonA, jsonB) + } +} + +func TestFormatModuleContextFailsClosedForAmbiguousSelectors(t *testing.T) { + root := t.TempDir() + left := testNode("jvm:left:common", "common") + right := testNode("jvm:right:common", "common") + graph := MergeFragments(root, []Fragment{{ + Provider: "jvm", + Nodes: []Node{right, left}, + Members: map[ID][]string{ + left.ID: {"shared/Main.kt"}, + right.ID: {"shared/Main.kt"}, + }, + Coverage: Coverage{Status: CoverageComplete}, + }}) + + moduleText, err := FormatModuleContext(graph, "common", "") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(moduleText, "Ambiguous module") || + !strings.Contains(moduleText, string(left.ID)) || + !strings.Contains(moduleText, string(right.ID)) || + strings.Contains(moduleText, "Hub:") { + t.Fatalf("ambiguous module output asserted a selection:\n%s", moduleText) + } + + fileText, err := FormatModuleContext(graph, "", "shared/Main.kt") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(fileText, "Ambiguous ownership") || + !strings.Contains(fileText, string(left.ID)) || + !strings.Contains(fileText, string(right.ID)) || + strings.Contains(fileText, "Member of hub module") { + t.Fatalf("ambiguous file output asserted hub membership:\n%s", fileText) + } +} + +func TestFormatModuleContextReportsUniqueHubMembership(t *testing.T) { + root := t.TempDir() + hub := testNode("jvm:settings.gradle.kts:core", "core") + nodes := []Node{hub} + members := map[ID][]string{hub.ID: {"core/src/Core.kt"}} + var edges []Edge + for _, name := range []string{"a", "b", "c"} { + node := testNode("jvm:settings.gradle.kts:"+name, name) + nodes = append(nodes, node) + members[node.ID] = []string{name + "/src/Main.kt"} + edges = append(edges, Edge{ + From: node.ID, + To: hub.ID, + Kind: EdgeDependency, + Evidence: Evidence{Manifest: "settings.gradle.kts"}, + }) + } + graph := MergeFragments(root, []Fragment{{ + Provider: "jvm", + Nodes: nodes, + Edges: edges, + Members: members, + Coverage: Coverage{Status: CoverageComplete}, + }}) + + text, err := FormatModuleContext(graph, "", "core/src/Core.kt") + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "Module: core", + "Member of hub module: yes", + "Dependents: 3", + } { + if !strings.Contains(text, want) { + t.Fatalf("module context missing %q:\n%s", want, text) + } + } +} diff --git a/topology_cli.go b/topology_cli.go new file mode 100644 index 0000000..33f2adb --- /dev/null +++ b/topology_cli.go @@ -0,0 +1,46 @@ +package main + +import ( + "context" + "fmt" + "io" + + "codemap/internal/projectpath" + "codemap/topology" +) + +var buildTopologyGraphOnly = topology.BuildGraph + +func canonicalTopologyRoot(root string) (string, error) { + selection, err := projectpath.Select(root) + if err != nil { + return "", err + } + return selection.ProjectRoot, nil +} + +func runTopologyMode(ctx context.Context, root, module, file, ecosystem string, jsonMode bool, output io.Writer) error { + graph, _, err := buildTopologyGraphOnly(ctx, root) + if err != nil { + return err + } + graph = topology.FilterGraph(graph, ecosystem) + if module != "" || file != "" { + text, err := topology.FormatModuleContext(graph, module, file) + if err != nil { + return err + } + _, err = fmt.Fprint(output, text) + return err + } + if jsonMode { + data, err := topology.FormatGraphJSON(graph, "") + if err != nil { + return err + } + _, err = fmt.Fprintln(output, string(data)) + return err + } + _, err = fmt.Fprint(output, topology.FormatGraph(graph, "")) + return err +} diff --git a/topology_cli_test.go b/topology_cli_test.go new file mode 100644 index 0000000..0dc5aa2 --- /dev/null +++ b/topology_cli_test.go @@ -0,0 +1,124 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "codemap/topology" +) + +func TestCanonicalTopologyRootSelectsRepositoryFromSubdirectory(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + nested := filepath.Join(root, "nested") + if err := os.Mkdir(nested, 0o755); err != nil { + t.Fatal(err) + } + got, err := canonicalTopologyRoot(nested) + if err != nil { + t.Fatal(err) + } + want, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("topology root = %q, want %q", got, want) + } +} + +func TestRunTopologyModeRendersGraphAndModule(t *testing.T) { + original := buildTopologyGraphOnly + buildTopologyGraphOnly = func(context.Context, string) (*topology.Graph, topology.CacheIdentity, error) { + node := topology.Node{ + ID: topology.ID("jvm:settings.gradle.kts:app"), + Kind: topology.NodeKind("gradle-project"), + Name: "app", + Manifest: "settings.gradle.kts", + Root: "app", + Provider: "jvm", + } + return &topology.Graph{ + Nodes: map[topology.ID]topology.Node{node.ID: node}, + Dependencies: map[topology.ID][]topology.Edge{}, + Dependents: map[topology.ID][]topology.Edge{}, + Members: map[topology.ID][]string{node.ID: {"app/src/Main.kt"}}, + Owners: map[string][]topology.ID{"app/src/Main.kt": {node.ID}}, + Coverage: topology.Coverage{Status: topology.CoverageComplete}, + }, topology.CacheIdentity{}, nil + } + t.Cleanup(func() { buildTopologyGraphOnly = original }) + + var graphOut bytes.Buffer + if err := runTopologyMode(context.Background(), "/repo", "", "", "", false, &graphOut); err != nil { + t.Fatal(err) + } + if !strings.Contains(graphOut.String(), "PROJECT TOPOLOGY") || + !strings.Contains(graphOut.String(), "jvm:settings.gradle.kts:app") { + t.Fatalf("unexpected topology output:\n%s", graphOut.String()) + } + + var moduleOut bytes.Buffer + if err := runTopologyMode(context.Background(), "/repo", "app", "", "", false, &moduleOut); err != nil { + t.Fatal(err) + } + if !strings.Contains(moduleOut.String(), "Module: app") { + t.Fatalf("unexpected module output:\n%s", moduleOut.String()) + } + + var fileOut bytes.Buffer + if err := runTopologyMode(context.Background(), "/repo", "", "app/src/Main.kt", "jvm", false, &fileOut); err != nil { + t.Fatal(err) + } + if !strings.Contains(fileOut.String(), "Module: app") { + t.Fatalf("unexpected file-owner output:\n%s", fileOut.String()) + } +} + +func TestTopologyCLIFileSelectorAndEcosystemContract(t *testing.T) { + root := t.TempDir() + output, err := runCodemap("--topology", "--ecosystem", "jvm", root) + if err != nil { + t.Fatalf("ecosystem invocation failed: %v\n%s", err, output) + } + output, err = runCodemap("--module", "app", "--module-file", "app/src/Main.kt", root) + if err == nil || !strings.Contains(output, "either --module or --module-file") { + t.Fatalf("conflicting selectors: err=%v output=%q", err, output) + } +} + +func TestTopologyCLIRejectsEcosystemOutsideTopology(t *testing.T) { + output, err := runCodemap("--ecosystem", "jvm", t.TempDir()) + if err == nil || !strings.Contains(output, "--ecosystem requires") { + t.Fatalf("unexpected result: err=%v output=%q", err, output) + } +} + +func TestRunTopologyModeJSONIsValid(t *testing.T) { + original := buildTopologyGraphOnly + buildTopologyGraphOnly = func(context.Context, string) (*topology.Graph, topology.CacheIdentity, error) { + return &topology.Graph{ + Nodes: map[topology.ID]topology.Node{}, + Dependencies: map[topology.ID][]topology.Edge{}, + Dependents: map[topology.ID][]topology.Edge{}, + Members: map[topology.ID][]string{}, + Owners: map[string][]topology.ID{}, + Coverage: topology.Coverage{Status: topology.CoverageUnavailable}, + }, topology.CacheIdentity{}, nil + } + t.Cleanup(func() { buildTopologyGraphOnly = original }) + + var out bytes.Buffer + if err := runTopologyMode(context.Background(), "/repo", "", "", "", true, &out); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(out.String(), "{") || !strings.Contains(out.String(), `"coverage"`) { + t.Fatalf("unexpected JSON output: %s", out.String()) + } +} diff --git a/watch/daemon.go b/watch/daemon.go index ab32b45..e386747 100644 --- a/watch/daemon.go +++ b/watch/daemon.go @@ -14,13 +14,20 @@ import ( "codemap/internal/projectpath" "codemap/limits" "codemap/scanner" + "codemap/topology" "github.com/fsnotify/fsnotify" ) +var ( + buildTopologyGraph = topology.BuildGraph + writeTopologyCache = topology.WriteCacheAt +) + // Daemon is the watch daemon that keeps the graph updated type Daemon struct { root string + configDir string runtimeDir string graph *Graph watcher *fsnotify.Watcher @@ -49,10 +56,11 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) { if canonical, err := filepath.EvalSymlinks(absRoot); err == nil { absRoot = canonical } - runtimeDir, err := projectpath.CheckedRuntimeCodemapDir(absRoot) + selection, err := projectpath.SelectRuntime(absRoot) if err != nil { return nil, fmt.Errorf("resolve runtime state: %w", err) } + runtimeDir := filepath.Join(selection.RuntimeDir, "projects", projectpath.ProjectKey(selection.ProjectRoot)) watcher, err := fsnotify.NewWatcher() if err != nil { @@ -61,7 +69,6 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) { gitCache := scanner.NewGitIgnoreCache(absRoot) - // Check if git repo (fast, one-time) isGitRepo := false if _, err := os.Stat(filepath.Join(absRoot, ".git")); err == nil { isGitRepo = true @@ -69,6 +76,7 @@ func NewDaemon(root string, verbose bool) (*Daemon, error) { d := &Daemon{ root: absRoot, + configDir: selection.PolicyDir, runtimeDir: runtimeDir, watcher: watcher, gitCache: gitCache, @@ -100,7 +108,7 @@ func (d *Daemon) Start() error { } // Ensure the config directory exists; it is watched so config edits can // refresh the configured-file inventory. - configDir := projectpath.CodemapDir(d.root) + configDir := d.configDir if err := os.MkdirAll(configDir, 0755); err != nil { return fmt.Errorf("failed to create .codemap dir: %w", err) } @@ -110,14 +118,10 @@ func (d *Daemon) Start() error { return fmt.Errorf("initial scan failed: %w", err) } - // Compute dependency graph (best effort). Skip on very large repos to avoid - // expensive startup memory/CPU spikes in background hook flows. + // Compute dependency and topology graphs (best effort). Skip on very large + // repos to avoid expensive startup memory/CPU spikes in background hook flows. fileCount := d.ConfiguredFileCount() - if shouldComputeDependencyGraph(fileCount) { - d.computeDeps() - } else if d.verbose { - fmt.Printf("[watch] Skipping dependency graph for large repo (%d files)\n", fileCount) - } + d.computeInitialGraphs(fileCount) // Add directories to watcher if err := d.addWatchDirs(); err != nil { @@ -140,6 +144,52 @@ func (d *Daemon) Start() error { return nil } +func (d *Daemon) computeInitialGraphs(fileCount int) { + if fileCount > limits.LargeRepoFileCount { + if d.verbose { + fmt.Printf("[watch] Skipping dependency and topology graphs for large repo (%d files)\n", fileCount) + } + return + } + d.computeDeps() + d.computeTopology() +} + +func (d *Daemon) computeTopology() { + start := time.Now() + graph, identity, err := buildTopologyGraph(context.Background(), d.root) + if err != nil { + if d.verbose { + fmt.Printf("[watch] Topology unavailable: %v\n", err) + } + return + } + + d.graph.mu.Lock() + d.graph.Topology = graph + d.graph.TopologyIdentity = identity + d.graph.mu.Unlock() + + if !topology.IsCacheable(graph) { + return + } + envelope := topology.CacheEnvelope{ + Schema: topology.CacheSchemaVersion, + GeneratedAt: time.Now().UTC(), + Identity: identity, + Graph: graph, + } + if err := writeTopologyCache(d.runtimeDir, envelope); err != nil { + if d.verbose { + fmt.Printf("[watch] Topology cache unavailable: %v\n", err) + } + return + } + if d.verbose { + fmt.Printf("[watch] Topology: %d modules in %v\n", len(graph.Nodes), time.Since(start)) + } +} + // Stop gracefully shuts down the daemon func (d *Daemon) Stop() { close(d.done) @@ -270,6 +320,7 @@ func (d *Daemon) refreshConfiguredFiles(resetIgnoreCache bool) error { if shouldComputeDependencyGraph(len(configured)) { d.computeDeps() } + d.computeTopology() return nil } diff --git a/watch/events.go b/watch/events.go index 3edc9f4..9084ca2 100644 --- a/watch/events.go +++ b/watch/events.go @@ -15,10 +15,13 @@ import ( "codemap/internal/runtimefile" "codemap/limits" "codemap/scanner" + "codemap/topology" "github.com/fsnotify/fsnotify" ) +var isTopologyManifest = topology.IsManifestPath + // eventDebouncer coalesces rapid successive WRITE events for the same path. // Non-WRITE operations are never debounced so create/remove transitions stay accurate. type eventDebouncer struct { @@ -236,11 +239,14 @@ func (d *Daemon) eventLoop() { for _, pending := range debouncer.takeDueBeforeEvent(event, now) { d.handleEvent(pending) } + if d.handleTopologyControlEvent(event) { + continue + } // Allow directory creates through (to add new dirs to watcher) // but skip non-source files otherwise isCreate := event.Op&fsnotify.Create != 0 - if !d.isSourceFile(event.Name) { + if !d.isSourceFile(event.Name) && !isTopologyManifest(event.Name) { // Check if it's a directory create - let those through if isCreate { if info, err := os.Stat(event.Name); err == nil && info.IsDir() { @@ -285,7 +291,7 @@ func (d *Daemon) eventLoop() { func (d *Daemon) filterControlEvent(path string) (resetIgnoreCache, control bool) { clean := filepath.Clean(path) - if clean == filepath.Join(d.root, ".codemap", "config.json") { + if clean == filepath.Join(d.configDir, "config.json") { return false, true } if filepath.Base(clean) == ".gitignore" { @@ -322,6 +328,17 @@ func (d *Daemon) handleConfiguredMembershipEvent(event fsnotify.Event) { } } +func (d *Daemon) handleTopologyControlEvent(event fsnotify.Event) bool { + rel, err := filepath.Rel(d.configDir, event.Name) + if err != nil || filepath.Clean(rel) != "config.json" { + return false + } + if event.Op&(fsnotify.Create|fsnotify.Write|fsnotify.Remove|fsnotify.Rename) != 0 { + d.computeTopology() + } + return true +} + func (d *Daemon) debounceAction(debouncer *eventDebouncer, event fsnotify.Event, now time.Time) debounceAction { if !debouncer.shouldSkip(event, now) { return debounceProcess @@ -410,6 +427,8 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) { Path: relPath, Language: scanner.DetectLanguage(relPath), } + topologyManifest := isTopologyManifest(relPath) + sourceMembershipChange := d.isSourceFile(relPath) && op != "WRITE" // Update graph and calculate deltas d.graph.mu.Lock() @@ -512,6 +531,12 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) { // Find related hot files - connected files also edited recently (last 5 min) event.RelatedHot = d.findRelatedHot(relPath, 5*time.Minute) } + if d.graph.Topology != nil { + event.ModuleIDs = d.graph.Topology.OwnersForFile(relPath) + if len(event.ModuleIDs) == 1 { + event.ModuleDependents = countTopologyDependents(d.graph.Topology, event.ModuleIDs[0]) + } + } d.graph.Events = appendBoundedEvents(d.graph.Events, event) @@ -527,6 +552,10 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) { // Log event d.logEvent(event) + if topologyManifest || sourceMembershipChange { + d.computeTopology() + } + if d.verbose { deltaStr := "" if event.Delta != 0 { @@ -548,6 +577,16 @@ func (d *Daemon) handleEvent(fsEvent fsnotify.Event) { } } +func countTopologyDependents(graph *topology.Graph, id topology.ID) int { + dependents := make(map[topology.ID]bool) + for _, edge := range graph.Dependents[id] { + if edge.Kind == topology.EdgeDependency { + dependents[edge.From] = true + } + } + return len(dependents) +} + // findRelatedHot finds connected files that were also recently edited // Must be called while holding d.graph.mu lock func (d *Daemon) findRelatedHot(path string, window time.Duration) []string { diff --git a/watch/topology_test.go b/watch/topology_test.go new file mode 100644 index 0000000..1b189d9 --- /dev/null +++ b/watch/topology_test.go @@ -0,0 +1,315 @@ +package watch + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "codemap/internal/projectpath" + "codemap/limits" + "codemap/scanner" + "codemap/topology" + + "github.com/fsnotify/fsnotify" +) + +func TestDaemonTopologyFailurePreservesFileGraph(t *testing.T) { + fileGraph := &scanner.FileGraph{ + Root: "/repo", + Imports: map[string][]string{"main.go": {"dep.go"}}, + Importers: map[string][]string{"dep.go": {"main.go"}}, + } + daemon := topologyTestDaemon(t) + daemon.graph.FileGraph = fileGraph + original := buildTopologyGraph + buildTopologyGraph = func(context.Context, string) (*topology.Graph, topology.CacheIdentity, error) { + return nil, topology.CacheIdentity{}, errors.New("bad manifest") + } + t.Cleanup(func() { buildTopologyGraph = original }) + + daemon.computeTopology() + + if daemon.graph.FileGraph != fileGraph { + t.Fatal("topology failure replaced the file graph") + } + if daemon.graph.Topology != nil { + t.Fatalf("topology failure retained graph: %#v", daemon.graph.Topology) + } +} + +func TestLargeRepoStartupSkipsTopology(t *testing.T) { + daemon := topologyTestDaemon(t) + calls := installTopologyBuildCounter(t) + + daemon.computeInitialGraphs(limits.LargeRepoFileCount + 1) + + if *calls != 0 { + t.Fatalf("large-repo startup built topology %d times", *calls) + } +} + +func TestManifestWriteRebuildsTopology(t *testing.T) { + daemon := topologyTestDaemon(t) + path := filepath.Join(daemon.root, "pom.xml") + if err := os.WriteFile(path, []byte(""), 0o644); err != nil { + t.Fatal(err) + } + calls := installTopologyBuildCounter(t) + originalManifest := isTopologyManifest + isTopologyManifest = func(string) bool { return true } + t.Cleanup(func() { isTopologyManifest = originalManifest }) + + daemon.handleEvent(fsnotify.Event{Name: path, Op: fsnotify.Write}) + + if *calls != 1 { + t.Fatalf("topology builds = %d, want 1", *calls) + } +} + +func TestOrdinarySourceWriteReusesTopology(t *testing.T) { + daemon := topologyTestDaemon(t) + path := filepath.Join(daemon.root, "main.go") + if err := os.WriteFile(path, []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + calls := installTopologyBuildCounter(t) + + daemon.handleEvent(fsnotify.Event{Name: path, Op: fsnotify.Write}) + + if *calls != 0 { + t.Fatalf("ordinary write rebuilt topology %d times", *calls) + } +} + +func TestSourceCreateAndRemoveRebuildMembership(t *testing.T) { + daemon := topologyTestDaemon(t) + path := filepath.Join(daemon.root, "new.go") + if err := os.WriteFile(path, []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + calls := installTopologyBuildCounter(t) + + daemon.handleEvent(fsnotify.Event{Name: path, Op: fsnotify.Create}) + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + daemon.handleEvent(fsnotify.Event{Name: path, Op: fsnotify.Remove}) + + if *calls != 2 { + t.Fatalf("topology builds = %d, want create and remove rebuilds", *calls) + } +} + +func TestTopologyConfigEventRebuildsWithoutIngestingStateWrites(t *testing.T) { + daemon := topologyTestDaemon(t) + calls := installTopologyBuildCounter(t) + + configPath := filepath.Join(daemon.root, ".codemap", "config.json") + if handled := daemon.handleTopologyControlEvent(fsnotify.Event{Name: configPath, Op: fsnotify.Write}); !handled { + t.Fatal("config event was not handled as topology control") + } + statePath := filepath.Join(daemon.root, ".codemap", "state.json") + if handled := daemon.handleTopologyControlEvent(fsnotify.Event{Name: statePath, Op: fsnotify.Write}); handled { + t.Fatal("state write was treated as topology control") + } + if *calls != 1 { + t.Fatalf("topology builds = %d, want one config rebuild", *calls) + } + if len(daemon.graph.Events) != 0 { + t.Fatalf("control event leaked into file events: %#v", daemon.graph.Events) + } +} + +func TestTopologyConfigEventUsesSelectedSetupDirectory(t *testing.T) { + root, setup := t.TempDir(), t.TempDir() + if err := os.Mkdir(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + projectpath.SetSetupRoot(setup) + t.Cleanup(projectpath.ResetSetupRoot) + + daemon, err := NewDaemon(root, false) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = daemon.watcher.Close() }) + calls := installTopologyBuildCounter(t) + configPath := filepath.Join(setup, ".codemap", "config.json") + if _, handled := daemon.filterControlEvent(configPath); !handled { + t.Fatal("setup-root config event was not classified as control") + } + if handled := daemon.handleTopologyControlEvent(fsnotify.Event{Name: configPath, Op: fsnotify.Write}); !handled { + t.Fatal("setup-root config event was not handled as topology control") + } + if *calls != 1 { + t.Fatalf("topology builds = %d, want one setup-root config rebuild", *calls) + } +} + +func TestTopologyCacheUsesDaemonRuntimeDirectory(t *testing.T) { + daemon := topologyTestDaemon(t) + runtimeDir := filepath.Join(t.TempDir(), "runtime") + daemon.runtimeDir = runtimeDir + originalBuild := buildTopologyGraph + originalWrite := writeTopologyCache + buildTopologyGraph = func(context.Context, string) (*topology.Graph, topology.CacheIdentity, error) { + return &topology.Graph{ + Nodes: map[topology.ID]topology.Node{}, + Dependencies: map[topology.ID][]topology.Edge{}, + Dependents: map[topology.ID][]topology.Edge{}, + Members: map[topology.ID][]string{}, + Owners: map[string][]topology.ID{}, + Coverage: topology.Coverage{Status: topology.CoverageComplete}, + }, topology.CacheIdentity{Filters: "runtime"}, nil + } + writeTopologyCache = topology.WriteCacheAt + t.Cleanup(func() { + buildTopologyGraph = originalBuild + writeTopologyCache = originalWrite + }) + + daemon.computeTopology() + + if _, err := os.Stat(topology.CachePathAt(runtimeDir)); err != nil { + t.Fatalf("runtime topology cache missing: %v", err) + } + if _, err := os.Stat(topology.CachePath(daemon.root)); !os.IsNotExist(err) { + t.Fatalf("project-local topology cache = %v, want no cache outside runtime", err) + } +} + +func TestTopologyCacheNeverChangesLegacyStateShape(t *testing.T) { + daemon := topologyTestDaemon(t) + node := topology.Node{ID: "jvm:pom.xml:app", Name: "app"} + daemon.graph.Topology = &topology.Graph{ + Nodes: map[topology.ID]topology.Node{node.ID: node}, + Dependencies: map[topology.ID][]topology.Edge{}, + Dependents: map[topology.ID][]topology.Edge{}, + Members: map[topology.ID][]string{node.ID: {"main.go"}}, + Owners: map[string][]topology.ID{"main.go": {node.ID}}, + Coverage: topology.Coverage{Status: topology.CoverageComplete}, + } + daemon.writeState() + + data, err := os.ReadFile(filepath.Join(daemon.root, ".codemap", "state.json")) + if err != nil { + t.Fatal(err) + } + var state map[string]any + if err := json.Unmarshal(data, &state); err != nil { + t.Fatal(err) + } + for _, forbidden := range []string{"topology", "modules", "owners", "affected_modules"} { + if _, ok := state[forbidden]; ok { + t.Fatalf("legacy state gained %q: %s", forbidden, data) + } + } +} + +func TestDaemonDoesNotCacheTransientProviderFailure(t *testing.T) { + daemon := topologyTestDaemon(t) + originalBuild := buildTopologyGraph + originalWrite := writeTopologyCache + writes := 0 + buildTopologyGraph = func(context.Context, string) (*topology.Graph, topology.CacheIdentity, error) { + return &topology.Graph{ + Nodes: map[topology.ID]topology.Node{}, + Dependencies: map[topology.ID][]topology.Edge{}, + Dependents: map[topology.ID][]topology.Edge{}, + Members: map[topology.ID][]string{}, + Owners: map[string][]topology.ID{}, + Coverage: topology.Coverage{ + Status: topology.CoveragePartial, + Issues: []topology.Issue{{Provider: "jvm", Code: "provider-failed", Message: "temporary failure"}}, + }, + }, topology.CacheIdentity{Filters: "test"}, nil + } + writeTopologyCache = func(string, topology.CacheEnvelope) error { + writes++ + return nil + } + t.Cleanup(func() { + buildTopologyGraph = originalBuild + writeTopologyCache = originalWrite + }) + + daemon.computeTopology() + + if writes != 0 { + t.Fatalf("transient provider failure wrote %d cache entries", writes) + } +} + +func TestOldWatchStateLoadsWithoutTopology(t *testing.T) { + root := t.TempDir() + stateDir := projectpath.ProjectRuntimeDir(root) + if err := os.MkdirAll(stateDir, 0o755); err != nil { + t.Fatal(err) + } + data := `{"updated_at":"` + time.Now().UTC().Format(time.RFC3339Nano) + `","file_count":1,"hubs":["main.go"],"importers":{},"imports":{},"recent_events":[]}` + if err := os.WriteFile(filepath.Join(stateDir, "state.json"), []byte(data), 0o644); err != nil { + t.Fatal(err) + } + + state := ReadState(root) + if state == nil || state.FileCount != 1 || len(state.Hubs) != 1 { + t.Fatalf("legacy state = %#v", state) + } +} + +func topologyTestDaemon(t *testing.T) *Daemon { + t.Helper() + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, ".codemap"), 0o755); err != nil { + t.Fatal(err) + } + watcher, err := fsnotify.NewWatcher() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = watcher.Close() }) + return &Daemon{ + root: root, + configDir: filepath.Join(root, ".codemap"), + runtimeDir: filepath.Join(root, ".codemap"), + watcher: watcher, + gitCache: scanner.NewGitIgnoreCache(root), + eventLog: filepath.Join(root, ".codemap", "events.log"), + graph: &Graph{ + Root: root, + Files: make(map[string]*scanner.FileInfo), + DepCtx: make(map[string]*DepContext), + State: make(map[string]*FileState), + Events: make([]Event, 0), + WorkingSet: NewWorkingSet(), + }, + } +} + +func installTopologyBuildCounter(t *testing.T) *int { + t.Helper() + calls := 0 + originalBuild := buildTopologyGraph + originalWrite := writeTopologyCache + buildTopologyGraph = func(context.Context, string) (*topology.Graph, topology.CacheIdentity, error) { + calls++ + return &topology.Graph{ + Nodes: map[topology.ID]topology.Node{}, + Dependencies: map[topology.ID][]topology.Edge{}, + Dependents: map[topology.ID][]topology.Edge{}, + Members: map[topology.ID][]string{}, + Owners: map[string][]topology.ID{}, + Coverage: topology.Coverage{Status: topology.CoverageComplete}, + }, topology.CacheIdentity{Filters: "test"}, nil + } + writeTopologyCache = func(string, topology.CacheEnvelope) error { return nil } + t.Cleanup(func() { + buildTopologyGraph = originalBuild + writeTopologyCache = originalWrite + }) + return &calls +} diff --git a/watch/types.go b/watch/types.go index b4ee08a..7714ba5 100644 --- a/watch/types.go +++ b/watch/types.go @@ -5,6 +5,7 @@ import ( "time" "codemap/scanner" + "codemap/topology" ) // Event represents a file change event with timestamp and structural context @@ -18,10 +19,12 @@ type Event struct { SizeDelta int64 `json:"size_delta,omitempty"` Dirty bool `json:"dirty,omitempty"` // uncommitted changes // Structural context from deps - Importers int `json:"importers,omitempty"` // how many files import this - Imports int `json:"imports,omitempty"` // how many files this imports - IsHub bool `json:"is_hub,omitempty"` // importers >= 3 - RelatedHot []string `json:"related_hot,omitempty"` // connected files also edited recently + Importers int `json:"importers,omitempty"` // how many files import this + Imports int `json:"imports,omitempty"` // how many files this imports + IsHub bool `json:"is_hub,omitempty"` // importers >= 3 + RelatedHot []string `json:"related_hot,omitempty"` // connected files also edited recently + ModuleIDs []topology.ID `json:"module_ids,omitempty"` + ModuleDependents int `json:"module_dependents,omitempty"` } // FileState tracks lightweight per-file state for delta calculations @@ -39,18 +42,20 @@ type DepContext struct { // Graph holds the live code graph state type Graph struct { - mu sync.RWMutex - Root string - Files map[string]*scanner.FileInfo // path -> file info - ConfiguredFiles map[string]struct{} // paths included by the active project filters - FileGraph *scanner.FileGraph // internal file-to-file dependencies - DepCtx map[string]*DepContext // path -> dependency context (precomputed) - State map[string]*FileState // path -> line/size cache for deltas - Events []Event - WorkingSet *WorkingSet // session working set - LastScan time.Time - IsGitRepo bool - HasDeps bool // whether deps were successfully computed + mu sync.RWMutex + Root string + Files map[string]*scanner.FileInfo // path -> file info + ConfiguredFiles map[string]struct{} // paths included by the active project filters + FileGraph *scanner.FileGraph // internal file-to-file dependencies + Topology *topology.Graph // independent project/module topology + TopologyIdentity topology.CacheIdentity // cache identity for the current topology + DepCtx map[string]*DepContext // path -> dependency context (precomputed) + State map[string]*FileState // path -> line/size cache for deltas + Events []Event + WorkingSet *WorkingSet // session working set + LastScan time.Time + IsGitRepo bool + HasDeps bool // whether deps were successfully computed } // State represents the daemon state that hooks can read