From e8e68e2558f678277d3a951468c96b0522ec77ea Mon Sep 17 00:00:00 2001 From: tae2089 Date: Sun, 19 Jul 2026 10:54:12 +0900 Subject: [PATCH 01/10] refactor: extract shared ccg ref matcher from CCGRefExists Pull the node-matching query into ccgRefNodeQuery so lint validation and the upcoming cross-ref materialization judge ccg:// targets identically. No behavior change. Co-Authored-By: Claude Fable 5 --- internal/adapters/outbound/graphgorm/docs.go | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/adapters/outbound/graphgorm/docs.go b/internal/adapters/outbound/graphgorm/docs.go index 43e767f..50b45a4 100644 --- a/internal/adapters/outbound/graphgorm/docs.go +++ b/internal/adapters/outbound/graphgorm/docs.go @@ -5,6 +5,8 @@ import ( "context" "strings" + "gorm.io/gorm" + docsapp "github.com/tae2089/code-context-graph/internal/app/docs" "github.com/tae2089/code-context-graph/internal/domain/graph" "github.com/tae2089/code-context-graph/internal/domain/reference" @@ -73,6 +75,15 @@ func (s *Store) QualifiedNameExists(ctx context.Context, namespace, name string) // @intent validate parsed cross-namespace ccg references against graph path and symbol semantics. func (s *Store) CCGRefExists(ctx context.Context, ref reference.Ref) (bool, error) { + var count int64 + err := s.ccgRefNodeQuery(ctx, ref).Count(&count).Error + return count > 0, err +} + +// ccgRefNodeQuery builds the node query matching a parsed ccg:// reference target. +// @intent keep one matcher for every consumer of ccg ref resolution so lint and cross-ref state never disagree. +// @domainRule a path scope matches the file itself or any file under the path; a symbol scope matches name or qualified-name suffix. +func (s *Store) ccgRefNodeQuery(ctx context.Context, ref reference.Ref) *gorm.DB { q := s.db.WithContext(ctx).Model(&graph.Node{}).Where("namespace = ?", ref.Namespace) if ref.Path != "" { if ref.Symbol != "" { @@ -83,7 +94,5 @@ func (s *Store) CCGRefExists(ctx context.Context, ref reference.Ref) (bool, erro } else if ref.Symbol != "" { q = q.Where("name = ? OR qualified_name = ? OR qualified_name LIKE ? OR qualified_name LIKE ?", ref.Symbol, ref.Symbol, "%."+ref.Symbol, "%::"+ref.Symbol) } - var count int64 - err := q.Count(&count).Error - return count > 0, err + return q } From c09fcd172e34fa3738b7926f4ef69421cbce827e Mon Sep 17 00:00:00 2001 From: tae2089 Date: Sun, 19 Jul 2026 10:55:11 +0900 Subject: [PATCH 02/10] feat: materialize ccg:// annotation refs into cross_refs Add a cross_refs table (migration 000015, SQLite and PostgreSQL) that stores every @see ccg://{namespace}/{path}#{symbol} annotation tag as a queryable row with a symbolic target plus derived resolution state (resolved_node_id, status resolved|dead, source annotation). A new crossref.Service syncs the table after every ingest commit: outbound rows are rebuilt from the namespace's current doc tags and inbound rows are re-resolved because replace-style builds regenerate node ids. The sync hook is optional on workflow.Service and wired in CLI build/update, the MCP build tool, and the webhook server runtime. Resolution reuses the matcher shared with lint (ccgRefNodeExists) so dead-ref findings and cross_refs status never disagree. Namespace-scope refs resolve without a node target; path-scope refs prefer the file node; malformed refs are skipped (lint owns reporting). Co-Authored-By: Claude Fable 5 --- internal/adapters/inbound/cli/build.go | 4 + internal/adapters/inbound/cli/update.go | 4 + .../adapters/inbound/mcp/handler_parse.go | 7 +- .../adapters/outbound/graphgorm/crossref.go | 120 ++++++++++++ .../outbound/graphgorm/crossref_test.go | 167 +++++++++++++++++ internal/adapters/outbound/graphgorm/store.go | 1 + internal/app/crossref/service.go | 150 +++++++++++++++ internal/app/crossref/service_test.go | 176 ++++++++++++++++++ internal/app/ingest/workflow/build.go | 3 + .../app/ingest/workflow/crossref_hook_test.go | 160 ++++++++++++++++ internal/app/ingest/workflow/indexer.go | 21 +++ internal/app/ingest/workflow/update.go | 12 +- internal/db/migration/migration.go | 16 +- internal/db/migration/migration_test.go | 40 +++- .../postgres/000015_cross_refs.down.sql | 1 + .../postgres/000015_cross_refs.up.sql | 19 ++ .../sqlite/000015_cross_refs.down.sql | 1 + .../migration/sqlite/000015_cross_refs.up.sql | 19 ++ internal/domain/graph/crossref.go | 38 ++++ internal/runtime/remote/http.go | 4 + 20 files changed, 957 insertions(+), 6 deletions(-) create mode 100644 internal/adapters/outbound/graphgorm/crossref.go create mode 100644 internal/adapters/outbound/graphgorm/crossref_test.go create mode 100644 internal/app/crossref/service.go create mode 100644 internal/app/crossref/service_test.go create mode 100644 internal/app/ingest/workflow/crossref_hook_test.go create mode 100644 internal/db/migration/postgres/000015_cross_refs.down.sql create mode 100644 internal/db/migration/postgres/000015_cross_refs.up.sql create mode 100644 internal/db/migration/sqlite/000015_cross_refs.down.sql create mode 100644 internal/db/migration/sqlite/000015_cross_refs.up.sql create mode 100644 internal/domain/graph/crossref.go diff --git a/internal/adapters/inbound/cli/build.go b/internal/adapters/inbound/cli/build.go index 58caeb8..2ec58f5 100644 --- a/internal/adapters/inbound/cli/build.go +++ b/internal/adapters/inbound/cli/build.go @@ -7,6 +7,7 @@ import ( "github.com/spf13/cobra" "github.com/tae2089/trace" + "github.com/tae2089/code-context-graph/internal/app/crossref" "github.com/tae2089/code-context-graph/internal/app/ingest" "github.com/tae2089/code-context-graph/internal/app/ingest/workflow" requestctx "github.com/tae2089/code-context-graph/internal/ctx" @@ -47,6 +48,9 @@ func newBuildCmd(deps *Deps) *cobra.Command { Walkers: deps.Walkers, Logger: deps.Logger, } + if crossRefStore, ok := deps.Store.(crossref.Store); ok { + svc.CrossRefs = crossref.New(crossRefStore) + } opts := workflow.BuildOptions{ Dir: dir, diff --git a/internal/adapters/inbound/cli/update.go b/internal/adapters/inbound/cli/update.go index 2f5d1fc..b6ef873 100644 --- a/internal/adapters/inbound/cli/update.go +++ b/internal/adapters/inbound/cli/update.go @@ -6,6 +6,7 @@ import ( "github.com/spf13/cobra" "github.com/tae2089/trace" + "github.com/tae2089/code-context-graph/internal/app/crossref" "github.com/tae2089/code-context-graph/internal/app/ingest" "github.com/tae2089/code-context-graph/internal/app/ingest/workflow" requestctx "github.com/tae2089/code-context-graph/internal/ctx" @@ -50,6 +51,9 @@ func newUpdateCmd(deps *Deps) *cobra.Command { Walkers: deps.Walkers, Logger: deps.Logger, } + if crossRefStore, ok := deps.Store.(crossref.Store); ok { + svc.CrossRefs = crossref.New(crossRefStore) + } stats, err := svc.Update(ctx, workflow.UpdateOptions{ BuildOptions: workflow.BuildOptions{ Dir: dir, diff --git a/internal/adapters/inbound/mcp/handler_parse.go b/internal/adapters/inbound/mcp/handler_parse.go index 33a7207..def4e5d 100644 --- a/internal/adapters/inbound/mcp/handler_parse.go +++ b/internal/adapters/inbound/mcp/handler_parse.go @@ -11,6 +11,7 @@ import ( "github.com/tae2089/trace" flowspkg "github.com/tae2089/code-context-graph/internal/app/analyze/flow" + "github.com/tae2089/code-context-graph/internal/app/crossref" "github.com/tae2089/code-context-graph/internal/app/ingest" "github.com/tae2089/code-context-graph/internal/app/ingest/workflow" "github.com/tae2089/code-context-graph/internal/obs" @@ -72,7 +73,7 @@ func (h *handlers) graphService() *workflow.Service { graphStore = candidate } parseCache, _ := h.deps.Build.Store.(ingest.ParseCache) - return &workflow.Service{ + svc := &workflow.Service{ Store: graphStore, UnitOfWork: h.deps.Build.UnitOfWork, Search: h.deps.Build.Search, @@ -80,6 +81,10 @@ func (h *handlers) graphService() *workflow.Service { Parsers: walkers, Logger: h.logger(), } + if crossRefStore, ok := h.deps.Build.Store.(crossref.Store); ok { + svc.CrossRefs = crossref.New(crossRefStore) + } + return svc } // parseProject parses a project directory and stores discovered graph elements. diff --git a/internal/adapters/outbound/graphgorm/crossref.go b/internal/adapters/outbound/graphgorm/crossref.go new file mode 100644 index 0000000..8c09dd1 --- /dev/null +++ b/internal/adapters/outbound/graphgorm/crossref.go @@ -0,0 +1,120 @@ +// @index GORM adapter for materialized cross-namespace reference rows and ccg ref resolution. +package graphgorm + +import ( + "context" + "errors" + + "gorm.io/gorm" + + "github.com/tae2089/trace" + + crossrefapp "github.com/tae2089/code-context-graph/internal/app/crossref" + "github.com/tae2089/code-context-graph/internal/domain/graph" + "github.com/tae2089/code-context-graph/internal/domain/reference" +) + +var _ crossrefapp.Store = (*Store)(nil) + +// ListAnnotationCCGRefs returns every @see ccg:// tag value declared by nodes of one namespace. +// @intent collect the source facts for rebuilding a namespace's outbound cross refs. +func (s *Store) ListAnnotationCCGRefs(ctx context.Context, namespace string) ([]crossrefapp.AnnotationRef, error) { + var rows []crossrefapp.AnnotationRef + err := s.db.WithContext(ctx).Model(&graph.DocTag{}). + Select("annotations.node_id AS node_id, doc_tags.value AS value"). + Joins("JOIN annotations ON annotations.id = doc_tags.annotation_id"). + Joins("JOIN nodes ON nodes.id = annotations.node_id"). + Where("doc_tags.kind = ?", graph.TagSee). + Where("doc_tags.value LIKE ?", reference.Scheme+"://%"). + Where("nodes.namespace = ?", namespace). + Scan(&rows).Error + if err != nil { + return nil, trace.Wrap(err, "list annotation ccg refs") + } + return rows, nil +} + +// ResolveCCGRef resolves a parsed ccg:// reference to a target node id. +// @intent give cross-ref materialization the concrete node identity behind a symbolic reference. +// @domainRule namespace-scope refs resolve with a zero node id when the namespace has any nodes. +// @domainRule path-scope refs prefer the file node of the path; remaining ties resolve to the lowest node id. +func (s *Store) ResolveCCGRef(ctx context.Context, ref reference.Ref) (uint, bool, error) { + if ref.Path == "" && ref.Symbol == "" { + var count int64 + if err := s.db.WithContext(ctx).Model(&graph.Node{}).Where("namespace = ?", ref.Namespace).Count(&count).Error; err != nil { + return 0, false, trace.Wrap(err, "resolve namespace-scope ccg ref") + } + return 0, count > 0, nil + } + var node graph.Node + err := s.ccgRefNodeQuery(ctx, ref). + Order("CASE WHEN kind = 'file' THEN 0 ELSE 1 END, id"). + First(&node).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return 0, false, nil + } + if err != nil { + return 0, false, trace.Wrap(err, "resolve ccg ref") + } + return node.ID, true, nil +} + +// ReplaceCrossRefsFrom atomically replaces every cross ref originating from one namespace. +// @intent make outbound cross-ref state a pure function of the namespace's current annotations. +// @sideEffect deletes and inserts cross_refs rows in one transaction. +func (s *Store) ReplaceCrossRefsFrom(ctx context.Context, fromNamespace string, refs []graph.CrossRef) error { + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Where("from_namespace = ?", fromNamespace).Delete(&graph.CrossRef{}).Error; err != nil { + return err + } + if len(refs) == 0 { + return nil + } + return tx.CreateInBatches(refs, 100).Error + }) + if err != nil { + return trace.Wrap(err, "replace cross refs") + } + return nil +} + +// ListInboundCrossRefs returns refs from other namespaces that target the given namespace. +// @intent select the rows whose resolution may change after this namespace rebuilds. +// @domainRule self-namespace refs are excluded because the outbound rebuild already re-resolved them. +func (s *Store) ListInboundCrossRefs(ctx context.Context, toNamespace string) ([]graph.CrossRef, error) { + var rows []graph.CrossRef + err := s.db.WithContext(ctx). + Where("to_namespace = ? AND from_namespace <> ?", toNamespace, toNamespace). + Order("id"). + Find(&rows).Error + if err != nil { + return nil, trace.Wrap(err, "list inbound cross refs") + } + return rows, nil +} + +// ListOutboundCrossRefs returns every cross ref originating from one namespace. +// @intent expose a namespace's declared external dependencies for listing and analysis. +func (s *Store) ListOutboundCrossRefs(ctx context.Context, fromNamespace string) ([]graph.CrossRef, error) { + var rows []graph.CrossRef + err := s.db.WithContext(ctx). + Where("from_namespace = ?", fromNamespace). + Order("id"). + Find(&rows).Error + if err != nil { + return nil, trace.Wrap(err, "list outbound cross refs") + } + return rows, nil +} + +// UpdateCrossRefResolution updates one row's derived resolution state. +// @intent remap or invalidate a reference after its target namespace rebuilt. +// @sideEffect updates resolved_node_id and status of one cross_refs row. +func (s *Store) UpdateCrossRefResolution(ctx context.Context, id uint, resolvedNodeID *uint, status graph.CrossRefStatus) error { + err := s.db.WithContext(ctx).Model(&graph.CrossRef{}).Where("id = ?", id). + Updates(map[string]any{"resolved_node_id": resolvedNodeID, "status": status}).Error + if err != nil { + return trace.Wrap(err, "update cross ref resolution") + } + return nil +} diff --git a/internal/adapters/outbound/graphgorm/crossref_test.go b/internal/adapters/outbound/graphgorm/crossref_test.go new file mode 100644 index 0000000..95dd87f --- /dev/null +++ b/internal/adapters/outbound/graphgorm/crossref_test.go @@ -0,0 +1,167 @@ +package graphgorm + +import ( + "context" + "testing" + + requestctx "github.com/tae2089/code-context-graph/internal/ctx" + "github.com/tae2089/code-context-graph/internal/domain/graph" + "github.com/tae2089/code-context-graph/internal/domain/reference" +) + +func seedCrossRefNodes(t *testing.T, s *Store, namespace string, nodes []graph.Node) map[string]uint { + t.Helper() + ctx := requestctx.WithNamespace(context.Background(), namespace) + if err := s.UpsertNodes(ctx, nodes); err != nil { + t.Fatalf("seed nodes for %s: %v", namespace, err) + } + stored, err := s.GetNodesByFiles(ctx, uniqueFilePaths(nodes)) + if err != nil { + t.Fatalf("load seeded nodes: %v", err) + } + ids := map[string]uint{} + for _, byFile := range stored { + for _, n := range byFile { + ids[n.QualifiedName] = n.ID + } + } + return ids +} + +func uniqueFilePaths(nodes []graph.Node) []string { + seen := map[string]bool{} + paths := []string{} + for _, n := range nodes { + if !seen[n.FilePath] { + seen[n.FilePath] = true + paths = append(paths, n.FilePath) + } + } + return paths +} + +func TestResolveCCGRef_Scopes(t *testing.T) { + s := setupTestDB(t) + ids := seedCrossRefNodes(t, s, "auth-svc", []graph.Node{ + {QualifiedName: "internal/auth/token.go", Kind: graph.NodeKindFile, Name: "token.go", FilePath: "internal/auth/token.go", StartLine: 1, EndLine: 1}, + {QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken", FilePath: "internal/auth/token.go", StartLine: 10, EndLine: 20}, + {QualifiedName: "auth.RenewToken", Kind: graph.NodeKindFunction, Name: "RenewToken", FilePath: "internal/auth/token.go", StartLine: 30, EndLine: 40}, + }) + ctx := context.Background() + + cases := []struct { + name string + ref reference.Ref + wantID uint + wantOK bool + }{ + {"path and symbol", reference.Ref{Namespace: "auth-svc", Path: "internal/auth/token.go", Symbol: "ValidateToken"}, ids["auth.ValidateToken"], true}, + {"path only prefers file node", reference.Ref{Namespace: "auth-svc", Path: "internal/auth/token.go"}, ids["internal/auth/token.go"], true}, + {"symbol only", reference.Ref{Namespace: "auth-svc", Symbol: "RenewToken"}, ids["auth.RenewToken"], true}, + {"namespace scope resolves without node", reference.Ref{Namespace: "auth-svc"}, 0, true}, + {"missing symbol", reference.Ref{Namespace: "auth-svc", Symbol: "Nope"}, 0, false}, + {"missing namespace", reference.Ref{Namespace: "ghost"}, 0, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + id, ok, err := s.ResolveCCGRef(ctx, tc.ref) + if err != nil { + t.Fatalf("ResolveCCGRef: %v", err) + } + if ok != tc.wantOK || id != tc.wantID { + t.Fatalf("ResolveCCGRef = (%d, %v), want (%d, %v)", id, ok, tc.wantID, tc.wantOK) + } + }) + } +} + +func TestReplaceCrossRefsFrom_ReplacesAndLists(t *testing.T) { + s := setupTestDB(t) + ctx := context.Background() + first := []graph.CrossRef{ + {FromNamespace: "web", FromNodeID: 1, Raw: "ccg://auth-svc/#Old", ToNamespace: "auth-svc", ToSymbol: "Old", Status: graph.CrossRefStatusDead, Source: graph.CrossRefSourceAnnotation}, + } + if err := s.ReplaceCrossRefsFrom(ctx, "web", first); err != nil { + t.Fatalf("first replace: %v", err) + } + second := []graph.CrossRef{ + {FromNamespace: "web", FromNodeID: 2, Raw: "ccg://auth-svc/#New", ToNamespace: "auth-svc", ToSymbol: "New", Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation}, + {FromNamespace: "web", FromNodeID: 3, Raw: "ccg://billing/#Charge", ToNamespace: "billing", ToSymbol: "Charge", Status: graph.CrossRefStatusDead, Source: graph.CrossRefSourceAnnotation}, + } + if err := s.ReplaceCrossRefsFrom(ctx, "web", second); err != nil { + t.Fatalf("second replace: %v", err) + } + + inbound, err := s.ListInboundCrossRefs(ctx, "auth-svc") + if err != nil { + t.Fatalf("ListInboundCrossRefs: %v", err) + } + if len(inbound) != 1 || inbound[0].Raw != "ccg://auth-svc/#New" { + t.Fatalf("inbound after replace = %+v, want only the new auth-svc ref", inbound) + } + + outbound, err := s.ListOutboundCrossRefs(ctx, "web") + if err != nil { + t.Fatalf("ListOutboundCrossRefs: %v", err) + } + if len(outbound) != 2 { + t.Fatalf("outbound rows = %d, want 2", len(outbound)) + } +} + +func TestListInboundCrossRefs_ExcludesSelfNamespace(t *testing.T) { + s := setupTestDB(t) + ctx := context.Background() + rows := []graph.CrossRef{ + {FromNamespace: "auth-svc", FromNodeID: 1, Raw: "ccg://auth-svc/internal#Self", ToNamespace: "auth-svc", ToSymbol: "Self", Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation}, + } + if err := s.ReplaceCrossRefsFrom(ctx, "auth-svc", rows); err != nil { + t.Fatalf("replace: %v", err) + } + inbound, err := s.ListInboundCrossRefs(ctx, "auth-svc") + if err != nil { + t.Fatalf("ListInboundCrossRefs: %v", err) + } + if len(inbound) != 0 { + t.Fatalf("inbound = %d rows, want 0 (self refs are rebuilt outbound)", len(inbound)) + } +} + +func TestUpdateCrossRefResolution_RemapsAndKills(t *testing.T) { + s := setupTestDB(t) + ctx := context.Background() + resolved := uint(42) + rows := []graph.CrossRef{ + {FromNamespace: "web", FromNodeID: 1, Raw: "ccg://auth-svc/#Fn", ToNamespace: "auth-svc", ToSymbol: "Fn", ResolvedNodeID: &resolved, Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation}, + } + if err := s.ReplaceCrossRefsFrom(ctx, "web", rows); err != nil { + t.Fatalf("replace: %v", err) + } + stored, err := s.ListInboundCrossRefs(ctx, "auth-svc") + if err != nil || len(stored) != 1 { + t.Fatalf("load stored row: %v (%d rows)", err, len(stored)) + } + + if err := s.UpdateCrossRefResolution(ctx, stored[0].ID, nil, graph.CrossRefStatusDead); err != nil { + t.Fatalf("kill resolution: %v", err) + } + after, err := s.ListInboundCrossRefs(ctx, "auth-svc") + if err != nil { + t.Fatalf("reload: %v", err) + } + if after[0].Status != graph.CrossRefStatusDead || after[0].ResolvedNodeID != nil { + t.Fatalf("after kill = %+v, want dead with nil node", after[0]) + } + + remapped := uint(99) + if err := s.UpdateCrossRefResolution(ctx, stored[0].ID, &remapped, graph.CrossRefStatusResolved); err != nil { + t.Fatalf("remap resolution: %v", err) + } + final, err := s.ListInboundCrossRefs(ctx, "auth-svc") + if err != nil { + t.Fatalf("reload final: %v", err) + } + if final[0].Status != graph.CrossRefStatusResolved || final[0].ResolvedNodeID == nil || *final[0].ResolvedNodeID != 99 { + t.Fatalf("after remap = %+v, want resolved node 99", final[0]) + } +} diff --git a/internal/adapters/outbound/graphgorm/store.go b/internal/adapters/outbound/graphgorm/store.go index abc5ecd..caa5f72 100644 --- a/internal/adapters/outbound/graphgorm/store.go +++ b/internal/adapters/outbound/graphgorm/store.go @@ -49,6 +49,7 @@ func (s *Store) AutoMigrate() error { &graph.ParseCacheEntry{}, &graph.UnresolvedEdgeCandidate{}, &graph.UnresolvedIndexState{}, + &graph.CrossRef{}, ); err != nil { return err } diff --git a/internal/app/crossref/service.go b/internal/app/crossref/service.go new file mode 100644 index 0000000..d3c48c1 --- /dev/null +++ b/internal/app/crossref/service.go @@ -0,0 +1,150 @@ +// @index Cross-namespace reference sync: materializes @see ccg:// annotation tags into cross_refs rows. +package crossref + +import ( + "context" + "log/slog" + + "github.com/tae2089/trace" + + requestctx "github.com/tae2089/code-context-graph/internal/ctx" + "github.com/tae2089/code-context-graph/internal/domain/graph" + "github.com/tae2089/code-context-graph/internal/domain/reference" +) + +// AnnotationRef pairs one ccg:// @see tag value with the node that declared it. +// @intent carry the minimal source facts needed to materialize a cross-namespace reference. +type AnnotationRef struct { + NodeID uint + Value string +} + +// Store exposes the persistence operations cross-ref sync needs. +// @intent keep the sync policy independent from GORM by owning a minimal consumer-side port. +type Store interface { + ListAnnotationCCGRefs(ctx context.Context, namespace string) ([]AnnotationRef, error) + ResolveCCGRef(ctx context.Context, ref reference.Ref) (uint, bool, error) + ReplaceCrossRefsFrom(ctx context.Context, fromNamespace string, refs []graph.CrossRef) error + ListInboundCrossRefs(ctx context.Context, toNamespace string) ([]graph.CrossRef, error) + UpdateCrossRefResolution(ctx context.Context, id uint, resolvedNodeID *uint, status graph.CrossRefStatus) error +} + +// Service rebuilds cross-namespace reference state after a namespace ingest. +// @intent keep cross_refs derived state consistent with annotations and both namespaces' current nodes. +type Service struct { + Store Store + Logger *slog.Logger +} + +// New constructs a cross-ref sync service. +// @intent bind the sync policy to one persistence port instance. +func New(store Store) *Service { + return &Service{Store: store} +} + +// SyncNamespace rebuilds outbound cross refs of the context namespace and re-resolves inbound ones. +// @intent run after a build/update commit so cross-namespace links reflect the namespace's new node identity. +// @domainRule outbound rows are fully replaced from current @see tags; malformed refs are skipped (lint owns reporting). +// @domainRule inbound rows are re-resolved because a replace-style build regenerates the target node ids. +// @sideEffect deletes, inserts, and updates cross_refs rows. +func (s *Service) SyncNamespace(ctx context.Context) error { + if s == nil || s.Store == nil { + return trace.New("cross-ref store is not configured") + } + ns := requestctx.FromContext(ctx) + if err := s.rebuildOutbound(ctx, ns); err != nil { + return trace.Wrap(err, "rebuild outbound cross refs") + } + if err := s.reresolveInbound(ctx, ns); err != nil { + return trace.Wrap(err, "re-resolve inbound cross refs") + } + return nil +} + +// @intent replace the namespace's outbound rows with rows derived from its current annotations. +func (s *Service) rebuildOutbound(ctx context.Context, ns string) error { + tags, err := s.Store.ListAnnotationCCGRefs(ctx, ns) + if err != nil { + return err + } + rows := make([]graph.CrossRef, 0, len(tags)) + seen := map[AnnotationRef]bool{} + for _, tag := range tags { + if seen[tag] { + continue + } + seen[tag] = true + ref, err := reference.Parse(tag.Value) + if err != nil { + s.logger().Debug("skipping malformed ccg ref", "node_id", tag.NodeID, "value", tag.Value, "error", err) + continue + } + resolvedID, status, err := s.resolve(ctx, *ref) + if err != nil { + return err + } + rows = append(rows, graph.CrossRef{ + FromNamespace: ns, + FromNodeID: tag.NodeID, + Raw: ref.Raw, + ToNamespace: ref.Namespace, + ToPath: ref.Path, + ToSymbol: ref.Symbol, + ResolvedNodeID: resolvedID, + Status: status, + Source: graph.CrossRefSourceAnnotation, + }) + } + return s.Store.ReplaceCrossRefsFrom(ctx, ns, rows) +} + +// @intent update inbound rows whose resolution changed after this namespace's nodes were rebuilt. +func (s *Service) reresolveInbound(ctx context.Context, ns string) error { + inbound, err := s.Store.ListInboundCrossRefs(ctx, ns) + if err != nil { + return err + } + for _, row := range inbound { + ref := reference.Ref{Raw: row.Raw, Namespace: row.ToNamespace, Path: row.ToPath, Symbol: row.ToSymbol} + resolvedID, status, err := s.resolve(ctx, ref) + if err != nil { + return err + } + if status == row.Status && equalNodeID(resolvedID, row.ResolvedNodeID) { + continue + } + if err := s.Store.UpdateCrossRefResolution(ctx, row.ID, resolvedID, status); err != nil { + return err + } + } + return nil +} + +// @intent translate matcher output into row state: namespace-scope hits stay resolved without a node target. +func (s *Service) resolve(ctx context.Context, ref reference.Ref) (*uint, graph.CrossRefStatus, error) { + id, ok, err := s.Store.ResolveCCGRef(ctx, ref) + if err != nil { + return nil, graph.CrossRefStatusDead, err + } + if !ok { + return nil, graph.CrossRefStatusDead, nil + } + if id == 0 { + return nil, graph.CrossRefStatusResolved, nil + } + return &id, graph.CrossRefStatusResolved, nil +} + +func equalNodeID(a, b *uint) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +func (s *Service) logger() *slog.Logger { + if s.Logger != nil { + return s.Logger + } + return slog.Default() +} diff --git a/internal/app/crossref/service_test.go b/internal/app/crossref/service_test.go new file mode 100644 index 0000000..24d11be --- /dev/null +++ b/internal/app/crossref/service_test.go @@ -0,0 +1,176 @@ +package crossref_test + +import ( + "context" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/tae2089/code-context-graph/internal/adapters/outbound/graphgorm" + "github.com/tae2089/code-context-graph/internal/app/crossref" + requestctx "github.com/tae2089/code-context-graph/internal/ctx" + "github.com/tae2089/code-context-graph/internal/domain/graph" +) + +func setupStore(t *testing.T) *graphgorm.Store { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Discard}) + if err != nil { + t.Fatalf("open test db: %v", err) + } + s := graphgorm.New(db) + if err := s.AutoMigrate(); err != nil { + t.Fatalf("migrate: %v", err) + } + return s +} + +func seedNode(t *testing.T, s *graphgorm.Store, namespace string, node graph.Node) uint { + t.Helper() + ctx := requestctx.WithNamespace(context.Background(), namespace) + if err := s.UpsertNodes(ctx, []graph.Node{node}); err != nil { + t.Fatalf("seed node %s: %v", node.QualifiedName, err) + } + stored, err := s.GetNode(ctx, node.QualifiedName) + if err != nil || stored == nil { + t.Fatalf("load node %s: %v", node.QualifiedName, err) + } + return stored.ID +} + +func annotate(t *testing.T, s *graphgorm.Store, namespace string, nodeID uint, seeValues ...string) { + t.Helper() + ctx := requestctx.WithNamespace(context.Background(), namespace) + tags := make([]graph.DocTag, len(seeValues)) + for i, v := range seeValues { + tags[i] = graph.DocTag{Kind: graph.TagSee, Value: v, Ordinal: i} + } + if err := s.UpsertAnnotation(ctx, &graph.Annotation{NodeID: nodeID, Summary: "test", Tags: tags}); err != nil { + t.Fatalf("annotate node %d: %v", nodeID, err) + } +} + +func syncNamespace(t *testing.T, svc *crossref.Service, namespace string) { + t.Helper() + ctx := requestctx.WithNamespace(context.Background(), namespace) + if err := svc.SyncNamespace(ctx); err != nil { + t.Fatalf("sync %s: %v", namespace, err) + } +} + +func TestSyncNamespace_MaterializesResolvedAndDeadRefs(t *testing.T) { + s := setupStore(t) + svc := crossref.New(s) + ctx := context.Background() + + targetID := seedNode(t, s, "auth-svc", graph.Node{ + QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken", + FilePath: "internal/auth/token.go", StartLine: 10, EndLine: 20, + }) + callerID := seedNode(t, s, "web", graph.Node{ + QualifiedName: "web.Login", Kind: graph.NodeKindFunction, Name: "Login", + FilePath: "internal/web/login.go", StartLine: 5, EndLine: 25, + }) + annotate(t, s, "web", callerID, + "ccg://auth-svc/internal/auth/token.go#ValidateToken", + "ccg://billing/#Charge", + "SessionManager.Create", + ) + + syncNamespace(t, svc, "web") + + rows, err := s.ListOutboundCrossRefs(ctx, "web") + if err != nil { + t.Fatalf("list outbound: %v", err) + } + if len(rows) != 2 { + t.Fatalf("outbound rows = %d, want 2 (local @see ignored)", len(rows)) + } + byRaw := map[string]graph.CrossRef{} + for _, r := range rows { + byRaw[r.Raw] = r + } + resolved := byRaw["ccg://auth-svc/internal/auth/token.go#ValidateToken"] + if resolved.Status != graph.CrossRefStatusResolved || resolved.ResolvedNodeID == nil || *resolved.ResolvedNodeID != targetID { + t.Fatalf("resolved row = %+v, want resolved to node %d", resolved, targetID) + } + if resolved.FromNodeID != callerID || resolved.ToNamespace != "auth-svc" || resolved.ToSymbol != "ValidateToken" { + t.Fatalf("resolved row identity = %+v", resolved) + } + dead := byRaw["ccg://billing/#Charge"] + if dead.Status != graph.CrossRefStatusDead || dead.ResolvedNodeID != nil { + t.Fatalf("dead row = %+v, want dead without node", dead) + } +} + +func TestSyncNamespace_SkipsMalformedRefs(t *testing.T) { + s := setupStore(t) + svc := crossref.New(s) + + callerID := seedNode(t, s, "web", graph.Node{ + QualifiedName: "web.Login", Kind: graph.NodeKindFunction, Name: "Login", + FilePath: "internal/web/login.go", StartLine: 5, EndLine: 25, + }) + annotate(t, s, "web", callerID, "ccg://bad/../escape#X") + + syncNamespace(t, svc, "web") + + rows, err := s.ListOutboundCrossRefs(context.Background(), "web") + if err != nil { + t.Fatalf("list outbound: %v", err) + } + if len(rows) != 0 { + t.Fatalf("outbound rows = %d, want 0 for malformed ref", len(rows)) + } +} + +func TestSyncNamespace_ReresolvesInboundAfterTargetRebuild(t *testing.T) { + s := setupStore(t) + svc := crossref.New(s) + ctx := context.Background() + authCtx := requestctx.WithNamespace(ctx, "auth-svc") + + oldID := seedNode(t, s, "auth-svc", graph.Node{ + QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken", + FilePath: "internal/auth/token.go", StartLine: 10, EndLine: 20, + }) + callerID := seedNode(t, s, "web", graph.Node{ + QualifiedName: "web.Login", Kind: graph.NodeKindFunction, Name: "Login", + FilePath: "internal/web/login.go", StartLine: 5, EndLine: 25, + }) + annotate(t, s, "web", callerID, "ccg://auth-svc/internal/auth/token.go#ValidateToken") + syncNamespace(t, svc, "web") + + // Simulate a replace-style rebuild of auth-svc: node ids change. + if err := s.DeleteNodesByFile(authCtx, "internal/auth/token.go"); err != nil { + t.Fatalf("delete target nodes: %v", err) + } + syncNamespace(t, svc, "auth-svc") + + rows, err := s.ListOutboundCrossRefs(ctx, "web") + if err != nil { + t.Fatalf("list after delete: %v", err) + } + if rows[0].Status != graph.CrossRefStatusDead || rows[0].ResolvedNodeID != nil { + t.Fatalf("after target deletion row = %+v, want dead", rows[0]) + } + + newID := seedNode(t, s, "auth-svc", graph.Node{ + QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken", + FilePath: "internal/auth/token.go", StartLine: 42, EndLine: 60, + }) + if newID == oldID { + t.Fatalf("test setup: expected a fresh node id, got same id %d", newID) + } + syncNamespace(t, svc, "auth-svc") + + rows, err = s.ListOutboundCrossRefs(ctx, "web") + if err != nil { + t.Fatalf("list after revive: %v", err) + } + if rows[0].Status != graph.CrossRefStatusResolved || rows[0].ResolvedNodeID == nil || *rows[0].ResolvedNodeID != newID { + t.Fatalf("after revive row = %+v, want resolved to remapped node %d", rows[0], newID) + } +} diff --git a/internal/app/ingest/workflow/build.go b/internal/app/ingest/workflow/build.go index 9b178f6..6f73e5a 100644 --- a/internal/app/ingest/workflow/build.go +++ b/internal/app/ingest/workflow/build.go @@ -196,6 +196,9 @@ func (s *Service) Build(ctx context.Context, opts BuildOptions) (BuildStats, err if err != nil { return stats, err } + if err := s.syncCrossRefs(ctx); err != nil { + return stats, err + } stats.Timing.TotalMS = time.Since(totalStart).Milliseconds() s.logger().Info("build complete", diff --git a/internal/app/ingest/workflow/crossref_hook_test.go b/internal/app/ingest/workflow/crossref_hook_test.go new file mode 100644 index 0000000..a39dee0 --- /dev/null +++ b/internal/app/ingest/workflow/crossref_hook_test.go @@ -0,0 +1,160 @@ +package workflow + +import ( + "context" + "log/slog" + "os" + "path/filepath" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" + + "github.com/tae2089/code-context-graph/internal/adapters/outbound/graphgorm" + "github.com/tae2089/code-context-graph/internal/adapters/outbound/treesitter" + "github.com/tae2089/code-context-graph/internal/app/crossref" + "github.com/tae2089/code-context-graph/internal/app/ingest/incremental" + requestctx "github.com/tae2089/code-context-graph/internal/ctx" + "github.com/tae2089/code-context-graph/internal/domain/graph" + "github.com/tae2089/trace" +) + +type recordingCrossRefSyncer struct { + namespaces []string + err error +} + +func (r *recordingCrossRefSyncer) SyncNamespace(ctx context.Context) error { + if r.err != nil { + return r.err + } + r.namespaces = append(r.namespaces, requestctx.FromContext(ctx)) + return nil +} + +func newCrossRefHookService(t *testing.T) (*Service, *graphgorm.Store, string) { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: gormlogger.Discard}) + if err != nil { + t.Fatalf("open db: %v", err) + } + st := graphgorm.New(db) + if err := st.AutoMigrate(); err != nil { + t.Fatalf("migrate: %v", err) + } + svc := &Service{Store: st, UnitOfWork: newTestUnitOfWork(db, nil), Walkers: map[string]Parser{".go": treesitter.NewWalker(treesitter.GoSpec)}, Logger: slog.Default()} + + tmpDir := t.TempDir() + writeFile := func(rel, content string) { + full := filepath.Join(tmpDir, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", rel, err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", rel, err) + } + } + writeFile("go.mod", "module github.com/example/project\n\ngo 1.25.0\n") + writeFile("mainpkg/main.go", "package mainpkg\n\nfunc Run() {}\n") + return svc, st, tmpDir +} + +func TestBuild_MaterializesCrossRefsFromSourceComments(t *testing.T) { + svc, st, tmpDir := newCrossRefHookService(t) + svc.CrossRefs = crossref.New(st) + + target := "package mainpkg\n\n// ValidateToken validates a token.\nfunc ValidateToken() {}\n" + if err := os.MkdirAll(filepath.Join(tmpDir, "auth"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(tmpDir, "auth/token.go"), []byte(target), 0o644); err != nil { + t.Fatalf("write target: %v", err) + } + authCtx := requestctx.WithNamespace(context.Background(), "auth-svc") + if _, err := svc.Build(authCtx, BuildOptions{Dir: tmpDir}); err != nil { + t.Fatalf("build auth-svc: %v", err) + } + + caller := "package mainpkg\n\n// Login logs a user in.\n// @intent authenticate the session.\n// @see ccg://auth-svc/auth/token.go#ValidateToken\nfunc Login() {}\n" + callerDir := t.TempDir() + if err := os.WriteFile(filepath.Join(callerDir, "go.mod"), []byte("module github.com/example/web\n\ngo 1.25.0\n"), 0o644); err != nil { + t.Fatalf("write go.mod: %v", err) + } + if err := os.MkdirAll(filepath.Join(callerDir, "mainpkg"), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(filepath.Join(callerDir, "mainpkg/login.go"), []byte(caller), 0o644); err != nil { + t.Fatalf("write caller: %v", err) + } + webCtx := requestctx.WithNamespace(context.Background(), "web") + if _, err := svc.Build(webCtx, BuildOptions{Dir: callerDir}); err != nil { + t.Fatalf("build web: %v", err) + } + + rows, err := st.ListOutboundCrossRefs(context.Background(), "web") + if err != nil { + t.Fatalf("list outbound: %v", err) + } + if len(rows) == 0 { + t.Fatal("no cross refs materialized from @see comment") + } + // The comment may bind to both the function and its file node; every + // materialized row must resolve into auth-svc. + for _, row := range rows { + if row.ToNamespace != "auth-svc" || row.ToSymbol != "ValidateToken" || row.Status != graph.CrossRefStatusResolved || row.ResolvedNodeID == nil { + t.Fatalf("materialized row = %+v, want resolved ref into auth-svc", row) + } + } +} + +func TestBuild_TriggersCrossRefSync(t *testing.T) { + svc, _, tmpDir := newCrossRefHookService(t) + rec := &recordingCrossRefSyncer{} + svc.CrossRefs = rec + + ctx := requestctx.WithNamespace(context.Background(), "web") + if _, err := svc.Build(ctx, BuildOptions{Dir: tmpDir}); err != nil { + t.Fatalf("Build: %v", err) + } + if len(rec.namespaces) != 1 || rec.namespaces[0] != "web" { + t.Fatalf("cross-ref sync calls = %v, want one call for namespace web", rec.namespaces) + } +} + +func TestBuild_PropagatesCrossRefSyncError(t *testing.T) { + svc, _, tmpDir := newCrossRefHookService(t) + svc.CrossRefs = &recordingCrossRefSyncer{err: trace.New("sync boom")} + + if _, err := svc.Build(context.Background(), BuildOptions{Dir: tmpDir}); err == nil { + t.Fatal("Build should surface cross-ref sync failure") + } +} + +func TestBuild_WithoutSyncerSkipsCrossRefSync(t *testing.T) { + svc, _, tmpDir := newCrossRefHookService(t) + if _, err := svc.Build(context.Background(), BuildOptions{Dir: tmpDir}); err != nil { + t.Fatalf("Build without syncer: %v", err) + } +} + +func TestUpdate_TriggersCrossRefSyncOnce(t *testing.T) { + svc, st, tmpDir := newCrossRefHookService(t) + ctx := requestctx.WithNamespace(context.Background(), "web") + if _, err := svc.Build(ctx, BuildOptions{Dir: tmpDir}); err != nil { + t.Fatalf("Build: %v", err) + } + + rec := &recordingCrossRefSyncer{} + svc.CrossRefs = rec + if err := os.WriteFile(filepath.Join(tmpDir, "mainpkg/main.go"), []byte("package mainpkg\n\nfunc Run() {}\n\nfunc Stop() {}\n"), 0o644); err != nil { + t.Fatalf("modify file: %v", err) + } + syncer := incremental.NewWithRegistry(st, map[string]incremental.Parser{".go": treesitter.NewWalker(treesitter.GoSpec)}, incremental.WithLogger(slog.Default())) + if _, err := svc.Update(ctx, UpdateOptions{BuildOptions: BuildOptions{Dir: tmpDir}, Syncer: syncer}); err != nil { + t.Fatalf("Update: %v", err) + } + if len(rec.namespaces) != 1 || rec.namespaces[0] != "web" { + t.Fatalf("cross-ref sync calls = %v, want exactly one call for namespace web", rec.namespaces) + } +} diff --git a/internal/app/ingest/workflow/indexer.go b/internal/app/ingest/workflow/indexer.go index 2e541b0..9ad8bc9 100644 --- a/internal/app/ingest/workflow/indexer.go +++ b/internal/app/ingest/workflow/indexer.go @@ -6,6 +6,8 @@ import ( "fmt" "log/slog" + "github.com/tae2089/trace" + ingestapp "github.com/tae2089/code-context-graph/internal/app/ingest" "github.com/tae2089/code-context-graph/internal/app/ingest/resolve" "github.com/tae2089/code-context-graph/internal/domain/graph" @@ -45,6 +47,12 @@ type batchIncrementalSyncer = ingestapp.BatchIncrementalSyncer // @intent let staged incremental reconciliation use the graph store from the active update transaction. type transactionalBatchIncrementalSyncer = ingestapp.TransactionalBatchIncrementalSyncer +// CrossRefSyncer rebuilds cross-namespace reference state after an ingest commit. +// @intent let build/update trigger cross-ref materialization without depending on its implementation. +type CrossRefSyncer interface { + SyncNamespace(ctx context.Context) error +} + // Service orchestrates graph building and search document refresh. // @intent 파싱 결과 저장과 검색 인덱스 재구성을 하나의 서비스로 묶는다. type Service struct { @@ -54,6 +62,7 @@ type Service struct { ParseCache ingestapp.ParseCache Walkers map[string]Parser Parsers map[string]Parser + CrossRefs CrossRefSyncer Logger *slog.Logger // resolveEdges overrides build-time edge resolution; nil uses resolve.ResolveWithOptions. @@ -71,6 +80,18 @@ func (s *Service) edgeResolver() resolveBuildEdgesFn { return resolve.ResolveWithOptions } +// syncCrossRefs runs the optional cross-ref sync after a successful ingest commit. +// @intent keep cross-namespace reference state current without making it a hard build dependency. +func (s *Service) syncCrossRefs(ctx context.Context) error { + if s.CrossRefs == nil { + return nil + } + if err := s.CrossRefs.SyncNamespace(ctx); err != nil { + return trace.Wrap(err, "sync cross-namespace refs") + } + return nil +} + // logger returns the configured slog.Logger or the process default when none was supplied. // @intent keep service code logging-safe even when callers leave Logger nil. func (s *Service) logger() *slog.Logger { diff --git a/internal/app/ingest/workflow/update.go b/internal/app/ingest/workflow/update.go index cf22daf..b67b02a 100644 --- a/internal/app/ingest/workflow/update.go +++ b/internal/app/ingest/workflow/update.go @@ -53,7 +53,14 @@ func (s *Service) Update(ctx context.Context, opts UpdateOptions) (*ingest.SyncS } defer spool.cleanup(s.logger()) spool.packages = packages - return s.updateGraphWithoutTx(ctx, absDir, opts, packages, spool) + stats, err := s.updateGraphWithoutTx(ctx, absDir, opts, packages, spool) + if err != nil { + return nil, err + } + if err := s.syncCrossRefs(ctx); err != nil { + return nil, err + } + return stats, nil } spool, err := s.prepareUpdateSpool(ctx, absDir, opts) @@ -75,6 +82,9 @@ func (s *Service) Update(ctx context.Context, opts UpdateOptions) (*ingest.SyncS spool.cleanup(s.logger()) return s.buildForUpdate(ctx, opts.BuildOptions, outcome.stats) } + if err := s.syncCrossRefs(ctx); err != nil { + return nil, err + } return outcome.stats, nil } diff --git a/internal/db/migration/migration.go b/internal/db/migration/migration.go index a7203e2..1a8229b 100644 --- a/internal/db/migration/migration.go +++ b/internal/db/migration/migration.go @@ -23,7 +23,7 @@ import ( ) const ( - RequiredSchemaVersion = 14 + RequiredSchemaVersion = 15 SchemaVersionKey = "schema" LegacySchemaVersionTable = "ccg_schema_versions" ) @@ -385,6 +385,7 @@ func RequiredSchemaTables() []string { "parse_cache_entries", "unresolved_edge_candidates", "unresolved_index_states", + "cross_refs", } } @@ -436,6 +437,13 @@ func RequiredTextColumns() []SchemaColumn { {Table: "unresolved_edge_candidates", Column: "kind"}, {Table: "unresolved_index_states", Column: "namespace"}, {Table: "unresolved_index_states", Column: "version"}, + {Table: "cross_refs", Column: "from_namespace"}, + {Table: "cross_refs", Column: "raw"}, + {Table: "cross_refs", Column: "to_namespace"}, + {Table: "cross_refs", Column: "to_path"}, + {Table: "cross_refs", Column: "to_symbol"}, + {Table: "cross_refs", Column: "status"}, + {Table: "cross_refs", Column: "source"}, } } @@ -472,6 +480,12 @@ func ModelNullabilityColumns() []SchemaColumn { {Table: "unresolved_edge_candidates", Column: "kind"}, {Table: "unresolved_index_states", Column: "namespace"}, {Table: "unresolved_index_states", Column: "version"}, + {Table: "cross_refs", Column: "from_namespace"}, + {Table: "cross_refs", Column: "from_node_id"}, + {Table: "cross_refs", Column: "raw"}, + {Table: "cross_refs", Column: "to_namespace"}, + {Table: "cross_refs", Column: "status"}, + {Table: "cross_refs", Column: "source"}, } } diff --git a/internal/db/migration/migration_test.go b/internal/db/migration/migration_test.go index 53c35b4..893eb03 100644 --- a/internal/db/migration/migration_test.go +++ b/internal/db/migration/migration_test.go @@ -9,9 +9,9 @@ import ( "gorm.io/gorm" ) -func TestRequiredSchemaVersion_IncludesUnboundedGraphStrings(t *testing.T) { - if RequiredSchemaVersion != 14 { - t.Fatalf("RequiredSchemaVersion = %d, want 14", RequiredSchemaVersion) +func TestRequiredSchemaVersion_IncludesCrossRefs(t *testing.T) { + if RequiredSchemaVersion != 15 { + t.Fatalf("RequiredSchemaVersion = %d, want 15", RequiredSchemaVersion) } } @@ -143,6 +143,40 @@ func TestSQLiteMigrationThirteen_AddsResolverFileLookupIndexAndCanMigrateDown(t } } +func TestSQLiteMigrationFifteen_CreatesCrossRefsAndCanMigrateDown(t *testing.T) { + dsn := filepath.Join(t.TempDir(), "migration.db") + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open SQLite: %v", err) + } + migrator, _, err := NewMigrator(db, "sqlite", "") + if err != nil { + t.Fatalf("NewMigrator: %v", err) + } + if err := migrator.Steps(15); err != nil { + t.Fatalf("migrate to version 15: %v", err) + } + + if !db.Migrator().HasTable("cross_refs") { + t.Fatal("version 15 missing table cross_refs") + } + for _, indexName := range []string{"idx_crossref_from_ns", "idx_crossref_to_ns", "idx_crossref_resolved_node"} { + exists, err := sqliteIndexExists(db, indexName) + if err != nil { + t.Fatalf("inspect index %q: %v", indexName, err) + } + if !exists { + t.Fatalf("version 15 missing index %q", indexName) + } + } + if err := migrator.Steps(-1); err != nil { + t.Fatalf("migrate down to version 14: %v", err) + } + if db.Migrator().HasTable("cross_refs") { + t.Fatal("version 14 retained table cross_refs") + } +} + func TestSQLiteMigrationFourteen_PreservesTextColumnsAndCanMigrateDown(t *testing.T) { dsn := filepath.Join(t.TempDir(), "migration.db") db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) diff --git a/internal/db/migration/postgres/000015_cross_refs.down.sql b/internal/db/migration/postgres/000015_cross_refs.down.sql new file mode 100644 index 0000000..fe1e94b --- /dev/null +++ b/internal/db/migration/postgres/000015_cross_refs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS cross_refs; diff --git a/internal/db/migration/postgres/000015_cross_refs.up.sql b/internal/db/migration/postgres/000015_cross_refs.up.sql new file mode 100644 index 0000000..a66ca1c --- /dev/null +++ b/internal/db/migration/postgres/000015_cross_refs.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS cross_refs ( + id bigserial PRIMARY KEY, + from_namespace text NOT NULL, + from_node_id bigint NOT NULL, + raw text NOT NULL, + to_namespace text NOT NULL, + to_path text NOT NULL DEFAULT '', + to_symbol text NOT NULL DEFAULT '', + resolved_node_id bigint, + status text NOT NULL, + source text NOT NULL DEFAULT 'annotation', + created_at timestamptz, + updated_at timestamptz +); + +CREATE INDEX IF NOT EXISTS idx_crossref_from_ns ON cross_refs(from_namespace); +CREATE INDEX IF NOT EXISTS idx_crossref_from_node ON cross_refs(from_node_id); +CREATE INDEX IF NOT EXISTS idx_crossref_to_ns ON cross_refs(to_namespace); +CREATE INDEX IF NOT EXISTS idx_crossref_resolved_node ON cross_refs(resolved_node_id); diff --git a/internal/db/migration/sqlite/000015_cross_refs.down.sql b/internal/db/migration/sqlite/000015_cross_refs.down.sql new file mode 100644 index 0000000..fe1e94b --- /dev/null +++ b/internal/db/migration/sqlite/000015_cross_refs.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS cross_refs; diff --git a/internal/db/migration/sqlite/000015_cross_refs.up.sql b/internal/db/migration/sqlite/000015_cross_refs.up.sql new file mode 100644 index 0000000..7cb3ba3 --- /dev/null +++ b/internal/db/migration/sqlite/000015_cross_refs.up.sql @@ -0,0 +1,19 @@ +CREATE TABLE IF NOT EXISTS cross_refs ( + id integer PRIMARY KEY AUTOINCREMENT, + from_namespace text NOT NULL, + from_node_id integer NOT NULL, + raw text NOT NULL, + to_namespace text NOT NULL, + to_path text NOT NULL DEFAULT '', + to_symbol text NOT NULL DEFAULT '', + resolved_node_id integer, + status text NOT NULL, + source text NOT NULL DEFAULT 'annotation', + created_at datetime, + updated_at datetime +); + +CREATE INDEX IF NOT EXISTS idx_crossref_from_ns ON cross_refs(from_namespace); +CREATE INDEX IF NOT EXISTS idx_crossref_from_node ON cross_refs(from_node_id); +CREATE INDEX IF NOT EXISTS idx_crossref_to_ns ON cross_refs(to_namespace); +CREATE INDEX IF NOT EXISTS idx_crossref_resolved_node ON cross_refs(resolved_node_id); diff --git a/internal/domain/graph/crossref.go b/internal/domain/graph/crossref.go new file mode 100644 index 0000000..c71a9af --- /dev/null +++ b/internal/domain/graph/crossref.go @@ -0,0 +1,38 @@ +// @index Materialized cross-namespace annotation references for federated graph analysis. +package graph + +import "time" + +// CrossRefStatus describes whether a cross-namespace reference currently resolves to a node. +// @intent distinguish navigable references from dangling ones without deleting authored links. +type CrossRefStatus string + +const ( + CrossRefStatusResolved CrossRefStatus = "resolved" + CrossRefStatusDead CrossRefStatus = "dead" +) + +// CrossRefSource records which signal produced a cross-namespace reference. +// @intent keep room for future non-annotation signals (e.g. import mapping) without schema rework. +type CrossRefSource string + +const CrossRefSourceAnnotation CrossRefSource = "annotation" + +// CrossRef materializes one @see ccg:// annotation tag as queryable cross-namespace graph state. +// @intent make annotation-declared repository links traversable and listable instead of plain tag text. +// @domainRule target identity is symbolic (namespace, path, symbol); resolved_node_id is derived state that rebuilds change. +// @domainRule rows for one source namespace are fully replaced on each build, so no uniqueness constraint is required. +type CrossRef struct { + ID uint `gorm:"primaryKey"` + FromNamespace string `gorm:"type:text;not null;index:idx_crossref_from_ns"` + FromNodeID uint `gorm:"not null;index:idx_crossref_from_node"` + Raw string `gorm:"type:text;not null"` + ToNamespace string `gorm:"type:text;not null;index:idx_crossref_to_ns"` + ToPath string `gorm:"type:text;not null;default:''"` + ToSymbol string `gorm:"type:text;not null;default:''"` + ResolvedNodeID *uint `gorm:"index:idx_crossref_resolved_node"` + Status CrossRefStatus `gorm:"type:text;not null"` + Source CrossRefSource `gorm:"type:text;not null;default:'annotation'"` + CreatedAt time.Time + UpdatedAt time.Time +} diff --git a/internal/runtime/remote/http.go b/internal/runtime/remote/http.go index 8025b55..527f3dc 100644 --- a/internal/runtime/remote/http.go +++ b/internal/runtime/remote/http.go @@ -17,6 +17,7 @@ import ( "github.com/tae2089/code-context-graph/internal/adapters/outbound/gitrepo" "github.com/tae2089/code-context-graph/internal/adapters/outbound/reposyncgraph" "github.com/tae2089/code-context-graph/internal/adapters/outbound/reposyncobs" + "github.com/tae2089/code-context-graph/internal/app/crossref" "github.com/tae2089/code-context-graph/internal/app/ingest/workflow" "github.com/tae2089/code-context-graph/internal/app/reposync" "github.com/tae2089/code-context-graph/internal/app/search/retrieval" @@ -100,6 +101,9 @@ func buildRepoSyncHTTP(rt *ccgruntime.Runtime, cfg httpin.Config, rules []reposy walkers[ext] = walker } graphSvc := &workflow.Service{Store: rt.Store, UnitOfWork: rt.UnitOfWork, Search: rt.Search, ParseCache: rt.Store, Walkers: walkers, Logger: rt.Logger} + if crossRefStore, ok := any(rt.Store).(crossref.Store); ok { + graphSvc.CrossRefs = crossref.New(crossRefStore) + } syncService := &reposync.Service{ Checkout: gitrepo.NewCheckout(cfg.RepoRoot, repoLocker, nil), BuildScope: configfiles.BuildScope{}, Graph: reposyncgraph.Updater{Service: graphSvc, Syncer: rt.Syncer}, From bc239923ee1ba4a3955f8fd85013582843db82f3 Mon Sep 17 00:00:00 2001 From: tae2089 Date: Sun, 19 Jul 2026 10:55:22 +0900 Subject: [PATCH 03/10] feat: federate search, query_graph, list_graph_stats, and search_docs across namespaces Add an optional 'namespaces' array parameter to the four read tools. Federation fans out per namespace at the handler level so every store query path keeps its single-namespace invariant: - search merges per-namespace candidate pools, reranks once, and labels each item with its namespace (single-namespace responses are byte-identical to before). - query_graph runs the extracted per-namespace body for each namespace and groups outcomes; a missing target in one namespace becomes a per-namespace error instead of failing the call. - list_graph_stats and search_docs return per-namespace groups without merging counts across unrelated graphs. Co-Authored-By: Claude Fable 5 --- internal/adapters/inbound/mcp/handler_docs.go | 35 ++ .../inbound/mcp/handler_federated_test.go | 190 ++++++++ .../adapters/inbound/mcp/handler_query.go | 445 ++++++++++++------ internal/adapters/inbound/mcp/handlers.go | 23 + internal/adapters/inbound/mcp/tools_docs.go | 1 + internal/adapters/inbound/mcp/tools_query.go | 14 +- 6 files changed, 555 insertions(+), 153 deletions(-) create mode 100644 internal/adapters/inbound/mcp/handler_federated_test.go diff --git a/internal/adapters/inbound/mcp/handler_docs.go b/internal/adapters/inbound/mcp/handler_docs.go index 002cd2f..9c87198 100644 --- a/internal/adapters/inbound/mcp/handler_docs.go +++ b/internal/adapters/inbound/mcp/handler_docs.go @@ -10,6 +10,8 @@ import ( "strings" "github.com/mark3labs/mcp-go/mcp" + "github.com/tae2089/trace" + "github.com/tae2089/code-context-graph/internal/app/wiki" requestctx "github.com/tae2089/code-context-graph/internal/ctx" ) @@ -104,6 +106,11 @@ func (h *handlers) searchDocs(ctx context.Context, request mcp.CallToolRequest) if h.deps.Docs.Retrieval == nil { return mcp.NewToolResultError("DB is not configured"), nil } + + if namespaces := requestNamespaces(request); len(namespaces) > 0 { + return h.searchDocsFederated(ctx, query, limit, namespaces) + } + return finalizeToolResult(h.cachedExecute(ctx, "search_docs:db:", map[string]any{"query": query, "limit": limit, "namespace": namespace}, func() (string, error) { results, err := h.searchDocsFromDB(ctx, namespace, query, limit) if err != nil { @@ -114,6 +121,34 @@ func (h *handlers) searchDocs(ctx context.Context, request mcp.CallToolRequest) })) } +// federatedDocsEntry labels one namespace's documentation candidates in a federated response. +// @intent keep doc candidates attributable to their source repository. +type federatedDocsEntry struct { + Namespace string `json:"namespace"` + Results []wiki.SearchResult `json:"results"` +} + +// searchDocsFederated searches documentation candidates across several namespaces. +// @intent let one docs query cover multiple repositories with per-namespace grouping. +func (h *handlers) searchDocsFederated(ctx context.Context, query string, limit int, namespaces []string) (*mcp.CallToolResult, error) { + return finalizeToolResult(h.cachedExecute(ctx, "search_docs:db:", map[string]any{"query": query, "limit": limit, "namespaces": namespaces}, func() (string, error) { + entries := make([]federatedDocsEntry, 0, len(namespaces)) + for _, ns := range namespaces { + nsCtx := requestctx.WithNamespace(ctx, ns) + results, err := h.searchDocsFromDB(nsCtx, ns, query, limit) + if err != nil { + return "", newToolResultErr(err.Error()) + } + entries = append(entries, federatedDocsEntry{Namespace: ns, Results: results}) + } + b, err := json.Marshal(map[string]any{"namespaces": entries}) + if err != nil { + return "", trace.Wrap(err, "marshal result") + } + return string(b), nil + })) +} + // @intent search persisted graph nodes directly from DB and search backend. // @requires ctx must carry the selected namespace for SearchBackend.Query. // @ensures returns SearchResult-compatible JSON items without requiring generated index files. diff --git a/internal/adapters/inbound/mcp/handler_federated_test.go b/internal/adapters/inbound/mcp/handler_federated_test.go new file mode 100644 index 0000000..f76569d --- /dev/null +++ b/internal/adapters/inbound/mcp/handler_federated_test.go @@ -0,0 +1,190 @@ +package mcp + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/mark3labs/mcp-go/mcp" +) + +func seedFederatedNamespaces(t *testing.T, deps *Deps) { + t.Helper() + write := func(dir, rel, content string) string { + t.Helper() + full := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + return full + } + alphaDir := t.TempDir() + betaDir := t.TempDir() + deps.Runtime.RepoRoot = filepath.Dir(alphaDir) + write(alphaDir, "pay.go", "package alpha\n\nfunc PaymentProcess() string {\n\treturn \"payment\"\n}\n") + write(betaDir, "refund.go", "package beta\n\nfunc PaymentRefund() string {\n\treturn \"payment\"\n}\n") + + deps.Runtime.RepoRoot = alphaDir + if res := callTool(t, deps, "build_or_update_graph", map[string]any{"path": alphaDir, "namespace": "alpha"}); res.IsError { + t.Fatalf("parse alpha failed: %+v", res) + } + deps.Runtime.RepoRoot = betaDir + if res := callTool(t, deps, "build_or_update_graph", map[string]any{"path": betaDir, "namespace": "beta"}); res.IsError { + t.Fatalf("parse beta failed: %+v", res) + } +} + +func resultTextOf(t *testing.T, result *mcp.CallToolResult) string { + t.Helper() + if result.IsError { + t.Fatalf("tool returned error: %+v", result.Content) + } + for _, content := range result.Content { + if text, ok := content.(mcp.TextContent); ok { + return text.Text + } + if text, ok := content.(*mcp.TextContent); ok { + return text.Text + } + } + t.Fatal("no text content in result") + return "" +} + +func TestSearch_FederatesAcrossNamespaces(t *testing.T) { + deps := setupTestDeps(t) + seedFederatedNamespaces(t, deps) + + result := callTool(t, deps, "search", map[string]any{ + "query": "Payment", + "namespaces": []string{"alpha", "beta"}, + }) + var items []struct { + QualifiedName string `json:"qualified_name"` + Namespace string `json:"namespace"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &items); err != nil { + t.Fatalf("unmarshal: %v", err) + } + seen := map[string]string{} + for _, item := range items { + seen[item.QualifiedName] = item.Namespace + } + if seen["alpha.PaymentProcess"] != "alpha" || seen["beta.PaymentRefund"] != "beta" { + t.Fatalf("federated search items = %v, want hits from both namespaces with labels", seen) + } +} + +func TestSearch_SingleNamespaceResponseUnchanged(t *testing.T) { + deps := setupTestDeps(t) + seedFederatedNamespaces(t, deps) + + result := callTool(t, deps, "search", map[string]any{"query": "Payment", "namespace": "alpha"}) + text := resultTextOf(t, result) + var raw []map[string]any + if err := json.Unmarshal([]byte(text), &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(raw) == 0 { + t.Fatal("single-namespace search returned no results") + } + for _, item := range raw { + if _, exists := item["namespace"]; exists { + t.Fatalf("single-namespace response gained a namespace field: %v", item) + } + } +} + +func TestListGraphStats_FederatesAcrossNamespaces(t *testing.T) { + deps := setupTestDeps(t) + seedFederatedNamespaces(t, deps) + + result := callTool(t, deps, "list_graph_stats", map[string]any{"namespaces": []string{"alpha", "beta"}}) + var payload struct { + Namespaces []struct { + Namespace string `json:"namespace"` + TotalNodes int64 `json:"total_nodes"` + } `json:"namespaces"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(payload.Namespaces) != 2 { + t.Fatalf("stats groups = %d, want 2", len(payload.Namespaces)) + } + for _, group := range payload.Namespaces { + if group.TotalNodes == 0 { + t.Fatalf("namespace %q has zero nodes in federated stats", group.Namespace) + } + } +} + +func TestQueryGraph_FederatesAcrossNamespaces(t *testing.T) { + deps := setupTestDeps(t) + seedFederatedNamespaces(t, deps) + + result := callTool(t, deps, "query_graph", map[string]any{ + "pattern": "callers_of", + "target": "alpha.PaymentProcess", + "namespaces": []string{"alpha", "beta"}, + }) + var payload struct { + Pattern string `json:"pattern"` + Namespaces []struct { + Namespace string `json:"namespace"` + Response json.RawMessage `json:"response,omitempty"` + Error string `json:"error,omitempty"` + } `json:"namespaces"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(payload.Namespaces) != 2 { + t.Fatalf("query groups = %d, want 2", len(payload.Namespaces)) + } + byNS := map[string]json.RawMessage{} + errsByNS := map[string]string{} + for _, group := range payload.Namespaces { + byNS[group.Namespace] = group.Response + errsByNS[group.Namespace] = group.Error + } + if len(byNS["alpha"]) == 0 { + t.Fatalf("alpha response missing: %+v", payload) + } + if errsByNS["beta"] == "" { + t.Fatalf("beta should report a per-namespace error for missing file, got %+v", payload) + } +} + +func TestSearchDocs_FederatesAcrossNamespaces(t *testing.T) { + deps := setupTestDeps(t) + seedFederatedNamespaces(t, deps) + + result := callTool(t, deps, "search_docs", map[string]any{ + "query": "Payment", + "namespaces": []string{"alpha", "beta"}, + }) + var payload struct { + Namespaces []struct { + Namespace string `json:"namespace"` + Results []struct { + Label string `json:"label"` + } `json:"results"` + } `json:"namespaces"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(payload.Namespaces) != 2 { + t.Fatalf("docs groups = %d, want 2", len(payload.Namespaces)) + } + for _, group := range payload.Namespaces { + if len(group.Results) == 0 { + t.Fatalf("namespace %q returned no doc candidates", group.Namespace) + } + } +} diff --git a/internal/adapters/inbound/mcp/handler_query.go b/internal/adapters/inbound/mcp/handler_query.go index eed7a1b..89dc1ed 100644 --- a/internal/adapters/inbound/mcp/handler_query.go +++ b/internal/adapters/inbound/mcp/handler_query.go @@ -3,6 +3,8 @@ package mcp import ( "context" + "encoding/json" + "errors" "fmt" "strings" @@ -10,6 +12,7 @@ import ( "github.com/tae2089/code-context-graph/internal/app/analyze" querypkg "github.com/tae2089/code-context-graph/internal/app/analyze/query" searchrank "github.com/tae2089/code-context-graph/internal/app/search/rank" + requestctx "github.com/tae2089/code-context-graph/internal/ctx" "github.com/tae2089/code-context-graph/internal/domain/graph" "github.com/tae2089/code-context-graph/internal/domain/reference" "github.com/tae2089/code-context-graph/internal/pathspec" @@ -87,12 +90,22 @@ type queryGraphResponse struct { // searchResultItem summarizes one node hit returned by full-text search. // @intent preserve a stable per-item DTO for search responses. +// @domainRule Namespace is set only in federated (multi-namespace) mode so single-namespace responses stay unchanged. type searchResultItem struct { ID uint `json:"id"` QualifiedName string `json:"qualified_name"` Kind graph.NodeKind `json:"kind"` Name string `json:"name"` FilePath string `json:"file_path"` + Namespace string `json:"namespace,omitempty"` +} + +// federatedNamespaceEntry wraps one namespace's result inside a federated tool response. +// @intent label per-namespace payloads and isolate per-namespace failures in federated reads. +type federatedNamespaceEntry struct { + Namespace string `json:"namespace"` + Response json.RawMessage `json:"response,omitempty"` + Error string `json:"error,omitempty"` } // fileSummaryResponse is the typed wire payload for file_summary queryGraph requests. @@ -201,6 +214,10 @@ func (h *handlers) search(ctx context.Context, request mcp.CallToolRequest) (*mc return mcp.NewToolResultError("SearchBackend not configured"), nil } + if namespaces := requestNamespaces(request); len(namespaces) > 0 { + return h.searchFederated(ctx, query, limit, pathPrefix, namespaces) + } + return finalizeToolResult(h.cachedExecute(ctx, "search:", map[string]any{"query": query, "limit": limit, "path": pathPrefix, "namespace": requestNamespace(request)}, func() (string, error) { // Over-fetch a wider candidate pool so structural reranking can promote // good matches that FTS ranked below the caller's limit, and so path @@ -238,6 +255,51 @@ func (h *handlers) search(ctx context.Context, request mcp.CallToolRequest) (*mc })) } +// searchFederated fans full-text search out over an explicit namespace set and merges reranked hits. +// @intent answer one search across several repositories with per-item namespace labels. +// @domainRule each namespace is queried in isolation; reranking happens once over the merged candidate pool. +func (h *handlers) searchFederated(ctx context.Context, query string, limit int, pathPrefix string, namespaces []string) (*mcp.CallToolResult, error) { + log := h.logger() + return finalizeToolResult(h.cachedExecute(ctx, "search:", map[string]any{"query": query, "limit": limit, "path": pathPrefix, "namespaces": namespaces}, func() (string, error) { + var merged []graph.Node + for _, ns := range namespaces { + nsCtx := requestctx.WithNamespace(ctx, ns) + nodes, err := h.deps.Graph.Search.Query(nsCtx, query, searchrank.FetchLimit(limit)) + if err != nil { + log.Error("federated search error", "query", query, "namespace", ns, trace.SlogError(err)) + return "", trace.Wrap(err, "federated search error") + } + for i := range nodes { + nodes[i].Namespace = ns + } + merged = append(merged, nodes...) + } + + if pathPrefix != "" { + filtered := merged[:0] + for _, n := range merged { + if pathspec.HasPathPrefix(n.FilePath, pathPrefix) { + filtered = append(filtered, n) + } + } + merged = filtered + } + + merged = searchrank.Rerank(query, merged, limit) + log.Info("federated search completed", "query", query, "namespaces", namespaces, "result_count", len(merged)) + + items := make([]searchResultItem, len(merged)) + for i, n := range merged { + items[i] = searchResultItem{ID: n.ID, QualifiedName: n.QualifiedName, Kind: n.Kind, Name: n.Name, FilePath: n.FilePath, Namespace: n.Namespace} + } + result, err := marshalJSON(items) + if err != nil { + return "", trace.Wrap(err, "marshal result") + } + return result, nil + })) +} + // getAnnotation returns stored annotation metadata for a graph node. // @intent fetch stored annotation tags and summary data so semantic search results can show business context. // @param request qualified_name is the fully qualified node name whose annotations should be loaded. @@ -338,6 +400,10 @@ func (h *handlers) queryGraph(ctx context.Context, request mcp.CallToolRequest) return mcp.NewToolResultError(fmt.Sprintf("unknown pattern: %q", pattern)), nil } + if namespaces := requestNamespaces(request); len(namespaces) > 0 { + return h.queryGraphFederated(ctx, pattern, target, limit, offset, includeFallbackCalls, namespaces) + } + return finalizeToolResult(h.cachedExecute(ctx, "query_graph:", map[string]any{ "pattern": pattern, "target": target, @@ -346,171 +412,214 @@ func (h *handlers) queryGraph(ctx context.Context, request mcp.CallToolRequest) "include_fallback_calls": includeFallbackCalls, "namespace": requestNamespace(request), }, func() (string, error) { - // file_summary does not require node lookup. - if pattern == "file_summary" { - if h.deps.Graph.Query == nil { - return "", newToolResultErr("QueryService not configured") - } - summary, err := h.deps.Graph.Query.FileSummaryOf(ctx, target) - if err != nil { - return "", newToolResultErr(fmt.Sprintf("file summary error: %v", err)) - } - result, err := marshalJSON(fileSummaryResponse{Pattern: pattern, Target: target, Results: summary}) - if err != nil { - return "", trace.Wrap(err, "marshal result") - } - return result, nil - } + return h.queryGraphInNamespace(ctx, pattern, target, limit, offset, includeFallbackCalls) + })) +} - // The remaining patterns resolve the target node first. - node, err := h.deps.Graph.Store.GetNode(ctx, target) - if err != nil { - return "", trace.Wrap(err, "store error") - } - if node == nil { - if h.deps.Graph.Query == nil { - return "", nodeNotFoundErr(target) - } - matches, err := h.deps.Graph.Query.FindExactNameMatches(ctx, target, 10) +// queryGraphFederatedResponse is the typed wire payload for federated query_graph calls. +// @intent group per-namespace traversal outcomes under one envelope with per-namespace errors. +type queryGraphFederatedResponse struct { + Pattern string `json:"pattern"` + Target string `json:"target"` + Namespaces []federatedNamespaceEntry `json:"namespaces"` +} + +// queryGraphFederated runs one predefined query across several namespaces and groups the outcomes. +// @intent keep federated traversal per-namespace so a missing target in one namespace never fails the rest. +func (h *handlers) queryGraphFederated(ctx context.Context, pattern, target string, limit, offset int, includeFallbackCalls bool, namespaces []string) (*mcp.CallToolResult, error) { + return finalizeToolResult(h.cachedExecute(ctx, "query_graph:", map[string]any{ + "pattern": pattern, + "target": target, + "limit": limit, + "offset": offset, + "include_fallback_calls": includeFallbackCalls, + "namespaces": namespaces, + }, func() (string, error) { + entries := make([]federatedNamespaceEntry, 0, len(namespaces)) + for _, ns := range namespaces { + nsCtx := requestctx.WithNamespace(ctx, ns) + payload, err := h.queryGraphInNamespace(nsCtx, pattern, target, limit, offset, includeFallbackCalls) if err != nil { - return "", trace.Wrap(err, "query target fallback") - } - switch len(matches) { - case 0: - return "", nodeNotFoundErr(target) - case 1: - node, err = h.deps.Graph.Store.GetNode(ctx, matches[0].QualifiedName) - if err != nil { - return "", trace.Wrap(err, "store fallback lookup") - } - if node == nil { - return "", nodeNotFoundErr(matches[0].QualifiedName) + var resultErr *toolResultErr + if errors.As(err, &resultErr) { + entries = append(entries, federatedNamespaceEntry{Namespace: ns, Error: err.Error()}) + continue } - default: - return "", newToolResultErr(compactQueryTargetAmbiguity(target, matches)) + return "", trace.Wrap(err, "federated query error") } + entries = append(entries, federatedNamespaceEntry{Namespace: ns, Response: json.RawMessage(payload)}) } + return marshalJSON(queryGraphFederatedResponse{Pattern: pattern, Target: target, Namespaces: entries}) + })) +} +// queryGraphInNamespace runs one predefined graph query inside the context namespace. +// @intent share one traversal implementation between single-namespace and federated query_graph calls. +func (h *handlers) queryGraphInNamespace(ctx context.Context, pattern, target string, limit, offset int, includeFallbackCalls bool) (string, error) { + // file_summary does not require node lookup. + if pattern == "file_summary" { if h.deps.Graph.Query == nil { return "", newToolResultErr("QueryService not configured") } - - queryOpts := querypkg.QueryOptions{ - IncludeFallbackCalls: &includeFallbackCalls, - Limit: limit, - Offset: offset, - } - - var nodes []graph.Node - var totalCount int - var page querypkg.PagedNodes - switch pattern { - case "callers_of": - page, err = h.deps.Graph.Query.CallersOfPage(ctx, node.ID, queryOpts) - nodes = page.Nodes - totalCount = page.TotalCount - case "callees_of": - page, err = h.deps.Graph.Query.CalleesOfPage(ctx, node.ID, queryOpts) - nodes = page.Nodes - totalCount = page.TotalCount - case "imports_of": - page, err = h.deps.Graph.Query.ImportsOfPage(ctx, node.ID, queryOpts) - nodes = page.Nodes - totalCount = page.TotalCount - case "importers_of": - page, err = h.deps.Graph.Query.ImportersOfPage(ctx, node.ID, queryOpts) - nodes = page.Nodes - totalCount = page.TotalCount - case "children_of": - page, err = h.deps.Graph.Query.ChildrenOfPage(ctx, node.ID, queryOpts) - nodes = page.Nodes - totalCount = page.TotalCount - case "tests_for": - page, err = h.deps.Graph.Query.TestsForPage(ctx, node.ID, queryOpts) - nodes = page.Nodes - totalCount = page.TotalCount - case "inheritors_of": - page, err = h.deps.Graph.Query.InheritorsOfPage(ctx, node.ID, queryOpts) - nodes = page.Nodes - totalCount = page.TotalCount + summary, err := h.deps.Graph.Query.FileSummaryOf(ctx, target) + if err != nil { + return "", newToolResultErr(fmt.Sprintf("file summary error: %v", err)) } - + result, err := marshalJSON(fileSummaryResponse{Pattern: pattern, Target: target, Results: summary}) if err != nil { - return "", trace.Wrap(err, "query error") + return "", trace.Wrap(err, "marshal result") } + return result, nil + } - neighborEdgeByNodeID := map[uint]graph.Edge{} - var strictPage querypkg.PagedNodes - if pattern == "callers_of" || pattern == "callees_of" { - if includeFallbackCalls { - strictOpts := querypkg.QueryOptions{IncludeFallbackCalls: &strictFalse, Limit: 1, Offset: 0} - switch pattern { - case "callers_of": - strictPage, err = h.deps.Graph.Query.CallersOfPage(ctx, node.ID, strictOpts) - case "callees_of": - strictPage, err = h.deps.Graph.Query.CalleesOfPage(ctx, node.ID, strictOpts) - } - if err != nil { - return "", trace.Wrap(err, "strict query error") - } - } - // Only augment edge evidence for nodes on the current response page. - neighborEdgeByNodeID, err = h.callQueryPatternEdges(ctx, node.ID, pattern, nodes) + // The remaining patterns resolve the target node first. + node, err := h.deps.Graph.Store.GetNode(ctx, target) + if err != nil { + return "", trace.Wrap(err, "store error") + } + if node == nil { + if h.deps.Graph.Query == nil { + return "", nodeNotFoundErr(target) + } + matches, err := h.deps.Graph.Query.FindExactNameMatches(ctx, target, 10) + if err != nil { + return "", trace.Wrap(err, "query target fallback") + } + switch len(matches) { + case 0: + return "", nodeNotFoundErr(target) + case 1: + node, err = h.deps.Graph.Store.GetNode(ctx, matches[0].QualifiedName) if err != nil { - return "", trace.Wrap(err, "query evidence edges") + return "", trace.Wrap(err, "store fallback lookup") } + if node == nil { + return "", nodeNotFoundErr(matches[0].QualifiedName) + } + default: + return "", newToolResultErr(compactQueryTargetAmbiguity(target, matches)) } + } - strictTotal := 0 - if pattern == "callers_of" || pattern == "callees_of" { - if includeFallbackCalls { - strictTotal = strictPage.TotalCount - } else { - strictTotal = totalCount + if h.deps.Graph.Query == nil { + return "", newToolResultErr("QueryService not configured") + } + + queryOpts := querypkg.QueryOptions{ + IncludeFallbackCalls: &includeFallbackCalls, + Limit: limit, + Offset: offset, + } + + var nodes []graph.Node + var totalCount int + var page querypkg.PagedNodes + switch pattern { + case "callers_of": + page, err = h.deps.Graph.Query.CallersOfPage(ctx, node.ID, queryOpts) + nodes = page.Nodes + totalCount = page.TotalCount + case "callees_of": + page, err = h.deps.Graph.Query.CalleesOfPage(ctx, node.ID, queryOpts) + nodes = page.Nodes + totalCount = page.TotalCount + case "imports_of": + page, err = h.deps.Graph.Query.ImportsOfPage(ctx, node.ID, queryOpts) + nodes = page.Nodes + totalCount = page.TotalCount + case "importers_of": + page, err = h.deps.Graph.Query.ImportersOfPage(ctx, node.ID, queryOpts) + nodes = page.Nodes + totalCount = page.TotalCount + case "children_of": + page, err = h.deps.Graph.Query.ChildrenOfPage(ctx, node.ID, queryOpts) + nodes = page.Nodes + totalCount = page.TotalCount + case "tests_for": + page, err = h.deps.Graph.Query.TestsForPage(ctx, node.ID, queryOpts) + nodes = page.Nodes + totalCount = page.TotalCount + case "inheritors_of": + page, err = h.deps.Graph.Query.InheritorsOfPage(ctx, node.ID, queryOpts) + nodes = page.Nodes + totalCount = page.TotalCount + } + + if err != nil { + return "", trace.Wrap(err, "query error") + } + + neighborEdgeByNodeID := map[uint]graph.Edge{} + var strictPage querypkg.PagedNodes + if pattern == "callers_of" || pattern == "callees_of" { + if includeFallbackCalls { + strictOpts := querypkg.QueryOptions{IncludeFallbackCalls: &strictFalse, Limit: 1, Offset: 0} + switch pattern { + case "callers_of": + strictPage, err = h.deps.Graph.Query.CallersOfPage(ctx, node.ID, strictOpts) + case "callees_of": + strictPage, err = h.deps.Graph.Query.CalleesOfPage(ctx, node.ID, strictOpts) + } + if err != nil { + return "", trace.Wrap(err, "strict query error") } } - truncated := false - nextOffset := 0 - if offset+len(nodes) < totalCount { - truncated = true - nextOffset = offset + len(nodes) + // Only augment edge evidence for nodes on the current response page. + neighborEdgeByNodeID, err = h.callQueryPatternEdges(ctx, node.ID, pattern, nodes) + if err != nil { + return "", trace.Wrap(err, "query evidence edges") } + } - qgResults := make([]queryGraphResultItem, len(nodes)) - for i, n := range nodes { - item := queryGraphResultItem{ID: n.ID, QualifiedName: n.QualifiedName, Kind: n.Kind, Name: n.Name, FilePath: n.FilePath} - if pattern == "callers_of" || pattern == "callees_of" { - edge, hasEvidence := neighborEdgeByNodeID[n.ID] - if hasEvidence && edge.Kind == graph.EdgeKindCalls { - item.Confidence = "strict" - item.EdgeKind = string(graph.EdgeKindCalls) - } else { - item.Confidence = "tentative" - item.EdgeKind = string(graph.EdgeKindFallbackCalls) - } - if hasEvidence { - item.Evidence = &queryGraphEvidence{FilePath: edge.FilePath, Line: edge.Line, Fingerprint: edge.Fingerprint} - } - } - qgResults[i] = item + strictTotal := 0 + if pattern == "callers_of" || pattern == "callees_of" { + if includeFallbackCalls { + strictTotal = strictPage.TotalCount + } else { + strictTotal = totalCount } + } + truncated := false + nextOffset := 0 + if offset+len(nodes) < totalCount { + truncated = true + nextOffset = offset + len(nodes) + } - metadata := queryGraphMetadata{Limit: limit, Offset: offset, ReturnedCount: len(qgResults), TotalCount: totalCount, Truncated: truncated} - if truncated { - metadata.NextOffset = &nextOffset - } + qgResults := make([]queryGraphResultItem, len(nodes)) + for i, n := range nodes { + item := queryGraphResultItem{ID: n.ID, QualifiedName: n.QualifiedName, Kind: n.Kind, Name: n.Name, FilePath: n.FilePath} if pattern == "callers_of" || pattern == "callees_of" { - tentativeCount := totalCount - strictTotal - metadata.StrictCount = &strictTotal - metadata.TentativeCount = &tentativeCount - metadata.IncludeFallbackCalls = &includeFallbackCalls - } - result, err := marshalJSON(queryGraphResponse{Pattern: pattern, Target: target, Results: qgResults, Metadata: metadata, Evidence: h.namespaceEvidenceFromContext(ctx)}) - if err != nil { - return "", trace.Wrap(err, "marshal result") + edge, hasEvidence := neighborEdgeByNodeID[n.ID] + if hasEvidence && edge.Kind == graph.EdgeKindCalls { + item.Confidence = "strict" + item.EdgeKind = string(graph.EdgeKindCalls) + } else { + item.Confidence = "tentative" + item.EdgeKind = string(graph.EdgeKindFallbackCalls) + } + if hasEvidence { + item.Evidence = &queryGraphEvidence{FilePath: edge.FilePath, Line: edge.Line, Fingerprint: edge.Fingerprint} + } } - return result, nil - })) + qgResults[i] = item + } + + metadata := queryGraphMetadata{Limit: limit, Offset: offset, ReturnedCount: len(qgResults), TotalCount: totalCount, Truncated: truncated} + if truncated { + metadata.NextOffset = &nextOffset + } + if pattern == "callers_of" || pattern == "callees_of" { + tentativeCount := totalCount - strictTotal + metadata.StrictCount = &strictTotal + metadata.TentativeCount = &tentativeCount + metadata.IncludeFallbackCalls = &includeFallbackCalls + } + result, err := marshalJSON(queryGraphResponse{Pattern: pattern, Target: target, Results: qgResults, Metadata: metadata, Evidence: h.namespaceEvidenceFromContext(ctx)}) + if err != nil { + return "", trace.Wrap(err, "marshal result") + } + return result, nil } // callQueryPatternEdges loads only edge evidence for current page nodes. @@ -552,20 +661,15 @@ func (h *handlers) listGraphStats(ctx context.Context, request mcp.CallToolReque log := h.logger() log.Info("list_graph_stats called") + if namespaces := requestNamespaces(request); len(namespaces) > 0 { + return h.listGraphStatsFederated(ctx, namespaces) + } + return finalizeToolResult(h.cachedExecute(ctx, "list_graph_stats:", map[string]any{"namespace": requestNamespace(request)}, func() (string, error) { - stats, err := h.deps.Graph.Statistics.GraphStatistics(ctx) + statsData, err := h.graphStatsInNamespace(ctx) if err != nil { return "", err } - - statsData := listGraphStatsResponse{ - TotalNodes: stats.NodeCount, - TotalEdges: stats.EdgeCount, - NodesByKind: stats.NodesByKind, - NodesByLanguage: stats.NodesByLanguage, - EdgesByKind: stats.EdgesByKind, - Evidence: h.namespaceEvidenceFromContext(ctx), - } result, err := marshalJSON(statsData) if err != nil { return "", trace.Wrap(err, "marshal result") @@ -574,6 +678,47 @@ func (h *handlers) listGraphStats(ctx context.Context, request mcp.CallToolReque })) } +// graphStatsInNamespace loads the statistics payload for the context namespace. +// @intent share one statistics assembly between single-namespace and federated calls. +func (h *handlers) graphStatsInNamespace(ctx context.Context) (listGraphStatsResponse, error) { + stats, err := h.deps.Graph.Statistics.GraphStatistics(ctx) + if err != nil { + return listGraphStatsResponse{}, err + } + return listGraphStatsResponse{ + TotalNodes: stats.NodeCount, + TotalEdges: stats.EdgeCount, + NodesByKind: stats.NodesByKind, + NodesByLanguage: stats.NodesByLanguage, + EdgesByKind: stats.EdgesByKind, + Evidence: h.namespaceEvidenceFromContext(ctx), + }, nil +} + +// federatedGraphStatsEntry labels one namespace's statistics inside a federated response. +// @intent keep per-namespace statistics separable instead of summing unrelated graphs. +type federatedGraphStatsEntry struct { + Namespace string `json:"namespace"` + listGraphStatsResponse +} + +// listGraphStatsFederated returns statistics grouped per namespace. +// @intent give one call visibility over several repositories without merging their counts. +func (h *handlers) listGraphStatsFederated(ctx context.Context, namespaces []string) (*mcp.CallToolResult, error) { + return finalizeToolResult(h.cachedExecute(ctx, "list_graph_stats:", map[string]any{"namespaces": namespaces}, func() (string, error) { + entries := make([]federatedGraphStatsEntry, 0, len(namespaces)) + for _, ns := range namespaces { + nsCtx := requestctx.WithNamespace(ctx, ns) + statsData, err := h.graphStatsInNamespace(nsCtx) + if err != nil { + return "", trace.Wrap(err, "federated stats error") + } + entries = append(entries, federatedGraphStatsEntry{Namespace: ns, listGraphStatsResponse: statsData}) + } + return marshalJSON(map[string]any{"namespaces": entries}) + })) +} + // validateQueryGraphLimit checks that the limit parameter for queryGraph is within acceptable bounds. // @intent enforce reasonable limits on queryGraph results to prevent excessive load and encourage pagination. func validateQueryGraphLimit(limit int) error { diff --git a/internal/adapters/inbound/mcp/handlers.go b/internal/adapters/inbound/mcp/handlers.go index 5763eb3..c0fbb4f 100644 --- a/internal/adapters/inbound/mcp/handlers.go +++ b/internal/adapters/inbound/mcp/handlers.go @@ -106,6 +106,27 @@ func requestNamespace(request mcp.CallToolRequest) string { return request.GetString("namespace", "") } +// requestNamespaces reads the multi-namespace federation argument. +// @intent let read tools fan out over several namespaces while keeping the single-namespace contract untouched. +// @domainRule values are normalized and deduplicated; an empty selection means single-namespace mode. +func requestNamespaces(request mcp.CallToolRequest) []string { + raw := request.GetStringSlice("namespaces", nil) + if len(raw) == 0 { + return nil + } + seen := make(map[string]bool, len(raw)) + namespaces := make([]string, 0, len(raw)) + for _, ns := range raw { + normalized := requestctx.Normalize(ns) + if seen[normalized] { + continue + } + seen[normalized] = true + namespaces = append(namespaces, normalized) + } + return namespaces +} + // makeCacheKey builds a cache key from a prefix and JSON-encoded parameters. // @intent turn request parameters into a stable string key so tool-result caching can reuse previous responses. // @param prefix scopes the cache namespace for a tool family. @@ -218,12 +239,14 @@ func finalizeToolResult(result string, err error) (*mcp.CallToolResult, error) { // nodeSummary is a compact node response payload shared by graph handlers. // @intent reuse one typed node representation across multiple tool responses. +// @domainRule Namespace is set only by cross-namespace analysis so existing responses keep their wire format. type nodeSummary struct { ID uint `json:"id"` QualifiedName string `json:"qualified_name"` Kind graph.NodeKind `json:"kind"` Name string `json:"name"` FilePath string `json:"file_path"` + Namespace string `json:"namespace,omitempty"` } // nodeToSummary converts a graph node into a compact typed response payload. diff --git a/internal/adapters/inbound/mcp/tools_docs.go b/internal/adapters/inbound/mcp/tools_docs.go index 6989e2c..400f485 100644 --- a/internal/adapters/inbound/mcp/tools_docs.go +++ b/internal/adapters/inbound/mcp/tools_docs.go @@ -24,6 +24,7 @@ func docsTools(h *handlers) []server.ServerTool { mcp.WithString("query", mcp.Description("Search keyword (case-insensitive)"), mcp.Required()), mcp.WithNumber("limit", mcp.Description("Maximum number of results (default: 10, max: 500)")), mcp.WithString("namespace", mcp.Description("Namespace. When set, searches that namespace's DB-backed documentation candidates.")), + mcp.WithArray("namespaces", mcp.Description("Federate this search across multiple namespaces (overrides 'namespace'); results are grouped per namespace"), mcp.WithStringItems()), ), Handler: h.searchDocs, }, diff --git a/internal/adapters/inbound/mcp/tools_query.go b/internal/adapters/inbound/mcp/tools_query.go index 3fbfe14..c82db6a 100644 --- a/internal/adapters/inbound/mcp/tools_query.go +++ b/internal/adapters/inbound/mcp/tools_query.go @@ -14,6 +14,14 @@ func withNamespaceParam(opts ...mcp.ToolOption) []mcp.ToolOption { ) } +// withFederatedNamespaceParams appends single- and multi-namespace arguments to a tool definition. +// @intent let federated read tools accept an explicit namespace set alongside the canonical single namespace. +func withFederatedNamespaceParams(opts ...mcp.ToolOption) []mcp.ToolOption { + return append(withNamespaceParam(opts...), + mcp.WithArray("namespaces", mcp.Description("Federate this call across multiple namespaces (overrides 'namespace'); results are labeled per namespace"), mcp.WithStringItems()), + ) +} + // queryTools registers lookup and traversal tools over the stored graph. // @intent expose reusable graph query primitives that other prompts and agents can compose. func queryTools(h *handlers) []server.ServerTool { @@ -26,7 +34,7 @@ func queryTools(h *handlers) []server.ServerTool { Handler: h.getNode, }, { - Tool: mcp.NewTool("search", withNamespaceParam( + Tool: mcp.NewTool("search", withFederatedNamespaceParams( mcp.WithDescription("Full-text search across code nodes. Use 'path' to scope results to a module for token-efficient queries."), mcp.WithString("query", mcp.Description("Search query string"), mcp.Required()), mcp.WithNumber("limit", mcp.Description("Maximum number of results"), mcp.DefaultNumber(10)), @@ -42,7 +50,7 @@ func queryTools(h *handlers) []server.ServerTool { Handler: h.getAnnotation, }, { - Tool: mcp.NewTool("query_graph", withNamespaceParam( + Tool: mcp.NewTool("query_graph", withFederatedNamespaceParams( mcp.WithDescription("Run predefined graph queries: callers_of, callees_of, imports_of, importers_of, children_of, tests_for, inheritors_of, file_summary"), mcp.WithString("pattern", mcp.Description("Query pattern"), mcp.Required()), mcp.WithString("target", mcp.Description("Target qualified name or file path"), mcp.Required()), @@ -53,7 +61,7 @@ func queryTools(h *handlers) []server.ServerTool { Handler: h.queryGraph, }, { - Tool: mcp.NewTool("list_graph_stats", withNamespaceParam( + Tool: mcp.NewTool("list_graph_stats", withFederatedNamespaceParams( mcp.WithDescription("Get graph statistics: total nodes, edges, and breakdowns by kind and language"), )...), Handler: h.listGraphStats, From 7c51bee4dded419e9f2dcb5f5b60d7dc0f3f1993 Mon Sep 17 00:00:00 2001 From: tae2089 Date: Sun, 19 Jul 2026 10:55:36 +0900 Subject: [PATCH 04/10] feat: cross-namespace impact/flow analysis and list_cross_refs tool Add a namespace-agnostic CrossNamespaceReader that reads nodes and edges by globally-unique id and merges resolved cross_refs rows into traversal as synthetic cross_ref edges. Existing BFS and flow-trace algorithms run unchanged on top of it. - get_impact_radius and trace_flow accept cross_namespace: true and traverse resolved ccg:// refs in both directions; impact results carry a namespace label in cross mode only. - New list_cross_refs tool exposes the repository-level dependency map (direction outbound/inbound/both, optional status filter). MCP tool count goes from 17 to 18. - The synthetic cross_ref edge kind is never persisted; the flow tracer accepts it as a continuation because regular stores never emit it. Co-Authored-By: Claude Fable 5 --- internal/adapters/inbound/mcp/deps.go | 19 +- .../adapters/inbound/mcp/handler_analysis.go | 28 ++- .../inbound/mcp/handler_crossns_test.go | 199 ++++++++++++++++++ .../adapters/inbound/mcp/handler_crossref.go | 98 +++++++++ internal/adapters/inbound/mcp/server_test.go | 5 +- .../adapters/inbound/mcp/testhelpers_test.go | 11 +- .../adapters/inbound/mcp/tools_analysis.go | 10 + .../outbound/graphgorm/crossnamespace.go | 126 +++++++++++ .../outbound/graphgorm/crossnamespace_test.go | 136 ++++++++++++ internal/app/analyze/flow/flow.go | 4 +- internal/domain/graph/edge.go | 5 + internal/runtime/mcp/runtime.go | 3 + 12 files changed, 627 insertions(+), 17 deletions(-) create mode 100644 internal/adapters/inbound/mcp/handler_crossns_test.go create mode 100644 internal/adapters/inbound/mcp/handler_crossref.go create mode 100644 internal/adapters/outbound/graphgorm/crossnamespace.go create mode 100644 internal/adapters/outbound/graphgorm/crossnamespace_test.go diff --git a/internal/adapters/inbound/mcp/deps.go b/internal/adapters/inbound/mcp/deps.go index 4dffd33..d823085 100644 --- a/internal/adapters/inbound/mcp/deps.go +++ b/internal/adapters/inbound/mcp/deps.go @@ -109,13 +109,24 @@ type GraphToolsDeps struct { Reader analyze.GraphReadRepository } +// CrossRefLister exposes materialized cross-namespace references for listing tools. +// @intent let handlers enumerate repository-level dependencies without a store implementation dependency. +type CrossRefLister interface { + ListOutboundCrossRefs(ctx context.Context, fromNamespace string) ([]graph.CrossRef, error) + ListInboundCrossRefs(ctx context.Context, toNamespace string) ([]graph.CrossRef, error) +} + // AnalysisToolsDeps owns bounded impact, flow, and git-change analysis dependencies. // @intent group only configured application analyzers and their read-model port. +// @domainRule CrossImpact/CrossFlow/CrossRefs are optional; when nil the cross-namespace analysis surface reports itself unconfigured. type AnalysisToolsDeps struct { - Impact ImpactAnalyzer - Flow FlowTracer - Changes ChangeAnalyzer - Reader analyze.GraphReadRepository + Impact ImpactAnalyzer + Flow FlowTracer + Changes ChangeAnalyzer + Reader analyze.GraphReadRepository + CrossImpact ImpactAnalyzer + CrossFlow FlowTracer + CrossRefs CrossRefLister } // DocsToolsDeps owns DB-primary documentation retrieval. diff --git a/internal/adapters/inbound/mcp/handler_analysis.go b/internal/adapters/inbound/mcp/handler_analysis.go index e1ecdc2..35896eb 100644 --- a/internal/adapters/inbound/mcp/handler_analysis.go +++ b/internal/adapters/inbound/mcp/handler_analysis.go @@ -116,14 +116,19 @@ func (h *handlers) getImpactRadius(ctx context.Context, request mcp.CallToolRequ depth := request.GetInt("depth", 1) maxDepth := request.GetInt("max_depth", defaultImpactMaxDepth) maxNodes := request.GetInt("max_nodes", defaultImpactMaxNodes) + crossNamespace := request.GetBool("cross_namespace", false) - log.InfoContext(ctx, "get_impact_radius called", append(obs.TraceLogArgs(ctx), "qualified_name", qn, "depth", depth)...) + log.InfoContext(ctx, "get_impact_radius called", append(obs.TraceLogArgs(ctx), "qualified_name", qn, "depth", depth, "cross_namespace", crossNamespace)...) - if h.deps.Analysis.Impact == nil { + analyzer := h.deps.Analysis.Impact + if crossNamespace { + analyzer = h.deps.Analysis.CrossImpact + } + if analyzer == nil { return mcp.NewToolResultError("ImpactAnalyzer not configured"), nil } - return finalizeToolResult(h.cachedExecute(ctx, "get_impact_radius:", map[string]any{"qualified_name": qn, "depth": depth, "max_depth": maxDepth, "max_nodes": maxNodes, "namespace": requestNamespace(request)}, func() (string, error) { + return finalizeToolResult(h.cachedExecute(ctx, "get_impact_radius:", map[string]any{"qualified_name": qn, "depth": depth, "max_depth": maxDepth, "max_nodes": maxNodes, "namespace": requestNamespace(request), "cross_namespace": crossNamespace}, func() (string, error) { node, err := h.deps.Graph.Store.GetNode(ctx, qn) if err != nil { log.ErrorContext(ctx, "store error", append(obs.TraceLogArgs(ctx), "tool", "get_impact_radius", trace.SlogError(err))...) @@ -134,7 +139,7 @@ func (h *handlers) getImpactRadius(ctx context.Context, request mcp.CallToolRequ return "", nodeNotFoundErr(qn) } - res, err := h.deps.Analysis.Impact.ImpactRadiusBounded(ctx, node.ID, depth, impactpkg.RadiusOptions{MaxDepth: maxDepth, MaxNodes: maxNodes}) + res, err := analyzer.ImpactRadiusBounded(ctx, node.ID, depth, impactpkg.RadiusOptions{MaxDepth: maxDepth, MaxNodes: maxNodes}) if err != nil { log.ErrorContext(ctx, "impact analysis error", append(obs.TraceLogArgs(ctx), "node_id", node.ID, trace.SlogError(err))...) return "", trace.Wrap(err, "impact analysis error") @@ -147,6 +152,9 @@ func (h *handlers) getImpactRadius(ctx context.Context, request mcp.CallToolRequ impactResult := make([]nodeSummary, len(nodes)) for i, n := range nodes { impactResult[i] = nodeToSummary(n) + if crossNamespace { + impactResult[i].Namespace = n.Namespace + } } result, err := marshalJSON(impactRadiusResponse{ Nodes: impactResult, @@ -179,9 +187,14 @@ func (h *handlers) traceFlow(ctx context.Context, request mcp.CallToolRequest) ( return missingParamResult(err) } - log.InfoContext(ctx, "trace_flow called", append(obs.TraceLogArgs(ctx), "qualified_name", qn)...) + crossNamespace := request.GetBool("cross_namespace", false) + log.InfoContext(ctx, "trace_flow called", append(obs.TraceLogArgs(ctx), "qualified_name", qn, "cross_namespace", crossNamespace)...) - if h.deps.Analysis.Flow == nil { + tracer := h.deps.Analysis.Flow + if crossNamespace { + tracer = h.deps.Analysis.CrossFlow + } + if tracer == nil { return mcp.NewToolResultError("FlowTracer not configured"), nil } @@ -192,6 +205,7 @@ func (h *handlers) traceFlow(ctx context.Context, request mcp.CallToolRequest) ( "max_nodes": maxNodes, "include_fallback_calls": includeFallbackCalls, "namespace": requestNamespace(request), + "cross_namespace": crossNamespace, }, func() (string, error) { node, err := h.deps.Graph.Store.GetNode(ctx, qn) if err != nil { @@ -203,7 +217,7 @@ func (h *handlers) traceFlow(ctx context.Context, request mcp.CallToolRequest) ( return "", nodeNotFoundErr(qn) } - res, err := h.deps.Analysis.Flow.TraceFlowBounded(ctx, node.ID, flowspkg.TraceOptions{MaxNodes: maxNodes, IncludeFallbackCalls: &includeFallbackCalls}) + res, err := tracer.TraceFlowBounded(ctx, node.ID, flowspkg.TraceOptions{MaxNodes: maxNodes, IncludeFallbackCalls: &includeFallbackCalls}) if err != nil { log.ErrorContext(ctx, "trace error", append(obs.TraceLogArgs(ctx), "node_id", node.ID, trace.SlogError(err))...) return "", trace.Wrap(err, "trace error") diff --git a/internal/adapters/inbound/mcp/handler_crossns_test.go b/internal/adapters/inbound/mcp/handler_crossns_test.go new file mode 100644 index 0000000..028a280 --- /dev/null +++ b/internal/adapters/inbound/mcp/handler_crossns_test.go @@ -0,0 +1,199 @@ +package mcp + +import ( + "context" + "encoding/json" + "testing" + + requestctx "github.com/tae2089/code-context-graph/internal/ctx" + "github.com/tae2089/code-context-graph/internal/domain/graph" +) + +// seedCrossNamespaceDeps stores web.Login -> (cross ref) -> auth.ValidateToken -> auth.RecordAudit. +func seedCrossNamespaceDeps(t *testing.T, deps *Deps) (webLogin, authValidate, authAudit uint) { + t.Helper() + st := testGraphStoreFor(deps) + seed := func(namespace string, node graph.Node) uint { + t.Helper() + ctx := requestctx.WithNamespace(context.Background(), namespace) + if err := st.UpsertNodes(ctx, []graph.Node{node}); err != nil { + t.Fatalf("seed node: %v", err) + } + stored, err := st.GetNode(ctx, node.QualifiedName) + if err != nil || stored == nil { + t.Fatalf("load node %s: %v", node.QualifiedName, err) + } + return stored.ID + } + webLogin = seed("web", graph.Node{QualifiedName: "web.Login", Kind: graph.NodeKindFunction, Name: "Login", FilePath: "internal/web/login.go", StartLine: 5, EndLine: 25}) + authValidate = seed("auth-svc", graph.Node{QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken", FilePath: "internal/auth/token.go", StartLine: 10, EndLine: 20}) + authAudit = seed("auth-svc", graph.Node{QualifiedName: "auth.RecordAudit", Kind: graph.NodeKindFunction, Name: "RecordAudit", FilePath: "internal/audit/audit.go", StartLine: 3, EndLine: 9}) + + authCtx := requestctx.WithNamespace(context.Background(), "auth-svc") + if err := st.UpsertEdges(authCtx, []graph.Edge{{ + FromNodeID: authValidate, ToNodeID: authAudit, Kind: graph.EdgeKindCalls, + FilePath: "internal/auth/token.go", Line: 15, Fingerprint: "calls:internal/auth/token.go:RecordAudit:15", + }}); err != nil { + t.Fatalf("seed edge: %v", err) + } + + resolved := authValidate + if err := st.ReplaceCrossRefsFrom(context.Background(), "web", []graph.CrossRef{{ + FromNamespace: "web", FromNodeID: webLogin, Raw: "ccg://auth-svc/internal/auth/token.go#ValidateToken", + ToNamespace: "auth-svc", ToPath: "internal/auth/token.go", ToSymbol: "ValidateToken", + ResolvedNodeID: &resolved, Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation, + }}); err != nil { + t.Fatalf("seed cross ref: %v", err) + } + return webLogin, authValidate, authAudit +} + +func TestGetImpactRadius_CrossNamespace(t *testing.T) { + deps := setupTestDeps(t) + seedCrossNamespaceDeps(t, deps) + + result := callTool(t, deps, "get_impact_radius", map[string]any{ + "qualified_name": "web.Login", + "namespace": "web", + "depth": 2, + "cross_namespace": true, + }) + var payload struct { + Nodes []struct { + QualifiedName string `json:"qualified_name"` + Namespace string `json:"namespace"` + } `json:"nodes"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + seen := map[string]string{} + for _, n := range payload.Nodes { + seen[n.QualifiedName] = n.Namespace + } + if seen["auth.ValidateToken"] != "auth-svc" { + t.Fatalf("impact nodes = %v, want auth.ValidateToken labeled auth-svc", seen) + } + if seen["auth.RecordAudit"] != "auth-svc" { + t.Fatalf("impact nodes = %v, want depth-2 traversal to continue inside auth-svc", seen) + } +} + +func TestGetImpactRadius_CrossNamespaceReverse(t *testing.T) { + deps := setupTestDeps(t) + seedCrossNamespaceDeps(t, deps) + + result := callTool(t, deps, "get_impact_radius", map[string]any{ + "qualified_name": "auth.ValidateToken", + "namespace": "auth-svc", + "depth": 1, + "cross_namespace": true, + }) + var payload struct { + Nodes []struct { + QualifiedName string `json:"qualified_name"` + Namespace string `json:"namespace"` + } `json:"nodes"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + found := false + for _, n := range payload.Nodes { + if n.QualifiedName == "web.Login" && n.Namespace == "web" { + found = true + } + } + if !found { + t.Fatalf("reverse impact = %+v, want web.Login from web namespace", payload.Nodes) + } +} + +func TestGetImpactRadius_DefaultStaysNamespaceScoped(t *testing.T) { + deps := setupTestDeps(t) + seedCrossNamespaceDeps(t, deps) + + result := callTool(t, deps, "get_impact_radius", map[string]any{ + "qualified_name": "web.Login", + "namespace": "web", + "depth": 2, + }) + var payload struct { + Nodes []struct { + QualifiedName string `json:"qualified_name"` + } `json:"nodes"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, n := range payload.Nodes { + if n.QualifiedName == "auth.ValidateToken" { + t.Fatal("default impact crossed namespaces without cross_namespace flag") + } + } +} + +func TestTraceFlow_CrossNamespace(t *testing.T) { + deps := setupTestDeps(t) + _, authValidate, authAudit := seedCrossNamespaceDeps(t, deps) + + result := callTool(t, deps, "trace_flow", map[string]any{ + "qualified_name": "web.Login", + "namespace": "web", + "cross_namespace": true, + }) + var payload struct { + Members []struct { + NodeID uint `json:"node_id"` + } `json:"members"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + got := map[uint]bool{} + for _, m := range payload.Members { + got[m.NodeID] = true + } + if !got[authValidate] || !got[authAudit] { + t.Fatalf("flow members = %+v, want continuation into auth-svc nodes %d and %d", payload.Members, authValidate, authAudit) + } +} + +func TestListCrossRefs_Directions(t *testing.T) { + deps := setupTestDeps(t) + webLogin, authValidate, _ := seedCrossNamespaceDeps(t, deps) + + outbound := callTool(t, deps, "list_cross_refs", map[string]any{"namespace": "web", "direction": "outbound"}) + var outPayload struct { + Refs []struct { + FromNamespace string `json:"from_namespace"` + FromNodeID uint `json:"from_node_id"` + ToNamespace string `json:"to_namespace"` + ResolvedNodeID *uint `json:"resolved_node_id"` + Status string `json:"status"` + } `json:"refs"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, outbound)), &outPayload); err != nil { + t.Fatalf("unmarshal outbound: %v", err) + } + if len(outPayload.Refs) != 1 { + t.Fatalf("outbound refs = %d, want 1", len(outPayload.Refs)) + } + ref := outPayload.Refs[0] + if ref.FromNodeID != webLogin || ref.ToNamespace != "auth-svc" || ref.Status != "resolved" || ref.ResolvedNodeID == nil || *ref.ResolvedNodeID != authValidate { + t.Fatalf("outbound ref = %+v, want resolved web -> auth-svc link", ref) + } + + inbound := callTool(t, deps, "list_cross_refs", map[string]any{"namespace": "auth-svc", "direction": "inbound"}) + var inPayload struct { + Refs []struct { + FromNamespace string `json:"from_namespace"` + } `json:"refs"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, inbound)), &inPayload); err != nil { + t.Fatalf("unmarshal inbound: %v", err) + } + if len(inPayload.Refs) != 1 || inPayload.Refs[0].FromNamespace != "web" { + t.Fatalf("inbound refs = %+v, want one ref from web", inPayload.Refs) + } +} diff --git a/internal/adapters/inbound/mcp/handler_crossref.go b/internal/adapters/inbound/mcp/handler_crossref.go new file mode 100644 index 0000000..3f48ab4 --- /dev/null +++ b/internal/adapters/inbound/mcp/handler_crossref.go @@ -0,0 +1,98 @@ +// @index MCP handler exposing materialized cross-namespace references as a repository dependency map. +package mcp + +import ( + "context" + "fmt" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/tae2089/trace" + + requestctx "github.com/tae2089/code-context-graph/internal/ctx" + "github.com/tae2089/code-context-graph/internal/domain/graph" +) + +// crossRefItem serializes one materialized cross-namespace reference. +// @intent expose symbolic target identity and derived resolution state without internal row metadata. +type crossRefItem struct { + FromNamespace string `json:"from_namespace"` + FromNodeID uint `json:"from_node_id"` + Raw string `json:"raw"` + ToNamespace string `json:"to_namespace"` + ToPath string `json:"to_path,omitempty"` + ToSymbol string `json:"to_symbol,omitempty"` + ResolvedNodeID *uint `json:"resolved_node_id,omitempty"` + Status string `json:"status"` + Source string `json:"source"` +} + +// listCrossRefsResponse is the typed wire payload for listCrossRefs. +// @intent keep the requested namespace and direction visible next to the reference list. +type listCrossRefsResponse struct { + Namespace string `json:"namespace"` + Direction string `json:"direction"` + Refs []crossRefItem `json:"refs"` +} + +// listCrossRefs lists cross-namespace references declared by or targeting one namespace. +// @intent give agents a repository-level dependency map derived from ccg:// annotations. +// @param request direction selects outbound, inbound, or both; status optionally filters by resolution state. +// @requires the cross-ref lister must be configured. +// @ensures returns refs sorted as stored (outbound before inbound for direction both). +func (h *handlers) listCrossRefs(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + ctx = h.applyNamespace(ctx, request) + log := h.logger() + + direction := request.GetString("direction", "both") + if direction != "outbound" && direction != "inbound" && direction != "both" { + return mcp.NewToolResultError(fmt.Sprintf("unknown direction: %q (want outbound, inbound, or both)", direction)), nil + } + status := request.GetString("status", "") + if status != "" && status != string(graph.CrossRefStatusResolved) && status != string(graph.CrossRefStatusDead) { + return mcp.NewToolResultError(fmt.Sprintf("unknown status: %q (want resolved or dead)", status)), nil + } + ns := requestctx.FromContext(ctx) + + log.Info("list_cross_refs called", "namespace", ns, "direction", direction, "status", status) + + if h.deps.Analysis.CrossRefs == nil { + return mcp.NewToolResultError("cross-ref lister not configured"), nil + } + + return finalizeToolResult(h.cachedExecute(ctx, "list_cross_refs:", map[string]any{"namespace": ns, "direction": direction, "status": status}, func() (string, error) { + var rows []graph.CrossRef + if direction == "outbound" || direction == "both" { + outbound, err := h.deps.Analysis.CrossRefs.ListOutboundCrossRefs(ctx, ns) + if err != nil { + return "", trace.Wrap(err, "list outbound cross refs") + } + rows = append(rows, outbound...) + } + if direction == "inbound" || direction == "both" { + inbound, err := h.deps.Analysis.CrossRefs.ListInboundCrossRefs(ctx, ns) + if err != nil { + return "", trace.Wrap(err, "list inbound cross refs") + } + rows = append(rows, inbound...) + } + + refs := make([]crossRefItem, 0, len(rows)) + for _, row := range rows { + if status != "" && string(row.Status) != status { + continue + } + refs = append(refs, crossRefItem{ + FromNamespace: row.FromNamespace, + FromNodeID: row.FromNodeID, + Raw: row.Raw, + ToNamespace: row.ToNamespace, + ToPath: row.ToPath, + ToSymbol: row.ToSymbol, + ResolvedNodeID: row.ResolvedNodeID, + Status: string(row.Status), + Source: string(row.Source), + }) + } + return marshalJSON(listCrossRefsResponse{Namespace: ns, Direction: direction, Refs: refs}) + })) +} diff --git a/internal/adapters/inbound/mcp/server_test.go b/internal/adapters/inbound/mcp/server_test.go index aad676a..fa6f7ca 100644 --- a/internal/adapters/inbound/mcp/server_test.go +++ b/internal/adapters/inbound/mcp/server_test.go @@ -23,6 +23,7 @@ func TestMCPServer_ListTools(t *testing.T) { "search", "get_annotation", "trace_flow", + "list_cross_refs", "build_or_update_graph", "run_postprocess", "query_graph", @@ -63,8 +64,8 @@ func TestMCPServer_ListTools_Count(t *testing.T) { srv := NewServer(deps) tools := srv.ListTools() - if len(tools) != 17 { - t.Fatalf("expected 17 tools, got %d", len(tools)) + if len(tools) != 18 { + t.Fatalf("expected 18 tools, got %d", len(tools)) } } diff --git a/internal/adapters/inbound/mcp/testhelpers_test.go b/internal/adapters/inbound/mcp/testhelpers_test.go index 4cee259..e2b5753 100644 --- a/internal/adapters/inbound/mcp/testhelpers_test.go +++ b/internal/adapters/inbound/mcp/testhelpers_test.go @@ -204,9 +204,14 @@ func groupedTestDeps(st *graphgorm.Store, db *gorm.DB, sb search.Backend, parser Statistics: st, Reader: st, }, - Analysis: AnalysisToolsDeps{Impact: impact.New(st), Flow: flows.New(st), Reader: st}, - Docs: DocsToolsDeps{Retrieval: retrieval.New(reader, reader)}, - Runtime: RuntimeToolsDeps{Logger: log, RepoRoot: os.TempDir()}, + Analysis: AnalysisToolsDeps{ + Impact: impact.New(st), Flow: flows.New(st), Reader: st, + CrossImpact: impact.New(st.CrossNamespaceReader()), + CrossFlow: flows.New(st.CrossNamespaceReader()), + CrossRefs: st, + }, + Docs: DocsToolsDeps{Retrieval: retrieval.New(reader, reader)}, + Runtime: RuntimeToolsDeps{Logger: log, RepoRoot: os.TempDir()}, } } diff --git a/internal/adapters/inbound/mcp/tools_analysis.go b/internal/adapters/inbound/mcp/tools_analysis.go index 894c236..a382093 100644 --- a/internal/adapters/inbound/mcp/tools_analysis.go +++ b/internal/adapters/inbound/mcp/tools_analysis.go @@ -17,6 +17,7 @@ func analysisTools(h *handlers) []server.ServerTool { mcp.WithNumber("depth", mcp.Description("BFS traversal depth"), mcp.DefaultNumber(1)), mcp.WithNumber("max_depth", mcp.Description("Maximum BFS depth returned"), mcp.DefaultNumber(defaultImpactMaxDepth)), mcp.WithNumber("max_nodes", mcp.Description("Maximum nodes returned"), mcp.DefaultNumber(defaultImpactMaxNodes)), + mcp.WithBoolean("cross_namespace", mcp.Description("When true, traversal follows resolved ccg:// cross-namespace refs in both directions; results carry a namespace label")), )...), Handler: h.getImpactRadius, }, @@ -26,9 +27,18 @@ func analysisTools(h *handlers) []server.ServerTool { mcp.WithString("qualified_name", mcp.Description("Fully qualified node name"), mcp.Required()), mcp.WithNumber("max_nodes", mcp.Description("Maximum flow members returned"), mcp.DefaultNumber(defaultTraceMaxNodes)), mcp.WithBoolean("include_fallback_calls", mcp.Description("When false, trace_flow excludes fallback_calls edges; defaults to true")), + mcp.WithBoolean("cross_namespace", mcp.Description("When true, the trace continues across resolved ccg:// cross-namespace refs")), )...), Handler: h.traceFlow, }, + { + Tool: mcp.NewTool("list_cross_refs", withNamespaceParam( + mcp.WithDescription("List materialized ccg:// cross-namespace references for a namespace, as a repository-level dependency map"), + mcp.WithString("direction", mcp.Description("outbound (refs this namespace declares), inbound (refs targeting this namespace), or both (default)")), + mcp.WithString("status", mcp.Description("Filter by resolution status: resolved or dead")), + )...), + Handler: h.listCrossRefs, + }, { Tool: mcp.NewTool("detect_changes", withNamespaceParam( mcp.WithDescription("Detect changed functions with risk scores based on git diff"), diff --git a/internal/adapters/outbound/graphgorm/crossnamespace.go b/internal/adapters/outbound/graphgorm/crossnamespace.go new file mode 100644 index 0000000..4a1cf48 --- /dev/null +++ b/internal/adapters/outbound/graphgorm/crossnamespace.go @@ -0,0 +1,126 @@ +// @index Namespace-agnostic graph reader that merges cross_refs into traversal edges for cross-repository analysis. +package graphgorm + +import ( + "context" + "errors" + + "gorm.io/gorm" + + "github.com/tae2089/trace" + + "github.com/tae2089/code-context-graph/internal/domain/graph" +) + +// CrossNamespaceReader reads nodes and edges across every namespace and augments +// traversal with synthetic cross_ref edges derived from resolved cross-namespace references. +// @intent let impact and flow analysis walk across repository boundaries declared by annotations. +// @domainRule node ids are globally unique, so id-based reads are safe without a namespace filter. +type CrossNamespaceReader struct { + db *gorm.DB +} + +// CrossNamespaceReader returns a namespace-agnostic reader over the same database. +// @intent derive the cross-repository read surface from an existing store without new wiring inputs. +func (s *Store) CrossNamespaceReader() *CrossNamespaceReader { + return &CrossNamespaceReader{db: s.db} +} + +// GetEdgesFrom returns outgoing edges of one node across namespaces, including cross refs. +// @intent satisfy the impact analyzer contract for cross-namespace traversal. +func (r *CrossNamespaceReader) GetEdgesFrom(ctx context.Context, nodeID uint) ([]graph.Edge, error) { + return r.GetEdgesFromNodes(ctx, []uint{nodeID}) +} + +// GetEdgesFromNodes returns outgoing edges of the node set across namespaces, including cross refs. +// @intent expand traversal frontiers across repository boundaries in one query pair. +func (r *CrossNamespaceReader) GetEdgesFromNodes(ctx context.Context, nodeIDs []uint) ([]graph.Edge, error) { + if len(nodeIDs) == 0 { + return nil, nil + } + var edges []graph.Edge + if err := r.db.WithContext(ctx).Where("from_node_id IN ?", nodeIDs).Find(&edges).Error; err != nil { + return nil, trace.Wrap(err, "cross-namespace edges from nodes") + } + var refs []graph.CrossRef + if err := r.db.WithContext(ctx). + Where("from_node_id IN ? AND status = ? AND resolved_node_id IS NOT NULL", nodeIDs, graph.CrossRefStatusResolved). + Find(&refs).Error; err != nil { + return nil, trace.Wrap(err, "cross-namespace refs from nodes") + } + return append(edges, crossRefEdges(refs)...), nil +} + +// GetEdgesTo returns incoming edges of one node across namespaces, including cross refs. +// @intent satisfy the impact analyzer contract for reverse cross-namespace traversal. +func (r *CrossNamespaceReader) GetEdgesTo(ctx context.Context, nodeID uint) ([]graph.Edge, error) { + return r.GetEdgesToNodes(ctx, []uint{nodeID}) +} + +// GetEdgesToNodes returns incoming edges of the node set across namespaces, including cross refs. +// @intent let impact analysis find foreign namespaces that depend on the target nodes. +func (r *CrossNamespaceReader) GetEdgesToNodes(ctx context.Context, nodeIDs []uint) ([]graph.Edge, error) { + if len(nodeIDs) == 0 { + return nil, nil + } + var edges []graph.Edge + if err := r.db.WithContext(ctx).Where("to_node_id IN ?", nodeIDs).Find(&edges).Error; err != nil { + return nil, trace.Wrap(err, "cross-namespace edges to nodes") + } + var refs []graph.CrossRef + if err := r.db.WithContext(ctx). + Where("resolved_node_id IN ? AND status = ?", nodeIDs, graph.CrossRefStatusResolved). + Find(&refs).Error; err != nil { + return nil, trace.Wrap(err, "cross-namespace refs to nodes") + } + return append(edges, crossRefEdges(refs)...), nil +} + +// GetNodeByID retrieves one node by primary key regardless of namespace. +// @intent resolve traversal frontiers that crossed into another namespace. +func (r *CrossNamespaceReader) GetNodeByID(ctx context.Context, id uint) (*graph.Node, error) { + var node graph.Node + result := r.db.WithContext(ctx).Where("id = ?", id).First(&node) + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, nil + } + if result.Error != nil { + return nil, trace.Wrap(result.Error, "cross-namespace node by id") + } + return &node, nil +} + +// GetNodesByIDs retrieves nodes by primary key regardless of namespace. +// @intent load result nodes for cross-namespace traversals in one query. +func (r *CrossNamespaceReader) GetNodesByIDs(ctx context.Context, ids []uint) ([]graph.Node, error) { + if len(ids) == 0 { + return nil, nil + } + var nodes []graph.Node + if err := r.db.WithContext(ctx).Where("id IN ?", ids).Find(&nodes).Error; err != nil { + return nil, trace.Wrap(err, "cross-namespace nodes by ids") + } + return nodes, nil +} + +// crossRefEdges converts resolved cross refs into synthetic traversal edges. +// @intent reuse existing traversal algorithms unchanged by presenting refs as edges. +func crossRefEdges(refs []graph.CrossRef) []graph.Edge { + if len(refs) == 0 { + return nil + } + edges := make([]graph.Edge, 0, len(refs)) + for _, ref := range refs { + if ref.ResolvedNodeID == nil { + continue + } + edges = append(edges, graph.Edge{ + Namespace: ref.FromNamespace, + FromNodeID: ref.FromNodeID, + ToNodeID: *ref.ResolvedNodeID, + Kind: graph.EdgeKindCrossRef, + Fingerprint: ref.Raw, + }) + } + return edges +} diff --git a/internal/adapters/outbound/graphgorm/crossnamespace_test.go b/internal/adapters/outbound/graphgorm/crossnamespace_test.go new file mode 100644 index 0000000..7d7fa33 --- /dev/null +++ b/internal/adapters/outbound/graphgorm/crossnamespace_test.go @@ -0,0 +1,136 @@ +package graphgorm + +import ( + "context" + "testing" + + requestctx "github.com/tae2089/code-context-graph/internal/ctx" + "github.com/tae2089/code-context-graph/internal/domain/graph" +) + +func seedCrossNamespaceGraph(t *testing.T, s *Store) (webLogin, authValidate, authAudit uint) { + t.Helper() + webLogin = seedOneNode(t, s, "web", graph.Node{ + QualifiedName: "web.Login", Kind: graph.NodeKindFunction, Name: "Login", + FilePath: "internal/web/login.go", StartLine: 5, EndLine: 25, + }) + authValidate = seedOneNode(t, s, "auth-svc", graph.Node{ + QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken", + FilePath: "internal/auth/token.go", StartLine: 10, EndLine: 20, + }) + authAudit = seedOneNode(t, s, "auth-svc", graph.Node{ + QualifiedName: "auth.RecordAudit", Kind: graph.NodeKindFunction, Name: "RecordAudit", + FilePath: "internal/audit/audit.go", StartLine: 3, EndLine: 9, + }) + + authCtx := requestctx.WithNamespace(context.Background(), "auth-svc") + if err := s.UpsertEdges(authCtx, []graph.Edge{{ + FromNodeID: authValidate, ToNodeID: authAudit, Kind: graph.EdgeKindCalls, + FilePath: "internal/auth/token.go", Line: 15, Fingerprint: "calls:internal/auth/token.go:RecordAudit:15", + }}); err != nil { + t.Fatalf("seed auth edge: %v", err) + } + + resolved := authValidate + if err := s.ReplaceCrossRefsFrom(context.Background(), "web", []graph.CrossRef{{ + FromNamespace: "web", FromNodeID: webLogin, Raw: "ccg://auth-svc/internal/auth/token.go#ValidateToken", + ToNamespace: "auth-svc", ToPath: "internal/auth/token.go", ToSymbol: "ValidateToken", + ResolvedNodeID: &resolved, Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation, + }}); err != nil { + t.Fatalf("seed cross ref: %v", err) + } + return webLogin, authValidate, authAudit +} + +func seedOneNode(t *testing.T, s *Store, namespace string, node graph.Node) uint { + t.Helper() + ctx := requestctx.WithNamespace(context.Background(), namespace) + if err := s.UpsertNodes(ctx, []graph.Node{node}); err != nil { + t.Fatalf("seed node: %v", err) + } + stored, err := s.GetNode(ctx, node.QualifiedName) + if err != nil || stored == nil { + t.Fatalf("load node %s: %v", node.QualifiedName, err) + } + return stored.ID +} + +func TestCrossNamespaceReader_MergesCrossRefEdges(t *testing.T) { + s := setupTestDB(t) + webLogin, authValidate, _ := seedCrossNamespaceGraph(t, s) + reader := s.CrossNamespaceReader() + ctx := requestctx.WithNamespace(context.Background(), "web") + + edges, err := reader.GetEdgesFromNodes(ctx, []uint{webLogin}) + if err != nil { + t.Fatalf("GetEdgesFromNodes: %v", err) + } + found := false + for _, e := range edges { + if e.Kind == graph.EdgeKindCrossRef && e.ToNodeID == authValidate { + found = true + } + } + if !found { + t.Fatalf("edges from web.Login = %+v, want synthetic cross_ref edge to node %d", edges, authValidate) + } + + inbound, err := reader.GetEdgesToNodes(ctx, []uint{authValidate}) + if err != nil { + t.Fatalf("GetEdgesToNodes: %v", err) + } + foundInbound := false + for _, e := range inbound { + if e.Kind == graph.EdgeKindCrossRef && e.FromNodeID == webLogin { + foundInbound = true + } + } + if !foundInbound { + t.Fatalf("edges to auth.ValidateToken = %+v, want reverse cross_ref edge from %d", inbound, webLogin) + } +} + +func TestCrossNamespaceReader_ReadsNodesAcrossNamespaces(t *testing.T) { + s := setupTestDB(t) + webLogin, authValidate, _ := seedCrossNamespaceGraph(t, s) + reader := s.CrossNamespaceReader() + // Context carries the web namespace, but reads must cross it. + ctx := requestctx.WithNamespace(context.Background(), "web") + + node, err := reader.GetNodeByID(ctx, authValidate) + if err != nil || node == nil { + t.Fatalf("GetNodeByID across namespaces: node=%v err=%v", node, err) + } + if node.Namespace != "auth-svc" { + t.Fatalf("node namespace = %q, want auth-svc", node.Namespace) + } + + nodes, err := reader.GetNodesByIDs(ctx, []uint{webLogin, authValidate}) + if err != nil { + t.Fatalf("GetNodesByIDs: %v", err) + } + if len(nodes) != 2 { + t.Fatalf("GetNodesByIDs returned %d nodes, want 2 across namespaces", len(nodes)) + } +} + +func TestCrossNamespaceReader_FollowsRegularEdgesInTargetNamespace(t *testing.T) { + s := setupTestDB(t) + _, authValidate, authAudit := seedCrossNamespaceGraph(t, s) + reader := s.CrossNamespaceReader() + ctx := requestctx.WithNamespace(context.Background(), "web") + + edges, err := reader.GetEdgesFromNodes(ctx, []uint{authValidate}) + if err != nil { + t.Fatalf("GetEdgesFromNodes: %v", err) + } + found := false + for _, e := range edges { + if e.Kind == graph.EdgeKindCalls && e.ToNodeID == authAudit { + found = true + } + } + if !found { + t.Fatalf("edges = %+v, want auth-svc internal call edge despite web context", edges) + } +} diff --git a/internal/app/analyze/flow/flow.go b/internal/app/analyze/flow/flow.go index 187e9dc..442fdd8 100644 --- a/internal/app/analyze/flow/flow.go +++ b/internal/app/analyze/flow/flow.go @@ -113,7 +113,9 @@ func (t *Tracer) TraceFlowBounded(ctx context.Context, startNodeID uint, opts Tr nextFrontier := make([]uint, 0) for _, current := range frontier { for _, e := range edgesBySource[current] { - if !graph.IsCallKind(e.Kind) || visited[e.ToNodeID] { + // Cross-ref edges only appear when a cross-namespace reader supplies them; + // regular stores never return this kind. + if (!graph.IsCallKind(e.Kind) && e.Kind != graph.EdgeKindCrossRef) || visited[e.ToNodeID] { continue } if e.Kind == graph.EdgeKindFallbackCalls { diff --git a/internal/domain/graph/edge.go b/internal/domain/graph/edge.go index b5aac5a..c965b44 100644 --- a/internal/domain/graph/edge.go +++ b/internal/domain/graph/edge.go @@ -16,6 +16,11 @@ const ( EdgeKindTestedBy EdgeKind = "tested_by" EdgeKindDependsOn EdgeKind = "depends_on" EdgeKindReferences EdgeKind = "references" + + // EdgeKindCrossRef marks a synthetic traversal edge derived from a resolved cross-namespace + // annotation reference. It is never persisted in the edges table; cross-namespace readers + // materialize it from cross_refs rows at query time. + EdgeKindCrossRef EdgeKind = "cross_ref" ) // CallEdgeKinds returns edge kinds that represent a callable relationship. diff --git a/internal/runtime/mcp/runtime.go b/internal/runtime/mcp/runtime.go index 66ec1e6..9f5bb3a 100644 --- a/internal/runtime/mcp/runtime.go +++ b/internal/runtime/mcp/runtime.go @@ -109,6 +109,9 @@ func New(components Components, opts Options) (*Instance, error) { Analysis: mcp.AnalysisToolsDeps{ Impact: impact.New(components.Store), Flow: flows.New(components.Store), Changes: changes.New(components.Store, gitexec.NewExecGitClient()), Reader: components.Store, + CrossImpact: impact.New(components.Store.CrossNamespaceReader()), + CrossFlow: flows.New(components.Store.CrossNamespaceReader()), + CrossRefs: components.Store, }, Docs: mcp.DocsToolsDeps{Retrieval: retrieval.New(components.SearchReader, components.SearchReader)}, Runtime: mcp.RuntimeToolsDeps{ From 4fe37cbcf2659bb578199e27ab02d8118e424557 Mon Sep 17 00:00:00 2001 From: tae2089 Date: Sun, 19 Jul 2026 10:55:47 +0900 Subject: [PATCH 05/10] docs: document cross-namespace refs, federated reads, and list_cross_refs Update English and Korean guides (mcp-tools, annotations, architecture, counts in README/development/runtime-layout), CLAUDE.md, and the ccg-analyze / ccg-namespace skills for the new cross-namespace surface. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 6 +++--- guide/README.md | 2 +- guide/annotations.md | 2 ++ guide/architecture.md | 12 +++++++++++- guide/development.md | 2 +- guide/ko/README.md | 2 +- guide/ko/architecture.md | 2 +- guide/ko/development.md | 2 +- guide/ko/mcp-tools.md | 17 +++++++++-------- guide/ko/runtime-layout.md | 2 +- guide/mcp-tools.md | 17 +++++++++-------- guide/runtime-layout.md | 2 +- skills/ccg-analyze/SKILL.md | 7 +++++-- skills/ccg-namespace/SKILL.md | 26 ++++++++++++++++++++++---- 14 files changed, 68 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6280818..df3652c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,11 +5,11 @@ Follow the global prompt rules first. This file adds project-specific skill routing for a project that uses the `agent-team` CLI (github.com/tae2089/agent-team) as its durable work ledger. ## MCP 서버 -`.mcp.json`에 등록된 ccg MCP 서버가 17개 도구를 제공합니다: +`.mcp.json`에 등록된 ccg MCP 서버가 18개 도구를 제공합니다: - `parse_project`, `build_or_update_graph`, `run_postprocess` - `get_node`, `search`, `query_graph`, `list_graph_stats`, `list_namespaces`, `get_minimal_context` -- `get_impact_radius`, `trace_flow` +- `get_impact_radius`, `trace_flow`, `list_cross_refs` - `detect_changes`, `get_affected_flows`, `list_flows` - `get_annotation` - `get_doc_content`, `search_docs` @@ -45,7 +45,7 @@ Graceful shutdown: SIGINT/SIGTERM 시 진행 중인 clone/build에 context cance 상세 문서는 `guide/` 디렉토리를 참조하세요: - [CLI Reference](guide/cli-reference.md) — 전체 명령어, 플래그, 설정 파일 -- [MCP Tools](guide/mcp-tools.md) — 17개 MCP 도구, Skills, AI-Driven Annotation +- [MCP Tools](guide/mcp-tools.md) — 18개 MCP 도구, Skills, AI-Driven Annotation - [Annotations](guide/annotations.md) — 어노테이션 태그, 예시, 검색 - [Webhook](guide/webhook.md) — Webhook sync, 브랜치 필터링, HMAC, graceful shutdown - [Docker](guide/docker.md) — Docker 빌드, MCP 서버, PostgreSQL 배포 diff --git a/guide/README.md b/guide/README.md index e21ddf4..aee5e99 100644 --- a/guide/README.md +++ b/guide/README.md @@ -20,7 +20,7 @@ explore graph edges. |----------|-------------| | [CLI Reference](cli-reference.md) | Full CLI commands, options, and configuration file (`.ccg.yaml`) | | [Lint](lint.md) | Detailed `ccg lint` category reference, interpretation guide, and CI usage | -| [MCP Tools](mcp-tools.md) | 17 MCP tools, agent skills, evidence-first routing, AI-driven annotation | +| [MCP Tools](mcp-tools.md) | 18 MCP tools, agent skills, evidence-first routing, AI-driven annotation | | [Annotations](annotations.md) | Custom annotation system — tags, examples, and search quality | | [Webhook](webhook.md) | GitHub / Gitea webhook sync, branch filtering, graceful shutdown | | [Docker](docker.md) | Docker image build, MCP server setup, Wiki UI deployment, PostgreSQL integration | diff --git a/guide/annotations.md b/guide/annotations.md index 2c17abf..de9ecf7 100644 --- a/guide/annotations.md +++ b/guide/annotations.md @@ -62,6 +62,8 @@ Use `ccg://{namespace}/{path}#{symbol}` in `@see` when a behavior depends on cod The path and symbol are optional, so `ccg://auth-svc/internal/auth` can point at a package/path scope and `ccg://auth-svc/` can point at a whole namespace. +Every `@see ccg://` tag is materialized into the `cross_refs` table after each build or update: the target is resolved against the referenced namespace's current nodes and the row is marked `resolved` or `dead`. When either side rebuilds, refs are re-resolved automatically, so a `dead` ref recovers as soon as the target symbol appears. Materialized refs power `list_cross_refs` (repository dependency map) and the `cross_namespace: true` mode of `get_impact_radius`/`trace_flow`. Namespace-scope refs (`ccg://auth-svc/`) resolve without a node target and never appear in traversal, only in listings. + ## Retrieval Quality Annotations are retrieval features. `search_docs` and generated docs rank diff --git a/guide/architecture.md b/guide/architecture.md index 0dcdb27..fc60cc4 100644 --- a/guide/architecture.md +++ b/guide/architecture.md @@ -62,7 +62,7 @@ closure with deterministic `go list -json` checks. ## Runtime and transports Both `ccg serve` (stdio) and `ccg-server` (Streamable HTTP) use the same five -grouped MCP dependency surfaces and expose exactly 17 tools plus four prompts. +grouped MCP dependency surfaces and expose exactly 18 tools plus four prompts. The local binary does not link remote HTTP, Wiki, webhook, or remote runtime packages. See [Runtime Layout](runtime-layout.md). @@ -74,6 +74,16 @@ filters. SQLite and PostgreSQL share GORM-owned graph operations and backend- specific full-text search adapters. Application and inbound packages never issue SQL; backend-specific SQL remains encapsulated in outbound adapters and migration code. +The `cross_refs` table is the one deliberate bridge across namespaces: each row +materializes an `@see ccg://` annotation with a symbolic target +(`to_namespace`, `to_path`, `to_symbol`) plus derived state +(`resolved_node_id`, `status`). Rows are rebuilt from annotations after every +build/update of the source namespace and re-resolved when the target namespace +rebuilds, because replace-style syncs regenerate node ids. Cross-namespace +analysis reads go through a dedicated namespace-agnostic reader that merges +resolved refs into traversal as synthetic `cross_ref` edges; regular +single-namespace query paths remain strictly namespace-filtered. + ## Reliability and security - Repository sync verifies HMAC before trust, applies ordered allow rules, bounds diff --git a/guide/development.md b/guide/development.md index 33d9551..07431a8 100644 --- a/guide/development.md +++ b/guide/development.md @@ -103,7 +103,7 @@ internal/ ctx/ — Request-context values (namespace isolation) docs/ — Documentation generation mcpruntime/ — Shared MCP runtime assembly, stdio runner, cache, telemetry - mcp/ — MCP server (17 tools) + mcp/ — MCP server (18 tools) wikiserver/ — ccg-server Wiki static serving and viewer API wikiindex/ — Wiki presentation index builder (`wiki-index.json`) model/ — DB models diff --git a/guide/ko/README.md b/guide/ko/README.md index 3c722bf..a5fb4fb 100644 --- a/guide/ko/README.md +++ b/guide/ko/README.md @@ -23,7 +23,7 @@ DB-backed graph/annotation evidence를 사용하며, 시각적 Graph 탭은 |------|------| | [CLI 레퍼런스](cli-reference.md) | 모든 CLI 명령어, 옵션 및 설정 파일(`.ccg.yaml`) 안내 | | [Lint](lint.md) | `ccg lint` 카테고리 상세 레퍼런스, 결과 해석 및 CI 활용법 | -| [MCP 도구](mcp-tools.md) | 17개의 MCP 도구, 에이전트 스킬, evidence-first 라우팅, AI 기반 어노테이션 | +| [MCP 도구](mcp-tools.md) | 18개의 MCP 도구, 에이전트 스킬, evidence-first 라우팅, AI 기반 어노테이션 | | [어노테이션](annotations.md) | 커스텀 어노테이션 시스템 — 태그, 예시, 검색 품질 | | [웹훅(Webhook)](webhook.md) | GitHub / Gitea 웹훅 동기화, 브랜치 필터링, Graceful Shutdown | | [Docker](docker.md) | Docker 이미지 빌드, MCP 서버 설정, Wiki UI 배포, PostgreSQL 연동 | diff --git a/guide/ko/architecture.md b/guide/ko/architecture.md index 71b0a4e..908c80c 100644 --- a/guide/ko/architecture.md +++ b/guide/ko/architecture.md @@ -60,7 +60,7 @@ CLI / MCP / HTTP / Wiki / webhook -> application capability -> outbound port ## 런타임과 전송 계층 `ccg serve`(stdio)와 `ccg-server`(Streamable HTTP)는 동일한 다섯 MCP 의존성 -그룹을 사용하며 정확히 17개 tool과 4개 prompt를 노출합니다. 로컬 바이너리는 +그룹을 사용하며 정확히 18개 tool과 4개 prompt를 노출합니다. 로컬 바이너리는 원격 HTTP, Wiki, webhook, remote runtime 패키지를 링크하지 않습니다. 자세한 내용은 [런타임 레이아웃](runtime-layout.md)을 참고하세요. diff --git a/guide/ko/development.md b/guide/ko/development.md index 09a7e0b..8dc983b 100644 --- a/guide/ko/development.md +++ b/guide/ko/development.md @@ -105,7 +105,7 @@ internal/ ctx/ — 요청 컨텍스트 값 (namespace 격리) docs/ — 문서 생성 로직 mcpruntime/ — 공용 MCP runtime assembly, stdio runner, cache, telemetry - mcp/ — MCP 서버 (17개 도구) + mcp/ — MCP 서버 (18개 도구) wikiserver/ — ccg-server Wiki 정적 파일 서빙 및 viewer API wikiindex/ — Wiki 표시용 인덱스 생성기 (`wiki-index.json`) model/ — DB 모델 diff --git a/guide/ko/mcp-tools.md b/guide/ko/mcp-tools.md index 708e301..e10dd27 100644 --- a/guide/ko/mcp-tools.md +++ b/guide/ko/mcp-tools.md @@ -1,6 +1,6 @@ # MCP 도구 -code-context-graph는 로컬 `ccg serve`와 셀프호스트 `ccg-server` 런타임 모두에서 17개의 MCP 도구를 제공합니다. +code-context-graph는 로컬 `ccg serve`와 셀프호스트 `ccg-server` 런타임 모두에서 18개의 MCP 도구를 제공합니다. ## 파싱 및 빌드 @@ -15,27 +15,28 @@ code-context-graph는 로컬 `ccg serve`와 셀프호스트 `ccg-server` 런타 | 도구 | 용도 | | ---- | ---- | | `get_node` | qualified name으로 node 하나 조회 | -| `search` | path scope를 선택적으로 적용하는 code node full-text search | +| `search` | path scope를 선택적으로 적용하는 code node full-text search; `namespaces: []`로 여러 namespace 연합 검색 (결과에 namespace 라벨) | | `get_annotation` | node의 annotation과 문서 tag 조회 | -| `query_graph` | callers, callees, imports, children, tests, inheritors, file summary 조회 | -| `list_graph_stats` | node와 edge를 kind 및 language별로 집계 | +| `query_graph` | callers, callees, imports, children, tests, inheritors, file summary 조회; `namespaces: []`는 namespace별 그룹 응답 | +| `list_graph_stats` | node와 edge를 kind 및 language별로 집계; `namespaces: []`는 namespace별 그룹 응답 | | `list_namespaces` | graph data가 있는 namespace와 node count 목록 조회 | ## 분석 | 도구 | 용도 | | ---- | ---- | -| `get_impact_radius` | node 주변의 제한된 BFS 영향 반경 계산 | -| `trace_flow` | node에서 시작하는 제한된 call chain 추적 | +| `get_impact_radius` | node 주변의 제한된 BFS 영향 반경 계산; `cross_namespace: true`면 resolved `ccg://` ref를 양방향으로 통과 | +| `trace_flow` | node에서 시작하는 제한된 call chain 추적; `cross_namespace: true`면 resolved `ccg://` ref를 넘어 계속 추적 | | `detect_changes` | git diff 기반 변경 함수 탐지 및 risk score 계산 | | `get_affected_flows` | 최근 변경의 영향을 받는 저장 flow 조회 | | `list_flows` | 저장 flow를 페이지네이션해 조회 | +| `list_cross_refs` | 실체화된 `ccg://` cross-namespace 참조 목록 (direction: outbound/inbound/both, status 필터) | ## 문서 및 컨텍스트 | 도구 | 용도 | | ---- | ---- | -| `search_docs` | DB-backed 문서 후보와 graph evidence 검색 | +| `search_docs` | DB-backed 문서 후보와 graph evidence 검색; `namespaces: []`는 namespace별 그룹 응답 | | `get_doc_content` | 선택한 생성 Markdown 파일을 안전하게 읽기 | | `get_minimal_context` | 작은 프로젝트/변경 요약과 다음 도구 제안 반환 | @@ -51,4 +52,4 @@ code-context-graph는 로컬 `ccg serve`와 셀프호스트 `ccg-server` 런타 `query_graph`와 `list_flows`에는 명시적인 `limit`, `offset`을 사용하십시오. 50 또는 100개로 시작하고 페이지네이션 metadata를 따라 확장합니다. -로컬 MCP client는 stdio 방식의 `ccg serve`를 시작하고, 원격 client는 `ccg-server`의 `/mcp` Streamable HTTP endpoint에 연결합니다. 두 런타임은 동일한 17개 도구를 등록합니다. +로컬 MCP client는 stdio 방식의 `ccg serve`를 시작하고, 원격 client는 `ccg-server`의 `/mcp` Streamable HTTP endpoint에 연결합니다. 두 런타임은 동일한 18개 도구를 등록합니다. diff --git a/guide/ko/runtime-layout.md b/guide/ko/runtime-layout.md index 5326346..4fd70a3 100644 --- a/guide/ko/runtime-layout.md +++ b/guide/ko/runtime-layout.md @@ -29,7 +29,7 @@ HTTP host, Wiki HTTP, webhook adapter, remote runtime은 링크하지 않습니 - `/wiki`, `/wiki/api/*` — 선택적인 CCG 내장 Wiki - `/webhook` — 선택적인 GitHub/Gitea repository sync -두 transport 모두 `Runtime.MCPComponents()`를 사용하므로 동일한 17개 tool과 +두 transport 모두 `Runtime.MCPComponents()`를 사용하므로 동일한 18개 tool과 4개 prompt를 등록합니다. ## 리소스 소유권 diff --git a/guide/mcp-tools.md b/guide/mcp-tools.md index 098b23b..73742fa 100644 --- a/guide/mcp-tools.md +++ b/guide/mcp-tools.md @@ -1,6 +1,6 @@ # MCP Tools -code-context-graph exposes 17 MCP tools through both local `ccg serve` and the self-hosted `ccg-server` runtime. +code-context-graph exposes 18 MCP tools through both local `ccg serve` and the self-hosted `ccg-server` runtime. ## Parse and Build @@ -15,27 +15,28 @@ code-context-graph exposes 17 MCP tools through both local `ccg serve` and the s | Tool | Purpose | | ---- | ------- | | `get_node` | Read one node by qualified name | -| `search` | Full-text search across code nodes, optionally scoped by path | +| `search` | Full-text search across code nodes, optionally scoped by path; `namespaces: []` federates across namespaces with per-item labels | | `get_annotation` | Read annotations and documentation tags for one node | -| `query_graph` | Run callers, callees, imports, children, tests, inheritors, or file-summary queries | -| `list_graph_stats` | Report node and edge counts by kind and language | +| `query_graph` | Run callers, callees, imports, children, tests, inheritors, or file-summary queries; `namespaces: []` groups results per namespace | +| `list_graph_stats` | Report node and edge counts by kind and language; `namespaces: []` returns per-namespace groups | | `list_namespaces` | List namespaces that contain graph data and their node counts | ## Analysis | Tool | Purpose | | ---- | ------- | -| `get_impact_radius` | Compute a bounded BFS blast radius around a node | -| `trace_flow` | Trace a bounded call chain from a node | +| `get_impact_radius` | Compute a bounded BFS blast radius around a node; `cross_namespace: true` follows resolved `ccg://` refs both ways | +| `trace_flow` | Trace a bounded call chain from a node; `cross_namespace: true` continues across resolved `ccg://` refs | | `detect_changes` | Detect changed functions from git diff and calculate risk scores | | `get_affected_flows` | List stored flows affected by recent changes | | `list_flows` | List stored flows with bounded pagination | +| `list_cross_refs` | List materialized `ccg://` cross-namespace references (direction: outbound/inbound/both, status filter) | ## Documentation and Context | Tool | Purpose | | ---- | ------- | -| `search_docs` | Search DB-backed documentation candidates and graph evidence | +| `search_docs` | Search DB-backed documentation candidates and graph evidence; `namespaces: []` groups results per namespace | | `get_doc_content` | Safely read a selected generated Markdown file | | `get_minimal_context` | Return a compact project/change summary and suggested next tools | @@ -53,6 +54,6 @@ Use explicit `limit` and `offset` values for `query_graph` and `list_flows`. Sta ## Runtime -Local MCP clients start `ccg serve` over stdio. Remote clients connect to the `/mcp` Streamable HTTP endpoint served by `ccg-server`. Both runtimes register the same 17 tools. +Local MCP clients start `ccg serve` over stdio. Remote clients connect to the `/mcp` Streamable HTTP endpoint served by `ccg-server`. Both runtimes register the same 18 tools. For tool parameters and response schemas, inspect the MCP schema exposed by the running server; source registration lives under `internal/adapters/inbound/mcp/tools_*.go`. diff --git a/guide/runtime-layout.md b/guide/runtime-layout.md index 44d2bd5..f61d063 100644 --- a/guide/runtime-layout.md +++ b/guide/runtime-layout.md @@ -31,7 +31,7 @@ runtime. This keeps local MCP use independent of remote hosting policy. - `/webhook` — optional GitHub/Gitea repository sync Both transports call `Runtime.MCPComponents()` and therefore register the same -17 tools and four prompts. +18 tools and four prompts. ## Resource ownership diff --git a/skills/ccg-analyze/SKILL.md b/skills/ccg-analyze/SKILL.md index ed209d8..c3c10d1 100644 --- a/skills/ccg-analyze/SKILL.md +++ b/skills/ccg-analyze/SKILL.md @@ -26,6 +26,8 @@ Graph-based analysis for **change impact, call flow, and recent-change risk**. | "Who calls this function?" | `query_graph` (callers_of) | | | "What does this function call?" | `query_graph` (callees_of) | | | "Risk of this change" | `detect_changes` + `get_affected_flows` | git diff-based | +| "Which repos depend on this one?" | `list_cross_refs` (direction inbound) | Annotation `ccg://` refs, materialized | +| "Impact across repos?" | `get_impact_radius` with `cross_namespace: true` | Crosses resolved `ccg://` refs both ways | ## Thin `trace_flow` Results @@ -90,11 +92,12 @@ the user specifically needs a bulk export. | Tool | One-liner | | --------------------------- | ---------------------------- | -| `get_impact_radius` | BFS blast radius | -| `trace_flow` | Call chain trace | +| `get_impact_radius` | BFS blast radius; `cross_namespace: true` follows resolved `ccg://` refs | +| `trace_flow` | Call chain trace; `cross_namespace: true` continues into referenced namespaces | | `detect_changes` | Git diff risk score | | `get_affected_flows` | Flows affected by change | | `list_flows` | Stored flow list, paginated | +| `list_cross_refs` | Repository dependency map from materialized `ccg://` refs (`direction`: outbound/inbound/both, `status` filter) | For detailed parameters, see MCP schema. diff --git a/skills/ccg-namespace/SKILL.md b/skills/ccg-namespace/SKILL.md index 2667c8d..fffba12 100644 --- a/skills/ccg-namespace/SKILL.md +++ b/skills/ccg-namespace/SKILL.md @@ -2,7 +2,7 @@ name: ccg-namespace description: "Isolate CCG graph build, search, documentation discovery, and analysis by namespace. Use when working across multiple repositories or services, preventing cross-project graph leakage, listing populated namespaces, or applying one namespace consistently across MCP and CLI operations. Do not use for ordinary single-repository work that fits the default namespace." metadata: - version: 1.1.0 + version: 1.2.0 openclaw: category: "code-intelligence" domain: "namespace" @@ -42,9 +42,10 @@ search_docs(namespace: "payment", query: "payment flow") | ---- | --- | | `list_namespaces` | List namespaces containing graph data and their node counts | | `build_or_update_graph` | Build or incrementally update one namespace from a filesystem path | -| `search` | Search code nodes inside a namespace | -| `search_docs` | Find documentation candidates inside a namespace | +| `search` | Search code nodes inside a namespace; `namespaces: []` federates across several with per-item labels | +| `search_docs` | Find documentation candidates inside a namespace; `namespaces: []` groups results per namespace | | `get_doc_content` | Read a selected generated document, optionally namespace-scoped | +| `list_cross_refs` | List materialized `ccg://` refs for a namespace (`direction`: outbound/inbound/both) | ## Operational Guidance @@ -55,10 +56,27 @@ search_docs(namespace: "payment", query: "payment flow") - Namespace deletion and file upload are not MCP capabilities; manage source directories outside CCG and rebuild graph state as needed. - A graph namespace does not generate or copy Markdown files. Generate and place docs separately before expecting namespace-scoped `get_doc_content` reads to succeed. +## Cross-Namespace Links + +Annotation `@see ccg://{namespace}/{path}#{symbol}` tags are materialized into +queryable cross-namespace references on every build/update: + +- `list_cross_refs(namespace, direction)` returns the repository-level + dependency map (outbound = declared dependencies, inbound = dependents). +- `get_impact_radius(..., cross_namespace: true)` and + `trace_flow(..., cross_namespace: true)` traverse resolved refs across + namespace boundaries; result nodes carry a namespace label. +- Refs re-resolve automatically after either side rebuilds; `status: dead` + marks targets that no longer exist (also reported by `ccg lint` as + `dead-ref`). +- Federated reads (`namespaces: []` on `search`, `query_graph`, + `list_graph_stats`, `search_docs`) fan out per namespace and label every + result; they never merge counts across namespaces. + ## Boundary - Use the default namespace for one local repository unless isolation is required. -- Never combine evidence from different namespaces without labeling each source. +- Never combine evidence from different namespaces without labeling each source; federated and cross-namespace tools label results for you. - Keep filesystem source ownership outside CCG; namespaces isolate graph state, not repository permissions. - Verify the selected namespace has graph rows before interpreting an empty search as no match. From 6976cf8ec098695b751a127d5e3bc378cb6f2daa Mon Sep 17 00:00:00 2001 From: tae2089 Date: Sun, 19 Jul 2026 10:52:20 +0900 Subject: [PATCH 06/10] chore: remove stray test comment in analyze ports.go Remove leftover // test comment after the import block in internal/app/analyze/ports.go that failed gofmt's blank-line separation. Comment-only change; gofmt now passes and internal/app/analyze tests are green. Co-Authored-By: Claude Fable 5 --- internal/app/analyze/ports.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/app/analyze/ports.go b/internal/app/analyze/ports.go index 3f75257..bc3fa21 100644 --- a/internal/app/analyze/ports.go +++ b/internal/app/analyze/ports.go @@ -6,7 +6,7 @@ import ( "github.com/tae2089/code-context-graph/internal/domain/graph" ) -// test + // FlowRebuildStore is the transaction-scoped graph and flow persistence surface used by a rebuild. // @intent let flow application policy trace and replace flows without importing a database adapter. type FlowRebuildStore interface { From 2c4568d6929ccd77442e77d72eba09ff524ffa56 Mon Sep 17 00:00:00 2001 From: tae2089 Date: Sun, 19 Jul 2026 11:00:25 +0900 Subject: [PATCH 07/10] fix: unbreak Postgres integration suite allow-repo config The multi-owner collision guard added in 5404e24 rejects wildcard owners in --allow-repo, so the integration compose file's '*/*' rule made ccg-server refuse to start and the suite fail before any test phase. Pin the rule to the suite's Gitea admin account (testadmin/*) and ignore the suite's default artifacts/ output directory. Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + docker-compose.integration.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1ac273e..285399b 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ pyproject.toml /container-artifacts/ search-retrieval.md _workspace/ +/artifacts/ diff --git a/docker-compose.integration.yml b/docker-compose.integration.yml index 0d69255..c7bbd84 100644 --- a/docker-compose.integration.yml +++ b/docker-compose.integration.yml @@ -67,7 +67,7 @@ services: - --http-bearer-token=test-mcp-token - --db-driver=postgres - --db-dsn=host=postgres user=ccg password=ccg dbname=ccg_integration port=5432 sslmode=disable - - --allow-repo=*/* + - --allow-repo=testadmin/* - --webhook-secret=test-webhook-secret - --repo-clone-base-url=http://gitea:3000 - --repo-root=/repos From 916a4711c22074ff88d1fb7b7d0ed4a67fba26b7 Mon Sep 17 00:00:00 2001 From: tae2089 Date: Sun, 19 Jul 2026 11:08:04 +0900 Subject: [PATCH 08/10] test: verify cross-namespace refs in Postgres integration suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Phase 13: push a ccg:// annotation from sample-go targeting sample-calc through the real Gitea webhook pipeline, then assert list_cross_refs (outbound resolved + inbound), cross-namespace impact radius with namespace labels, default namespace-scoped impact, and federated search labeling — all against PostgreSQL. The phase polls list_cross_refs directly because wait_for_webhook_sync returns as soon as a namespace has any nodes, which is immediate on a re-push. Banner updated to 16/18 verified tools. Co-Authored-By: Claude Fable 5 --- scripts/integration-test.sh | 72 +++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh index 9c23502..d1d35f3 100755 --- a/scripts/integration-test.sh +++ b/scripts/integration-test.sh @@ -680,6 +680,72 @@ else warn "MCP session unavailable under local debug override — skipping Phase 12" fi +# ── Phase 13: Cross-namespace refs (ccg:// annotations) ── +if [ -n "$MCP_SESSION" ]; then + info "Phase 13: Testing cross-namespace refs..." + + # Declare a cross-namespace dependency from sample-go to sample-calc via a ccg:// annotation. + cat > "${TMPDIR_CLONE}/repo/main.go" <<'GOEOF' +package main + +import "fmt" + +// @intent greet a user by name +// @see ccg://sample-calc/math.go#subtract +func greet(name string) string { + return fmt.Sprintf("Hello, %s!", name) +} + +func main() { + fmt.Println(greet("world")) +} +GOEOF + pushd "${TMPDIR_CLONE}/repo" >/dev/null + git add -A + git commit -q -m "docs: declare cross-namespace ref to sample-calc" + info "Pushing cross-ref annotation to Gitea (triggers webhook)..." + git push -q origin main + popd >/dev/null + + # The namespace already has nodes, so wait_for_webhook_sync would return + # immediately; poll until the materialized ref itself becomes visible. + info "Waiting for cross-ref materialization (max ${WEBHOOK_WAIT_SECONDS}s)..." + CROSSREF_DEADLINE=$((SECONDS + WEBHOOK_WAIT_SECONDS)) + CROSSREF_RESP="" + while [ $SECONDS -lt $CROSSREF_DEADLINE ]; do + CROSSREF_RESP=$(mcp_call "list_cross_refs" "{\"namespace\":\"${REPO_NAME}\",\"direction\":\"outbound\"}") + CROSSREF_TEXT="" + [ -n "$CROSSREF_RESP" ] && CROSSREF_TEXT=$(mcp_text "$CROSSREF_RESP") || CROSSREF_TEXT="" + if [[ "$CROSSREF_TEXT" == *'"status":"resolved"'* ]]; then + break + fi + sleep 2 + done + assert_mcp_ok "list_cross_refs(outbound)" "$CROSSREF_RESP" + assert_mcp_contains "list_cross_refs(outbound)" "$CROSSREF_RESP" "\"to_namespace\":\"${REPO2_NAME}\"" + assert_mcp_contains "list_cross_refs(outbound)" "$CROSSREF_RESP" '"status":"resolved"' + + RESP=$(mcp_call "list_cross_refs" "{\"namespace\":\"${REPO2_NAME}\",\"direction\":\"inbound\"}") + assert_mcp_contains "list_cross_refs(inbound)" "$RESP" "\"from_namespace\":\"${REPO_NAME}\"" + + # Cross-namespace impact: greet -> (ccg ref) -> subtract in sample-calc. + RESP=$(mcp_call "get_impact_radius" "{\"qualified_name\":\"main.greet\",\"depth\":1,\"namespace\":\"${REPO_NAME}\",\"cross_namespace\":true}") + assert_mcp_contains "get_impact_radius(cross_namespace)" "$RESP" "main.subtract" + assert_mcp_contains "get_impact_radius(cross_namespace)" "$RESP" "\"namespace\":\"${REPO2_NAME}\"" + + # Default impact stays namespace-scoped. + RESP=$(mcp_call "get_impact_radius" "{\"qualified_name\":\"main.greet\",\"depth\":1,\"namespace\":\"${REPO_NAME}\"}") + assert_mcp_not_contains "get_impact_radius(default)" "$RESP" "main.subtract" + + # Federated search across both namespaces labels each hit. + RESP=$(mcp_call "search" "{\"query\":\"subtract\",\"limit\":5,\"namespaces\":[\"${REPO_NAME}\",\"${REPO2_NAME}\"]}") + assert_mcp_contains "search(namespaces)" "$RESP" "\"namespace\":\"${REPO2_NAME}\"" + + info "Phase 13 complete ✅" +else + warn "MCP session unavailable under local debug override — skipping Phase 13" +fi + # ── Phase 14: Documentation discovery tools ── if [ -n "$MCP_SESSION" ]; then info "Phase 14: Testing documentation discovery tools..." @@ -703,9 +769,9 @@ fi # ── Summary ── TOOLS_TESTED=0 -TOTAL_MCP_TOOLS=17 +TOTAL_MCP_TOOLS=18 if [ -n "$MCP_SESSION" ]; then - TOOLS_TESTED=15 + TOOLS_TESTED=16 fi echo "" @@ -718,7 +784,7 @@ fi echo -e "${GREEN}════════════════════════════════════════════════════════${NC}" echo "" info "Pipeline: Gitea push → webhook → ccg clone → build → DB ✅" -info "MCP tools: graph query, analysis, namespace, documentation discovery, postprocess ✅" +info "MCP tools: graph query, analysis, namespace, cross-namespace refs, documentation discovery, postprocess ✅" } if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then From de1a0814f0a0273c7531f5ec43cfdcf79f002a20 Mon Sep 17 00:00:00 2001 From: tae2089 Date: Sun, 19 Jul 2026 11:40:32 +0900 Subject: [PATCH 09/10] fix: guarantee per-namespace representation in federated search results The caller's limit was applied to the globally reranked merged pool, so one namespace with high-scoring hits could push every other namespace out of the response entirely. Rank globally without truncation, then select with a per-namespace quota (limit/namespaces, minimum one) and fill the remaining slots in global rank order. Co-Authored-By: Claude Fable 5 --- .../inbound/mcp/handler_federated_test.go | 54 +++++++++++++++++++ .../adapters/inbound/mcp/handler_query.go | 43 ++++++++++++++- 2 files changed, 96 insertions(+), 1 deletion(-) diff --git a/internal/adapters/inbound/mcp/handler_federated_test.go b/internal/adapters/inbound/mcp/handler_federated_test.go index f76569d..7bc7a5e 100644 --- a/internal/adapters/inbound/mcp/handler_federated_test.go +++ b/internal/adapters/inbound/mcp/handler_federated_test.go @@ -2,8 +2,10 @@ package mcp import ( "encoding/json" + "fmt" "os" "path/filepath" + "strings" "testing" "github.com/mark3labs/mcp-go/mcp" @@ -79,6 +81,58 @@ func TestSearch_FederatesAcrossNamespaces(t *testing.T) { } } +func TestSearch_FederatedLimitDoesNotStarveNamespaces(t *testing.T) { + deps := setupTestDeps(t) + + alphaDir := t.TempDir() + betaDir := t.TempDir() + var alphaSrc strings.Builder + alphaSrc.WriteString("package alpha\n") + for i := 1; i <= 5; i++ { + fmt.Fprintf(&alphaSrc, "\nfunc Payment%d() string {\n\treturn \"payment\"\n}\n", i) + } + if err := os.WriteFile(filepath.Join(alphaDir, "pay.go"), []byte(alphaSrc.String()), 0o644); err != nil { + t.Fatalf("write alpha: %v", err) + } + if err := os.WriteFile(filepath.Join(betaDir, "refund.go"), []byte("package beta\n\nfunc PaymentZz() string {\n\treturn \"payment\"\n}\n"), 0o644); err != nil { + t.Fatalf("write beta: %v", err) + } + deps.Runtime.RepoRoot = alphaDir + if res := callTool(t, deps, "build_or_update_graph", map[string]any{"path": alphaDir, "namespace": "alpha"}); res.IsError { + t.Fatalf("parse alpha failed: %+v", res) + } + deps.Runtime.RepoRoot = betaDir + if res := callTool(t, deps, "build_or_update_graph", map[string]any{"path": betaDir, "namespace": "beta"}); res.IsError { + t.Fatalf("parse beta failed: %+v", res) + } + + result := callTool(t, deps, "search", map[string]any{ + "query": "Payment", + "limit": 3, + "namespaces": []string{"alpha", "beta"}, + }) + var items []struct { + QualifiedName string `json:"qualified_name"` + Namespace string `json:"namespace"` + } + if err := json.Unmarshal([]byte(resultTextOf(t, result)), &items); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(items) != 3 { + t.Fatalf("result count = %d, want limit 3", len(items)) + } + perNS := map[string]int{} + for _, item := range items { + perNS[item.Namespace]++ + } + if perNS["beta"] == 0 { + t.Fatalf("federated limit starved namespace beta: %v", perNS) + } + if perNS["alpha"] == 0 { + t.Fatalf("federated limit starved namespace alpha: %v", perNS) + } +} + func TestSearch_SingleNamespaceResponseUnchanged(t *testing.T) { deps := setupTestDeps(t) seedFederatedNamespaces(t, deps) diff --git a/internal/adapters/inbound/mcp/handler_query.go b/internal/adapters/inbound/mcp/handler_query.go index 89dc1ed..7f77ec3 100644 --- a/internal/adapters/inbound/mcp/handler_query.go +++ b/internal/adapters/inbound/mcp/handler_query.go @@ -285,7 +285,8 @@ func (h *handlers) searchFederated(ctx context.Context, query string, limit int, merged = filtered } - merged = searchrank.Rerank(query, merged, limit) + merged = searchrank.Rerank(query, merged, 0) + merged = selectWithNamespaceQuota(merged, limit, len(namespaces)) log.Info("federated search completed", "query", query, "namespaces", namespaces, "result_count", len(merged)) items := make([]searchResultItem, len(merged)) @@ -300,6 +301,46 @@ func (h *handlers) searchFederated(ctx context.Context, query string, limit int, })) } +// selectWithNamespaceQuota bounds globally-ranked federated results while guaranteeing +// every namespace with hits at least limit/namespaceCount slots (minimum one). +// @intent keep one high-scoring repository from starving the other namespaces out of a federated result. +// @domainRule remaining slots after the per-namespace quota pass are filled in global rank order. +func selectWithNamespaceQuota(ranked []graph.Node, limit, namespaceCount int) []graph.Node { + if limit <= 0 || len(ranked) <= limit { + return ranked + } + quota := max(limit/max(namespaceCount, 1), 1) + chosen := make([]bool, len(ranked)) + count := 0 + perNamespace := map[string]int{} + for i, n := range ranked { + if count == limit { + break + } + if perNamespace[n.Namespace] < quota { + chosen[i] = true + perNamespace[n.Namespace]++ + count++ + } + } + for i := range ranked { + if count == limit { + break + } + if !chosen[i] { + chosen[i] = true + count++ + } + } + selected := make([]graph.Node, 0, limit) + for i, n := range ranked { + if chosen[i] { + selected = append(selected, n) + } + } + return selected +} + // getAnnotation returns stored annotation metadata for a graph node. // @intent fetch stored annotation tags and summary data so semantic search results can show business context. // @param request qualified_name is the fully qualified node name whose annotations should be loaded. From 8812dd7615b2e322bfffea2a6aa37a8b2d4a4d02 Mon Sep 17 00:00:00 2001 From: tae2089 Date: Sun, 19 Jul 2026 11:40:32 +0900 Subject: [PATCH 10/10] fix: label cross-namespace trace_flow members with their real namespace Flow memberships were stamped with the start namespace, so members reached through a cross_ref edge carried an unusable label and clients could not resolve foreign node ids. The tracer now batch-loads member nodes when the reader supports it and stamps each membership with the node's stored namespace; the MCP response exposes it only in cross-namespace mode, keeping single-namespace payloads unchanged. The persisted-flow rebuild path is unaffected because FlowRebuildStore has no batch node read. Co-Authored-By: Claude Fable 5 --- .../adapters/inbound/mcp/handler_analysis.go | 9 ++++- .../inbound/mcp/handler_crossns_test.go | 17 +++++--- internal/app/analyze/flow/flow.go | 39 +++++++++++++++++++ 3 files changed, 58 insertions(+), 7 deletions(-) diff --git a/internal/adapters/inbound/mcp/handler_analysis.go b/internal/adapters/inbound/mcp/handler_analysis.go index 35896eb..0bdae3a 100644 --- a/internal/adapters/inbound/mcp/handler_analysis.go +++ b/internal/adapters/inbound/mcp/handler_analysis.go @@ -39,9 +39,11 @@ type impactRadiusResponse struct { // traceFlowMember captures one ordered member inside a traced flow. // @intent serialize flow member references without exposing the full node record. +// @domainRule Namespace is set only in cross-namespace mode so single-namespace responses stay unchanged. type traceFlowMember struct { - NodeID uint `json:"node_id"` - Ordinal int `json:"ordinal"` + NodeID uint `json:"node_id"` + Ordinal int `json:"ordinal"` + Namespace string `json:"namespace,omitempty"` } // traceFlowMetadata records bounded trace settings and fallback-edge summary data. @@ -232,6 +234,9 @@ func (h *handlers) traceFlow(ctx context.Context, request mcp.CallToolRequest) ( members := make([]traceFlowMember, len(flow.Members)) for i, m := range flow.Members { members[i] = traceFlowMember{NodeID: m.NodeID, Ordinal: m.Ordinal} + if crossNamespace { + members[i].Namespace = m.Namespace + } } result, err := marshalJSON(traceFlowResponse{ diff --git a/internal/adapters/inbound/mcp/handler_crossns_test.go b/internal/adapters/inbound/mcp/handler_crossns_test.go index 028a280..5e21344 100644 --- a/internal/adapters/inbound/mcp/handler_crossns_test.go +++ b/internal/adapters/inbound/mcp/handler_crossns_test.go @@ -144,18 +144,25 @@ func TestTraceFlow_CrossNamespace(t *testing.T) { }) var payload struct { Members []struct { - NodeID uint `json:"node_id"` + NodeID uint `json:"node_id"` + Namespace string `json:"namespace"` } `json:"members"` } if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil { t.Fatalf("unmarshal: %v", err) } - got := map[uint]bool{} + got := map[uint]string{} for _, m := range payload.Members { - got[m.NodeID] = true + got[m.NodeID] = m.Namespace } - if !got[authValidate] || !got[authAudit] { - t.Fatalf("flow members = %+v, want continuation into auth-svc nodes %d and %d", payload.Members, authValidate, authAudit) + if _, ok := got[authValidate]; !ok { + t.Fatalf("flow members = %+v, want continuation into auth-svc node %d", payload.Members, authValidate) + } + if _, ok := got[authAudit]; !ok { + t.Fatalf("flow members = %+v, want continuation into auth-svc node %d", payload.Members, authAudit) + } + if got[authValidate] != "auth-svc" || got[authAudit] != "auth-svc" { + t.Fatalf("cross members namespaces = %v, want auth-svc labels so clients can resolve foreign node ids", got) } } diff --git a/internal/app/analyze/flow/flow.go b/internal/app/analyze/flow/flow.go index 442fdd8..636b8b4 100644 --- a/internal/app/analyze/flow/flow.go +++ b/internal/app/analyze/flow/flow.go @@ -22,6 +22,41 @@ type Tracer struct { store EdgeReader } +// nodeBatchReader is the optional capability used to resolve members' true namespaces. +// @intent let cross-namespace readers label foreign members without widening the EdgeReader contract. +type nodeBatchReader interface { + GetNodesByIDs(ctx context.Context, ids []uint) ([]graph.Node, error) +} + +// stampMemberNamespaces replaces the context-namespace default with each member node's stored namespace. +// @intent make cross-namespace flow members resolvable by callers; single-namespace traces are unchanged +// because every member already lives in the context namespace. +// @domainRule stores without batch node reads keep the context-namespace stamp (persisted-flow rebuild path). +func (t *Tracer) stampMemberNamespaces(ctx context.Context, members []graph.FlowMembership) error { + reader, ok := t.store.(nodeBatchReader) + if !ok || len(members) == 0 { + return nil + } + ids := make([]uint, len(members)) + for i, m := range members { + ids[i] = m.NodeID + } + nodes, err := reader.GetNodesByIDs(ctx, ids) + if err != nil { + return err + } + namespaceByID := make(map[uint]string, len(nodes)) + for _, n := range nodes { + namespaceByID[n.ID] = n.Namespace + } + for i := range members { + if ns, found := namespaceByID[members[i].NodeID]; found { + members[i].Namespace = ns + } + } + return nil +} + // TraceOptions bounds how far a single flow trace is allowed to expand. // @intent let callers cap traversal cost when tracing large call graphs type TraceOptions struct { @@ -136,6 +171,10 @@ func (t *Tracer) TraceFlowBounded(ctx context.Context, startNodeID uint, opts Tr frontier = nextFrontier } + if err := t.stampMemberNamespaces(ctx, members); err != nil { + return nil, err + } + node, _ := t.store.GetNodeByID(ctx, startNodeID) name := fmt.Sprintf("flow_from_%d", startNodeID) if node != nil {