Skip to content

Commit 0608ba5

Browse files
committed
Link wiki CCG refs to docs and graph
1 parent e571b79 commit 0608ba5

6 files changed

Lines changed: 443 additions & 23 deletions

File tree

internal/wikiserver/server.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616

1717
"gorm.io/gorm"
1818

19+
"github.com/tae2089/code-context-graph/internal/ccgref"
1920
"github.com/tae2089/code-context-graph/internal/ctxns"
2021
"github.com/tae2089/code-context-graph/internal/model"
2122
nsfs "github.com/tae2089/code-context-graph/internal/namespacefs"
@@ -112,6 +113,7 @@ func (s *Server) APIHandler() http.Handler {
112113
mux.HandleFunc("/wiki/api/namespaces", s.handleNamespaces)
113114
mux.HandleFunc("/wiki/api/tree", s.handleTree)
114115
mux.HandleFunc("/wiki/api/doc", s.handleDoc)
116+
mux.HandleFunc("/wiki/api/ref", s.handleRef)
115117
mux.HandleFunc("/wiki/api/search", s.handleSearch)
116118
mux.HandleFunc("/wiki/api/retrieve", s.handleRetrieve)
117119
mux.HandleFunc("/wiki/api/graph", s.handleGraph)
@@ -393,6 +395,50 @@ func (s *Server) handleDoc(w http.ResponseWriter, r *http.Request) {
393395
})
394396
}
395397

