Skip to content

Commit 49b37bd

Browse files
feat(wikiindex): expose DB wiki tree builder
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
1 parent f090e0e commit 49b37bd

2 files changed

Lines changed: 116 additions & 6 deletions

File tree

internal/wikiindex/builder.go

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,23 @@ type Builder struct {
3737
// @intent generate a UI-oriented tree independent of community detection and PageIndex retrieval.
3838
// @sideEffect reads graph tables and writes wiki-index.json.
3939
func (b *Builder) Build(ctx context.Context) (int, int, error) {
40+
ns := b.namespace(ctx)
41+
ctx = ctxns.WithNamespace(ctx, ns)
42+
root, packages, files, err := b.BuildTree(ctx)
43+
if err != nil {
44+
return 0, 0, err
45+
}
46+
idx := &ragindex.Index{Version: 1, BuiltAt: time.Now().UTC(), Root: root}
47+
if err := b.writeIndex(ns, idx); err != nil {
48+
return 0, 0, err
49+
}
50+
return packages, files, nil
51+
}
52+
53+
// BuildTree creates the browser-facing package/file/symbol Wiki tree without writing wiki-index.json.
54+
// @intent let runtime callers synthesize the same Wiki tree directly from DB rows when the JSON index has not been generated.
55+
// @ensures successful calls return deterministic folder/package/file/symbol ordering matching Build output.
56+
func (b *Builder) BuildTree(ctx context.Context) (*ragindex.TreeNode, int, int, error) {
4057
ns := b.Namespace
4158
if ns == "" {
4259
ns = ctxns.FromContext(ctx)
@@ -45,10 +62,13 @@ func (b *Builder) Build(ctx context.Context) (int, int, error) {
4562
ns = ctxns.DefaultNamespace
4663
}
4764
ctx = ctxns.WithNamespace(ctx, ns)
65+
if b.DB == nil {
66+
return nil, 0, 0, trace.New("DB not configured")
67+
}
4868

4969
nodes, annByID, err := b.loadNodes(ctx)
5070
if err != nil {
51-
return 0, 0, err
71+
return nil, 0, 0, err
5272
}
5373
root := &ragindex.TreeNode{ID: "root", Label: "Root", Kind: "root", Summary: b.ProjectDesc, Children: []*ragindex.TreeNode{}}
5474
state := &treeState{
@@ -84,11 +104,19 @@ func (b *Builder) Build(ctx context.Context) (int, int, error) {
84104
}
85105

86106
sortTree(root)
87-
idx := &ragindex.Index{Version: 1, BuiltAt: time.Now().UTC(), Root: root}
88-
if err := b.writeIndex(ns, idx); err != nil {
89-
return 0, 0, err
107+
return root, len(state.packages), len(state.files), nil
108+
}
109+
110+
// @intent resolve the namespace used for both DB reads and wiki-index output paths.
111+
func (b *Builder) namespace(ctx context.Context) string {
112+
ns := b.Namespace
113+
if ns == "" {
114+
ns = ctxns.FromContext(ctx)
115+
}
116+
if ns == "" {
117+
ns = ctxns.DefaultNamespace
90118
}
91-
return len(state.packages), len(state.files), nil
119+
return ns
92120
}
93121

94122
// @intent load the graph node set needed for Wiki navigation and summaries.
@@ -126,7 +154,13 @@ func (b *Builder) loadNodes(ctx context.Context) ([]model.Node, map[uint]*model.
126154
annByID := map[uint]*model.Annotation{}
127155
if len(ids) > 0 {
128156
var annotations []model.Annotation
129-
if err := b.DB.WithContext(ctx).Where("node_id IN ?", ids).Preload("Tags").Find(&annotations).Error; err != nil {
157+
if err := b.DB.WithContext(ctx).
158+
Joins("JOIN nodes ON nodes.id = annotations.node_id").
159+
Where("annotations.node_id IN ? AND nodes.namespace = ?", ids, ns).
160+
Preload("Tags", func(db *gorm.DB) *gorm.DB {
161+
return db.Order("ordinal ASC, id ASC")
162+
}).
163+
Find(&annotations).Error; err != nil {
130164
return nil, nil, trace.Wrap(err, "load wiki annotations")
131165
}
132166
for i := range annotations {

internal/wikiindex/builder_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@ package wikiindex_test
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
7+
"io/fs"
8+
"os"
69
"path/filepath"
710
"testing"
811

@@ -107,6 +110,79 @@ func TestBuilder_BuildsPackageFileSymbolTree(t *testing.T) {
107110
}
108111
}
109112

113+
func TestBuilder_BuildTreeReturnsWikiTreeWithoutWritingIndex(t *testing.T) {
114+
db := setupDB(t)
115+
tmpDir := t.TempDir()
116+
117+
pkg := createNode(t, db, model.Node{QualifiedName: "github.com/example/project/internal/core", Kind: model.NodeKindPackage, Name: "core", FilePath: "internal/core", StartLine: 1, EndLine: 1, Language: "go"})
118+
file := createNode(t, db, model.Node{QualifiedName: "internal/core/runtime.go", Kind: model.NodeKindFile, Name: "internal/core/runtime.go", FilePath: "internal/core/runtime.go", StartLine: 1, EndLine: 40, Language: "go"})
119+
fn := createNode(t, db, model.Node{QualifiedName: "core.NewRuntime", Kind: model.NodeKindFunction, Name: "NewRuntime", FilePath: "internal/core/runtime.go", StartLine: 10, EndLine: 20, Language: "go"})
120+
createTag(t, db, pkg.ID, model.TagIndex, "Core runtime package")
121+
createTag(t, db, file.ID, model.TagIndex, "Runtime wiring")
122+
createTag(t, db, fn.ID, model.TagIntent, "construct runtime")
123+
124+
builder := &wikiindex.Builder{DB: db, OutDir: "docs", IndexDir: filepath.Join(tmpDir, ".ccg")}
125+
root, packages, files, err := builder.BuildTree(context.Background())
126+
if err != nil {
127+
t.Fatalf("BuildTree: %v", err)
128+
}
129+
if packages != 1 || files != 1 {
130+
t.Fatalf("counts = packages:%d files:%d, want 1/1", packages, files)
131+
}
132+
if _, err := os.Stat(filepath.Join(tmpDir, ".ccg", "wiki-index.json")); !errors.Is(err, fs.ErrNotExist) {
133+
t.Fatalf("BuildTree wrote wiki-index.json or stat failed: %v", err)
134+
}
135+
pkgNode := ragindex.FindNode(root, "package:internal/core")
136+
if pkgNode == nil || pkgNode.Summary != "Core runtime package" {
137+
t.Fatalf("package node = %#v", pkgNode)
138+
}
139+
fileNode := ragindex.FindNode(root, "file:internal/core/runtime.go")
140+
if fileNode == nil || fileNode.DocPath != filepath.Join("docs", "internal/core/runtime.go.md") {
141+
t.Fatalf("file node = %#v", fileNode)
142+
}
143+
symNode := ragindex.FindNode(root, "symbol:core.NewRuntime")
144+
if symNode == nil || symNode.Details == nil || symNode.Details.QualifiedName != "core.NewRuntime" {
145+
t.Fatalf("symbol node = %#v", symNode)
146+
}
147+
}
148+
149+
func TestBuilder_BuildTreeScopesAnnotationsAndOrdersTags(t *testing.T) {
150+
db := setupDB(t)
151+
alphaNode := createNode(t, db, model.Node{Namespace: "alpha", QualifiedName: "alpha.Build", Kind: model.NodeKindFunction, Name: "Build", FilePath: "internal/alpha/build.go", StartLine: 1, EndLine: 10, Language: "go"})
152+
betaNode := createNode(t, db, model.Node{Namespace: "beta", QualifiedName: "beta.Build", Kind: model.NodeKindFunction, Name: "Build", FilePath: "internal/beta/build.go", StartLine: 1, EndLine: 10, Language: "go"})
153+
createAnnotation(t, db, alphaNode.ID,
154+
model.DocTag{Kind: model.TagSee, Value: "ccg://alpha/internal/alpha/build.go#Build", Ordinal: 2},
155+
model.DocTag{Kind: model.TagIntent, Value: "alpha build intent", Ordinal: 0},
156+
model.DocTag{Kind: model.TagDomainRule, Value: "alpha domain rule", Ordinal: 1},
157+
)
158+
createTag(t, db, betaNode.ID, model.TagIntent, "beta build intent")
159+
160+
root, _, _, err := (&wikiindex.Builder{DB: db, OutDir: "docs", Namespace: "alpha"}).BuildTree(context.Background())
161+
if err != nil {
162+
t.Fatalf("BuildTree: %v", err)
163+
}
164+
if ragindex.FindNode(root, "symbol:beta.Build") != nil {
165+
t.Fatalf("beta namespace node leaked into alpha tree: %#v", root)
166+
}
167+
symNode := ragindex.FindNode(root, "symbol:alpha.Build")
168+
if symNode == nil || symNode.Details == nil || symNode.Details.Annotation == nil {
169+
t.Fatalf("missing alpha symbol annotation details: %#v", symNode)
170+
}
171+
gotKinds := []model.TagKind{}
172+
for _, tag := range symNode.Details.Annotation.Tags {
173+
gotKinds = append(gotKinds, tag.Kind)
174+
}
175+
wantKinds := []model.TagKind{model.TagIntent, model.TagDomainRule, model.TagSee}
176+
if len(gotKinds) != len(wantKinds) {
177+
t.Fatalf("tag kinds = %#v, want %#v", gotKinds, wantKinds)
178+
}
179+
for i := range wantKinds {
180+
if gotKinds[i] != wantKinds[i] {
181+
t.Fatalf("tag kinds = %#v, want %#v", gotKinds, wantKinds)
182+
}
183+
}
184+
}
185+
110186
func TestBuilder_RespectsExclude(t *testing.T) {
111187
db := setupDB(t)
112188
tmpDir := t.TempDir()

0 commit comments

Comments
 (0)