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