398+
// @intent resolve a ccg:// annotation reference to a Wiki target and optional graph node.
399+
func (s *Server) handleRef(w http.ResponseWriter, r *http.Request) {
400+
if !requireMethod(w, r, http.MethodGet) {
401+
return
402+
}
403+
rawRef := strings.TrimSpace(r.URL.Query().Get("ref"))
404+
if rawRef == "" {
405+
writeError(w, http.StatusBadRequest, "ref must not be empty", nil)
406+
return
407+
}
408+
ref, err := ccgref.Parse(rawRef)
409+
if err != nil {
410+
writeError(w, http.StatusBadRequest, "parse ref", err)
411+
return
412+
}
413+
if err := validateNamespace(ref.Namespace); err != nil {
414+
writeError(w, http.StatusBadRequest, err.Error(), nil)
415+
return
416+
}
417+
idx, indexErr := s.loadIndex(ref.Namespace)
418+
var treeNode *ragindex.TreeNode
419+
if indexErr == nil {
420+
treeNode = findRefTreeNode(idx.Root, ref)
421+
}
422+
graphNode, graphErr := s.findRefGraphNode(r, ref)
423+
if treeNode == nil && graphNode == nil {
424+
if indexErr != nil {
425+
writeError(w, statusForReadErr(indexErr), "load wiki-index", indexErr)
426+
return
427+
}
428+
if graphErr != nil && !errors.Is(graphErr, gorm.ErrRecordNotFound) {
429+
writeError(w, http.StatusInternalServerError, "resolve graph ref", graphErr)
430+
return
431+
}
432+
writeError(w, http.StatusNotFound, "ref target not found", nil)
433+
return
434+
}
435+
writeJSON(w, http.StatusOK, refResponse{
436+
Namespace: ref.Namespace,
437+
Ref: ref,
438+
Target: refTargetFromMatches(treeNode, graphNode),
439+
})
440+
}
441+
396442
// @intent assemble selected docs or summaries into one Markdown block for LLM context.
397443
func (s *Server) handleContext(w http.ResponseWriter, r *http.Request) {
398444
if !requireMethod(w, r, http.MethodPost) {
@@ -470,6 +516,23 @@ type retrieveResult struct {
470516
ContentTruncated bool `json:"content_truncated,omitempty"`
471517
}
472518

519+
// @intent return the resolved Wiki navigation target for one ccg:// ref.
520+
type refResponse struct {
521+
Namespace string `json:"namespace"`
522+
Ref *ccgref.Ref `json:"ref"`
523+
Target refTarget `json:"target"`
524+
}
525+
526+
// @intent describe the doc and graph destinations available for a resolved ccg:// ref.
527+
type refTarget struct {
528+
Label string `json:"label"`
529+
Kind string `json:"kind"`
530+
Summary string `json:"summary,omitempty"`
531+
DocPath string `json:"doc_path,omitempty"`
532+
GraphNodeID string `json:"graph_node_id,omitempty"`
533+
Details *ragindex.NodeDetails `json:"details,omitempty"`
534+
}
535+
473536
// @intent describe one graph node in the Wiki force graph API.
474537
type graphNode struct {
475538
ID string `json:"id"`
@@ -677,6 +740,151 @@ func findDocPath(root *ragindex.TreeNode, docPath string) *ragindex.TreeNode {
677740
return nil
678741
}
679742

743+
// @intent locate the Wiki tree node that best matches a parsed ccg:// ref.
744+
func findRefTreeNode(root *ragindex.TreeNode, ref *ccgref.Ref) *ragindex.TreeNode {
745+
if root == nil || ref == nil {
746+
return nil
747+
}
748+
if ref.Path == "" && ref.Symbol == "" {
749+
return root
750+
}
751+
if refPathMatchesTree(root, ref) {
752+
return root
753+
}
754+
for _, child := range root.Children {
755+
if found := findRefTreeNode(child, ref); found != nil {
756+
return found
757+
}
758+
}
759+
return nil
760+
}
761+
762+
// @intent compare a ccg:// path/symbol target against one Wiki tree node.
763+
func refPathMatchesTree(node *ragindex.TreeNode, ref *ccgref.Ref) bool {
764+
if ref.Symbol != "" {
765+
if node.Details == nil {
766+
return false
767+
}
768+
if ref.Path != "" && !sameRefPath(node.Details.FilePath, ref.Path) {
769+
return false
770+
}
771+
return symbolMatches(node.Label, node.Details.QualifiedName, ref.Symbol)
772+
}
773+
if ref.Path == "" {
774+
return false
775+
}
776+
if node.Details != nil && sameRefPath(node.Details.FilePath, ref.Path) {
777+
return true
778+
}
779+
if node.DocPath != "" && filepath.ToSlash(node.DocPath) == filepath.ToSlash(docPathForSource(ref.Path)) {
780+
return true
781+
}
782+
idPath := strings.TrimPrefix(strings.TrimPrefix(node.ID, "file:"), "package:")
783+
idPath = strings.TrimPrefix(idPath, "folder:")
784+
return sameRefPath(idPath, ref.Path)
785+
}
786+
787+
// @intent resolve a ccg:// ref against graph nodes so the browser graph can focus the destination.
788+
func (s *Server) findRefGraphNode(r *http.Request, ref *ccgref.Ref) (*graphNode, error) {
789+
if s.db == nil || ref == nil {
790+
return nil, gorm.ErrRecordNotFound
791+
}
792+
if ref.Path == "" && ref.Symbol == "" {
793+
return nil, gorm.ErrRecordNotFound
794+
}
795+
var nodes []model.Node
796+
query := s.db.WithContext(r.Context()).
797+
Where("namespace = ?", ref.Namespace).
798+
Preload("Annotation.Tags").
799+
Order("kind ASC, file_path ASC, start_line ASC, qualified_name ASC")
800+
if ref.Path != "" && ref.Symbol != "" {
801+
query = query.Where("file_path = ?", ref.Path)
802+
}
803+
if err := query.Find(&nodes).Error; err != nil {
804+
return nil, err
805+
}
806+
for _, node := range nodes {
807+
if ref.Symbol != "" {
808+
if !symbolMatches(node.Name, node.QualifiedName, ref.Symbol) {
809+
continue
810+
}
811+
} else if ref.Path != "" {
812+
if !sameRefPath(node.FilePath, ref.Path) && !strings.HasPrefix(filepath.ToSlash(node.FilePath), strings.TrimSuffix(filepath.ToSlash(ref.Path), "/")+"/") {
813+
continue
814+
}
815+
}
816+
graphNode := graphNodeFromModel(node)
817+
if node.Annotation != nil && graphNode.Details != nil {
818+
graphNode.Details.Annotation = annotationDetailFromModel(node.Annotation)
819+
}
820+
return &graphNode, nil
821+
}
822+
return nil, gorm.ErrRecordNotFound
823+
}
824+
825+
// @intent merge tree and graph matches into one browser navigation payload.
826+
func refTargetFromMatches(treeNode *ragindex.TreeNode, graphNode *graphNode) refTarget {
827+
target := refTarget{}
828+
if treeNode != nil {
829+
target.Label = treeNode.Label
830+
target.Kind = treeNode.Kind
831+
target.Summary = treeNode.Summary
832+
target.DocPath = treeNode.DocPath
833+
target.Details = treeNode.Details
834+
}
835+
if graphNode != nil {
836+
if target.Label == "" {
837+
target.Label = graphNode.Label
838+
}
839+
if target.Kind == "" {
840+
target.Kind = graphNode.Kind
841+
}
842+
if target.Summary == "" {
843+
target.Summary = strings.TrimSpace(graphNode.FilePath)
844+
}
845+
if target.DocPath == "" {
846+
target.DocPath = graphNode.DocPath
847+
}
848+
if target.Details == nil {
849+
target.Details = graphNode.Details
850+
}
851+
target.GraphNodeID = graphNode.ID
852+
}
853+
return target
854+
}
855+
856+
// @intent convert a stored annotation into the same details shape used by wiki-index.json.
857+
func annotationDetailFromModel(annotation *model.Annotation) *ragindex.AnnotationDetail {
858+
if annotation == nil {
859+
return nil
860+
}
861+
tags := make([]ragindex.DocTagDetail, 0, len(annotation.Tags))
862+
for _, tag := range annotation.Tags {
863+
tags = append(tags, ragindex.DocTagDetailFromModel(tag))
864+
}
865+
return &ragindex.AnnotationDetail{
866+
Summary: strings.TrimSpace(annotation.Summary),
867+
Context: strings.TrimSpace(annotation.Context),
868+
Tags: tags,
869+
}
870+
}
871+
872+
// @intent match ccg:// file paths against graph and Wiki slash-separated paths.
873+
func sameRefPath(left, right string) bool {
874+
return strings.Trim(filepath.ToSlash(left), "/") == strings.Trim(filepath.ToSlash(right), "/")
875+
}
876+
877+
// @intent allow short symbol refs to match names and language-qualified names.
878+
func symbolMatches(name, qualifiedName, want string) bool {
879+
if want == "" {
880+
return true
881+
}
882+
return name == want ||
883+
qualifiedName == want ||
884+
strings.HasSuffix(qualifiedName, "."+want) ||
885+
strings.HasSuffix(qualifiedName, "::"+want)
886+
}
887+
680888
// @intent render a Wiki tree node as fallback Markdown when no generated file exists.
681889
func nodeMarkdown(node *ragindex.TreeNode) string {
682890
parts := []string{fmt.Sprintf("# %s", node.Label)}

internal/wikiserver/server_test.go

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"net/http"
66
"net/http/httptest"
7+
"net/url"
78
"os"
89
"path/filepath"
910
"strings"
@@ -55,6 +56,48 @@ func newTestServer(t *testing.T) *Server {
5556
if err := os.WriteFile(filepath.Join(ragDir, "doc-index.json"), data, 0o644); err != nil {
5657
t.Fatalf("write rag index json: %v", err)
5758
}
59+
authIdx := &ragindex.Index{
60+
Version: 1,
61+
BuiltAt: time.Date(2026, 5, 5, 0, 0, 0, 0, time.UTC),
62+
Root: &ragindex.TreeNode{
63+
ID: "root", Label: "Root",
64+
Children: []*ragindex.TreeNode{
65+
{
66+
ID: "folder:internal", Label: "internal", Kind: "folder",
67+
Children: []*ragindex.TreeNode{
68+
{
69+
ID: "folder:internal/auth", Label: "auth", Kind: "folder",
70+
Children: []*ragindex.TreeNode{
71+
{
72+
ID: "file:internal/auth/token.go", Label: "token.go", Kind: "file", DocPath: "docs/internal/auth/token.go.md", Summary: "Token file",
73+
Children: []*ragindex.TreeNode{
74+
{
75+
ID: "symbol:auth.ValidateToken", Label: "ValidateToken", Kind: "function", Summary: "validates tokens",
76+
Details: &ragindex.NodeDetails{
77+
QualifiedName: "auth.ValidateToken",
78+
FilePath: "internal/auth/token.go",
79+
StartLine: 10,
80+
EndLine: 24,
81+
Language: "go",
82+
},
83+
Children: []*ragindex.TreeNode{},
84+
},
85+
},
86+
},
87+
},
88+
},
89+
},
90+
},
91+
},
92+
},
93+
}
94+
authData, _ := json.Marshal(authIdx)
95+
if err := os.MkdirAll(filepath.Join(ragDir, "auth-svc"), 0o755); err != nil {
96+
t.Fatalf("create auth namespace index dir: %v", err)
97+
}
98+
if err := os.WriteFile(filepath.Join(ragDir, "auth-svc", "wiki-index.json"), authData, 0o644); err != nil {
99+
t.Fatalf("write auth wiki index json: %v", err)
100+
}
58101
if err := os.MkdirAll(filepath.Join(root, "docs"), 0o755); err != nil {
59102
t.Fatalf("create docs dir: %v", err)
60103
}
@@ -66,7 +109,7 @@ func newTestServer(t *testing.T) *Server {
66109
if err != nil {
67110
t.Fatalf("open db: %v", err)
68111
}
69-
if err := db.AutoMigrate(&model.Node{}, &model.Edge{}); err != nil {
112+
if err := db.AutoMigrate(&model.Node{}, &model.Edge{}, &model.Annotation{}, &model.DocTag{}); err != nil {
70113
t.Fatalf("migrate nodes: %v", err)
71114
}
72115
fileNode := model.Node{Namespace: ctxns.DefaultNamespace, QualifiedName: "main.go", Kind: model.NodeKindFile, Name: "main.go", FilePath: "main.go", StartLine: 1, EndLine: 20, Language: "go"}
@@ -83,6 +126,10 @@ func newTestServer(t *testing.T) *Server {
83126
if err := db.Create(&model.Node{Namespace: "sample-go", QualifiedName: "main.Run", Kind: model.NodeKindFunction, Name: "Run", FilePath: "main.go"}).Error; err != nil {
84127
t.Fatalf("create node: %v", err)
85128
}
129+
authNode := model.Node{Namespace: "auth-svc", QualifiedName: "auth.ValidateToken", Kind: model.NodeKindFunction, Name: "ValidateToken", FilePath: "internal/auth/token.go", StartLine: 10, EndLine: 24, Language: "go"}
130+
if err := db.Create(&authNode).Error; err != nil {
131+
t.Fatalf("create auth node: %v", err)
132+
}
86133

87134
cwd, err := os.Getwd()
88135
if err != nil {
@@ -235,6 +282,47 @@ func TestAPI_GraphReturnsNodesAndEdges(t *testing.T) {
235282
}
236283
}
237284

285+
func TestAPI_RefResolvesWikiAndGraphTarget(t *testing.T) {
286+
srv := newTestServer(t)
287+
rawRef := "ccg://auth-svc/internal/auth/token.go#ValidateToken"
288+
req := httptest.NewRequest(http.MethodGet, "/wiki/api/ref?ref="+url.QueryEscape(rawRef), nil)
289+
rec := httptest.NewRecorder()
290+
291+
srv.APIHandler().ServeHTTP(rec, req)
292+
293+
if rec.Code != http.StatusOK {
294+
t.Fatalf("ref status = %d body=%s", rec.Code, rec.Body.String())
295+
}
296+
var got refResponse
297+
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
298+
t.Fatalf("decode ref: %v", err)
299+
}
300+
if got.Namespace != "auth-svc" {
301+
t.Fatalf("namespace = %q", got.Namespace)
302+
}
303+
if got.Target.Label != "ValidateToken" || got.Target.Kind != "function" {
304+
t.Fatalf("target = %#v", got.Target)
305+
}
306+
if got.Target.GraphNodeID == "" {
307+
t.Fatalf("expected graph node id in target: %#v", got.Target)
308+
}
309+
if got.Target.Details == nil || got.Target.Details.FilePath != "internal/auth/token.go" {
310+
t.Fatalf("details = %#v", got.Target.Details)
311+
}
312+
}
313+
314+
func TestAPI_RefReturnsNotFoundForMissingTarget(t *testing.T) {
315+
srv := newTestServer(t)
316+
req := httptest.NewRequest(http.MethodGet, "/wiki/api/ref?ref="+url.QueryEscape("ccg://auth-svc/internal/auth/token.go#Missing"), nil)
317+
rec := httptest.NewRecorder()
318+
319+
srv.APIHandler().ServeHTTP(rec, req)
320+
321+
if rec.Code != http.StatusNotFound {
322+
t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String())
323+
}
324+
}
325+
238326
func TestAPI_DocAllowsAbsolutePathUnderRoot(t *testing.T) {
239327
srv := newTestServer(t)
240328
absPath, err := filepath.Abs(filepath.Join("docs", "core.md"))

0 commit comments

Comments
 (0)