Skip to content

Commit c09fcd1

Browse files
tae2089claude
andcommitted
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 <noreply@anthropic.com>
1 parent e8e68e2 commit c09fcd1

20 files changed

Lines changed: 957 additions & 6 deletions

File tree

internal/adapters/inbound/cli/build.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/spf13/cobra"
88
"github.com/tae2089/trace"
99

10+
"github.com/tae2089/code-context-graph/internal/app/crossref"
1011
"github.com/tae2089/code-context-graph/internal/app/ingest"
1112
"github.com/tae2089/code-context-graph/internal/app/ingest/workflow"
1213
requestctx "github.com/tae2089/code-context-graph/internal/ctx"
@@ -47,6 +48,9 @@ func newBuildCmd(deps *Deps) *cobra.Command {
4748
Walkers: deps.Walkers,
4849
Logger: deps.Logger,
4950
}
51+
if crossRefStore, ok := deps.Store.(crossref.Store); ok {
52+
svc.CrossRefs = crossref.New(crossRefStore)
53+
}
5054

5155
opts := workflow.BuildOptions{
5256
Dir: dir,

internal/adapters/inbound/cli/update.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"github.com/spf13/cobra"
77
"github.com/tae2089/trace"
88

9+
"github.com/tae2089/code-context-graph/internal/app/crossref"
910
"github.com/tae2089/code-context-graph/internal/app/ingest"
1011
"github.com/tae2089/code-context-graph/internal/app/ingest/workflow"
1112
requestctx "github.com/tae2089/code-context-graph/internal/ctx"
@@ -50,6 +51,9 @@ func newUpdateCmd(deps *Deps) *cobra.Command {
5051
Walkers: deps.Walkers,
5152
Logger: deps.Logger,
5253
}
54+
if crossRefStore, ok := deps.Store.(crossref.Store); ok {
55+
svc.CrossRefs = crossref.New(crossRefStore)
56+
}
5357
stats, err := svc.Update(ctx, workflow.UpdateOptions{
5458
BuildOptions: workflow.BuildOptions{
5559
Dir: dir,

internal/adapters/inbound/mcp/handler_parse.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"github.com/tae2089/trace"
1212

1313
flowspkg "github.com/tae2089/code-context-graph/internal/app/analyze/flow"
14+
"github.com/tae2089/code-context-graph/internal/app/crossref"
1415
"github.com/tae2089/code-context-graph/internal/app/ingest"
1516
"github.com/tae2089/code-context-graph/internal/app/ingest/workflow"
1617
"github.com/tae2089/code-context-graph/internal/obs"
@@ -72,14 +73,18 @@ func (h *handlers) graphService() *workflow.Service {
7273
graphStore = candidate
7374
}
7475
parseCache, _ := h.deps.Build.Store.(ingest.ParseCache)
75-
return &workflow.Service{
76+
svc := &workflow.Service{
7677
Store: graphStore,
7778
UnitOfWork: h.deps.Build.UnitOfWork,
7879
Search: h.deps.Build.Search,
7980
ParseCache: parseCache,
8081
Parsers: walkers,
8182
Logger: h.logger(),
8283
}
84+
if crossRefStore, ok := h.deps.Build.Store.(crossref.Store); ok {
85+
svc.CrossRefs = crossref.New(crossRefStore)
86+
}
87+
return svc
8388
}
8489

8590
// parseProject parses a project directory and stores discovered graph elements.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// @index GORM adapter for materialized cross-namespace reference rows and ccg ref resolution.
2+
package graphgorm
3+
4+
import (
5+
"context"
6+
"errors"
7+
8+
"gorm.io/gorm"
9+
10+
"github.com/tae2089/trace"
11+
12+
crossrefapp "github.com/tae2089/code-context-graph/internal/app/crossref"
13+
"github.com/tae2089/code-context-graph/internal/domain/graph"
14+
"github.com/tae2089/code-context-graph/internal/domain/reference"
15+
)
16+
17+
var _ crossrefapp.Store = (*Store)(nil)
18+
19+
// ListAnnotationCCGRefs returns every @see ccg:// tag value declared by nodes of one namespace.
20+
// @intent collect the source facts for rebuilding a namespace's outbound cross refs.
21+
func (s *Store) ListAnnotationCCGRefs(ctx context.Context, namespace string) ([]crossrefapp.AnnotationRef, error) {
22+
var rows []crossrefapp.AnnotationRef
23+
err := s.db.WithContext(ctx).Model(&graph.DocTag{}).
24+
Select("annotations.node_id AS node_id, doc_tags.value AS value").
25+
Joins("JOIN annotations ON annotations.id = doc_tags.annotation_id").
26+
Joins("JOIN nodes ON nodes.id = annotations.node_id").
27+
Where("doc_tags.kind = ?", graph.TagSee).
28+
Where("doc_tags.value LIKE ?", reference.Scheme+"://%").
29+
Where("nodes.namespace = ?", namespace).
30+
Scan(&rows).Error
31+
if err != nil {
32+
return nil, trace.Wrap(err, "list annotation ccg refs")
33+
}
34+
return rows, nil
35+
}
36+
37+
// ResolveCCGRef resolves a parsed ccg:// reference to a target node id.
38+
// @intent give cross-ref materialization the concrete node identity behind a symbolic reference.
39+
// @domainRule namespace-scope refs resolve with a zero node id when the namespace has any nodes.
40+
// @domainRule path-scope refs prefer the file node of the path; remaining ties resolve to the lowest node id.
41+
func (s *Store) ResolveCCGRef(ctx context.Context, ref reference.Ref) (uint, bool, error) {
42+
if ref.Path == "" && ref.Symbol == "" {
43+
var count int64
44+
if err := s.db.WithContext(ctx).Model(&graph.Node{}).Where("namespace = ?", ref.Namespace).Count(&count).Error; err != nil {
45+
return 0, false, trace.Wrap(err, "resolve namespace-scope ccg ref")
46+
}
47+
return 0, count > 0, nil
48+
}
49+
var node graph.Node
50+
err := s.ccgRefNodeQuery(ctx, ref).
51+
Order("CASE WHEN kind = 'file' THEN 0 ELSE 1 END, id").
52+
First(&node).Error
53+
if errors.Is(err, gorm.ErrRecordNotFound) {
54+
return 0, false, nil
55+
}
56+
if err != nil {
57+
return 0, false, trace.Wrap(err, "resolve ccg ref")
58+
}
59+
return node.ID, true, nil
60+
}
61+
62+
// ReplaceCrossRefsFrom atomically replaces every cross ref originating from one namespace.
63+
// @intent make outbound cross-ref state a pure function of the namespace's current annotations.
64+
// @sideEffect deletes and inserts cross_refs rows in one transaction.
65+
func (s *Store) ReplaceCrossRefsFrom(ctx context.Context, fromNamespace string, refs []graph.CrossRef) error {
66+
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
67+
if err := tx.Where("from_namespace = ?", fromNamespace).Delete(&graph.CrossRef{}).Error; err != nil {
68+
return err
69+
}
70+
if len(refs) == 0 {
71+
return nil
72+
}
73+
return tx.CreateInBatches(refs, 100).Error
74+
})
75+
if err != nil {
76+
return trace.Wrap(err, "replace cross refs")
77+
}
78+
return nil
79+
}
80+
81+
// ListInboundCrossRefs returns refs from other namespaces that target the given namespace.
82+
// @intent select the rows whose resolution may change after this namespace rebuilds.
83+
// @domainRule self-namespace refs are excluded because the outbound rebuild already re-resolved them.
84+
func (s *Store) ListInboundCrossRefs(ctx context.Context, toNamespace string) ([]graph.CrossRef, error) {
85+
var rows []graph.CrossRef
86+
err := s.db.WithContext(ctx).
87+
Where("to_namespace = ? AND from_namespace <> ?", toNamespace, toNamespace).
88+
Order("id").
89+
Find(&rows).Error
90+
if err != nil {
91+
return nil, trace.Wrap(err, "list inbound cross refs")
92+
}
93+
return rows, nil
94+
}
95+
96+
// ListOutboundCrossRefs returns every cross ref originating from one namespace.
97+
// @intent expose a namespace's declared external dependencies for listing and analysis.
98+
func (s *Store) ListOutboundCrossRefs(ctx context.Context, fromNamespace string) ([]graph.CrossRef, error) {
99+
var rows []graph.CrossRef
100+
err := s.db.WithContext(ctx).
101+
Where("from_namespace = ?", fromNamespace).
102+
Order("id").
103+
Find(&rows).Error
104+
if err != nil {
105+
return nil, trace.Wrap(err, "list outbound cross refs")
106+
}
107+
return rows, nil
108+
}
109+
110+
// UpdateCrossRefResolution updates one row's derived resolution state.
111+
// @intent remap or invalidate a reference after its target namespace rebuilt.
112+
// @sideEffect updates resolved_node_id and status of one cross_refs row.
113+
func (s *Store) UpdateCrossRefResolution(ctx context.Context, id uint, resolvedNodeID *uint, status graph.CrossRefStatus) error {
114+
err := s.db.WithContext(ctx).Model(&graph.CrossRef{}).Where("id = ?", id).
115+
Updates(map[string]any{"resolved_node_id": resolvedNodeID, "status": status}).Error
116+
if err != nil {
117+
return trace.Wrap(err, "update cross ref resolution")
118+
}
119+
return nil
120+
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package graphgorm
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
requestctx "github.com/tae2089/code-context-graph/internal/ctx"
8+
"github.com/tae2089/code-context-graph/internal/domain/graph"
9+
"github.com/tae2089/code-context-graph/internal/domain/reference"
10+
)
11+
12+
func seedCrossRefNodes(t *testing.T, s *Store, namespace string, nodes []graph.Node) map[string]uint {
13+
t.Helper()
14+
ctx := requestctx.WithNamespace(context.Background(), namespace)
15+
if err := s.UpsertNodes(ctx, nodes); err != nil {
16+
t.Fatalf("seed nodes for %s: %v", namespace, err)
17+
}
18+
stored, err := s.GetNodesByFiles(ctx, uniqueFilePaths(nodes))
19+
if err != nil {
20+
t.Fatalf("load seeded nodes: %v", err)
21+
}
22+
ids := map[string]uint{}
23+
for _, byFile := range stored {
24+
for _, n := range byFile {
25+
ids[n.QualifiedName] = n.ID
26+
}
27+
}
28+
return ids
29+
}
30+
31+
func uniqueFilePaths(nodes []graph.Node) []string {
32+
seen := map[string]bool{}
33+
paths := []string{}
34+
for _, n := range nodes {
35+
if !seen[n.FilePath] {
36+
seen[n.FilePath] = true
37+
paths = append(paths, n.FilePath)
38+
}
39+
}
40+
return paths
41+
}
42+
43+
func TestResolveCCGRef_Scopes(t *testing.T) {
44+
s := setupTestDB(t)
45+
ids := seedCrossRefNodes(t, s, "auth-svc", []graph.Node{
46+
{QualifiedName: "internal/auth/token.go", Kind: graph.NodeKindFile, Name: "token.go", FilePath: "internal/auth/token.go", StartLine: 1, EndLine: 1},
47+
{QualifiedName: "auth.ValidateToken", Kind: graph.NodeKindFunction, Name: "ValidateToken", FilePath: "internal/auth/token.go", StartLine: 10, EndLine: 20},
48+
{QualifiedName: "auth.RenewToken", Kind: graph.NodeKindFunction, Name: "RenewToken", FilePath: "internal/auth/token.go", StartLine: 30, EndLine: 40},
49+
})
50+
ctx := context.Background()
51+
52+
cases := []struct {
53+
name string
54+
ref reference.Ref
55+
wantID uint
56+
wantOK bool
57+
}{
58+
{"path and symbol", reference.Ref{Namespace: "auth-svc", Path: "internal/auth/token.go", Symbol: "ValidateToken"}, ids["auth.ValidateToken"], true},
59+
{"path only prefers file node", reference.Ref{Namespace: "auth-svc", Path: "internal/auth/token.go"}, ids["internal/auth/token.go"], true},
60+
{"symbol only", reference.Ref{Namespace: "auth-svc", Symbol: "RenewToken"}, ids["auth.RenewToken"], true},
61+
{"namespace scope resolves without node", reference.Ref{Namespace: "auth-svc"}, 0, true},
62+
{"missing symbol", reference.Ref{Namespace: "auth-svc", Symbol: "Nope"}, 0, false},
63+
{"missing namespace", reference.Ref{Namespace: "ghost"}, 0, false},
64+
}
65+
for _, tc := range cases {
66+
t.Run(tc.name, func(t *testing.T) {
67+
id, ok, err := s.ResolveCCGRef(ctx, tc.ref)
68+
if err != nil {
69+
t.Fatalf("ResolveCCGRef: %v", err)
70+
}
71+
if ok != tc.wantOK || id != tc.wantID {
72+
t.Fatalf("ResolveCCGRef = (%d, %v), want (%d, %v)", id, ok, tc.wantID, tc.wantOK)
73+
}
74+
})
75+
}
76+
}
77+
78+
func TestReplaceCrossRefsFrom_ReplacesAndLists(t *testing.T) {
79+
s := setupTestDB(t)
80+
ctx := context.Background()
81+
first := []graph.CrossRef{
82+
{FromNamespace: "web", FromNodeID: 1, Raw: "ccg://auth-svc/#Old", ToNamespace: "auth-svc", ToSymbol: "Old", Status: graph.CrossRefStatusDead, Source: graph.CrossRefSourceAnnotation},
83+
}
84+
if err := s.ReplaceCrossRefsFrom(ctx, "web", first); err != nil {
85+
t.Fatalf("first replace: %v", err)
86+
}
87+
second := []graph.CrossRef{
88+
{FromNamespace: "web", FromNodeID: 2, Raw: "ccg://auth-svc/#New", ToNamespace: "auth-svc", ToSymbol: "New", Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation},
89+
{FromNamespace: "web", FromNodeID: 3, Raw: "ccg://billing/#Charge", ToNamespace: "billing", ToSymbol: "Charge", Status: graph.CrossRefStatusDead, Source: graph.CrossRefSourceAnnotation},
90+
}
91+
if err := s.ReplaceCrossRefsFrom(ctx, "web", second); err != nil {
92+
t.Fatalf("second replace: %v", err)
93+
}
94+
95+
inbound, err := s.ListInboundCrossRefs(ctx, "auth-svc")
96+
if err != nil {
97+
t.Fatalf("ListInboundCrossRefs: %v", err)
98+
}
99+
if len(inbound) != 1 || inbound[0].Raw != "ccg://auth-svc/#New" {
100+
t.Fatalf("inbound after replace = %+v, want only the new auth-svc ref", inbound)
101+
}
102+
103+
outbound, err := s.ListOutboundCrossRefs(ctx, "web")
104+
if err != nil {
105+
t.Fatalf("ListOutboundCrossRefs: %v", err)
106+
}
107+
if len(outbound) != 2 {
108+
t.Fatalf("outbound rows = %d, want 2", len(outbound))
109+
}
110+
}
111+
112+
func TestListInboundCrossRefs_ExcludesSelfNamespace(t *testing.T) {
113+
s := setupTestDB(t)
114+
ctx := context.Background()
115+
rows := []graph.CrossRef{
116+
{FromNamespace: "auth-svc", FromNodeID: 1, Raw: "ccg://auth-svc/internal#Self", ToNamespace: "auth-svc", ToSymbol: "Self", Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation},
117+
}
118+
if err := s.ReplaceCrossRefsFrom(ctx, "auth-svc", rows); err != nil {
119+
t.Fatalf("replace: %v", err)
120+
}
121+
inbound, err := s.ListInboundCrossRefs(ctx, "auth-svc")
122+
if err != nil {
123+
t.Fatalf("ListInboundCrossRefs: %v", err)
124+
}
125+
if len(inbound) != 0 {
126+
t.Fatalf("inbound = %d rows, want 0 (self refs are rebuilt outbound)", len(inbound))
127+
}
128+
}
129+
130+
func TestUpdateCrossRefResolution_RemapsAndKills(t *testing.T) {
131+
s := setupTestDB(t)
132+
ctx := context.Background()
133+
resolved := uint(42)
134+
rows := []graph.CrossRef{
135+
{FromNamespace: "web", FromNodeID: 1, Raw: "ccg://auth-svc/#Fn", ToNamespace: "auth-svc", ToSymbol: "Fn", ResolvedNodeID: &resolved, Status: graph.CrossRefStatusResolved, Source: graph.CrossRefSourceAnnotation},
136+
}
137+
if err := s.ReplaceCrossRefsFrom(ctx, "web", rows); err != nil {
138+
t.Fatalf("replace: %v", err)
139+
}
140+
stored, err := s.ListInboundCrossRefs(ctx, "auth-svc")
141+
if err != nil || len(stored) != 1 {
142+
t.Fatalf("load stored row: %v (%d rows)", err, len(stored))
143+
}
144+
145+
if err := s.UpdateCrossRefResolution(ctx, stored[0].ID, nil, graph.CrossRefStatusDead); err != nil {
146+
t.Fatalf("kill resolution: %v", err)
147+
}
148+
after, err := s.ListInboundCrossRefs(ctx, "auth-svc")
149+
if err != nil {
150+
t.Fatalf("reload: %v", err)
151+
}
152+
if after[0].Status != graph.CrossRefStatusDead || after[0].ResolvedNodeID != nil {
153+
t.Fatalf("after kill = %+v, want dead with nil node", after[0])
154+
}
155+
156+
remapped := uint(99)
157+
if err := s.UpdateCrossRefResolution(ctx, stored[0].ID, &remapped, graph.CrossRefStatusResolved); err != nil {
158+
t.Fatalf("remap resolution: %v", err)
159+
}
160+
final, err := s.ListInboundCrossRefs(ctx, "auth-svc")
161+
if err != nil {
162+
t.Fatalf("reload final: %v", err)
163+
}
164+
if final[0].Status != graph.CrossRefStatusResolved || final[0].ResolvedNodeID == nil || *final[0].ResolvedNodeID != 99 {
165+
t.Fatalf("after remap = %+v, want resolved node 99", final[0])
166+
}
167+
}

internal/adapters/outbound/graphgorm/store.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ func (s *Store) AutoMigrate() error {
4949
&graph.ParseCacheEntry{},
5050
&graph.UnresolvedEdgeCandidate{},
5151
&graph.UnresolvedIndexState{},
52+
&graph.CrossRef{},
5253
); err != nil {
5354
return err
5455
}

0 commit comments

Comments
 (0)