Skip to content

Commit d89de18

Browse files
tae2089claude
andauthored
Split indexer.go god file into cohesive service files (#2)
indexer.go was 2,456 lines mixing the build pipeline, update pipeline, spool, package discovery, search refresh, graph-state loading, and file I/O. Move each concern into its own file within the same package: build.go, update.go, spool.go, packages.go, search.go, graphstate.go, fileio.go. indexer.go keeps the GraphService type, interfaces, options, and shared helpers (2,456 -> 160 lines). Pure structural change: no declaration added or removed (114 top-level decls before and after), imports pruned per file, full suite green. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 21988d5 commit d89de18

8 files changed

Lines changed: 2407 additions & 2296 deletions

File tree

internal/service/build.go

Lines changed: 637 additions & 0 deletions
Large diffs are not rendered by default.

internal/service/fileio.go

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// @index Filesystem walking, per-file parsing, and unreadable-file accounting.
2+
package service
3+
4+
import (
5+
"context"
6+
"fmt"
7+
"log/slog"
8+
"os"
9+
"path/filepath"
10+
11+
"github.com/tae2089/code-context-graph/internal/model"
12+
"github.com/tae2089/code-context-graph/internal/parse"
13+
"github.com/tae2089/code-context-graph/internal/parse/treesitter"
14+
"github.com/tae2089/code-context-graph/internal/pathutil"
15+
)
16+
17+
// @intent walk candidate source files once while applying recursion, exclude, and include-path policy before parsing.
18+
func walkMatchingFiles(ctx context.Context, absDir string, opts BuildOptions, fn func(path, relPath string) error) error {
19+
return filepath.Walk(absDir, func(path string, info os.FileInfo, err error) error {
20+
if ctxErr := ctx.Err(); ctxErr != nil {
21+
return ctxErr
22+
}
23+
if err != nil {
24+
return err
25+
}
26+
27+
relPath, _ := filepath.Rel(absDir, path)
28+
29+
if info.IsDir() {
30+
if path != absDir && opts.NoRecursive {
31+
return filepath.SkipDir
32+
}
33+
if pathutil.ShouldSkipDir(info.Name()) || pathutil.MatchExcludes(opts.ExcludePatterns, relPath) {
34+
return filepath.SkipDir
35+
}
36+
if len(opts.IncludePaths) > 0 && path != absDir && !pathutil.MatchIncludePaths(relPath, opts.IncludePaths) {
37+
return filepath.SkipDir
38+
}
39+
return nil
40+
}
41+
42+
if pathutil.MatchExcludes(opts.ExcludePatterns, relPath) {
43+
return nil
44+
}
45+
if len(opts.IncludePaths) > 0 && !pathutil.MatchIncludePaths(relPath, opts.IncludePaths) {
46+
return nil
47+
}
48+
return fn(path, relPath)
49+
})
50+
}
51+
52+
// parseForBuild parses one source file using the comment-aware parser when available.
53+
// @intent surface comment blocks and language alongside nodes/edges so the binder can attach annotations.
54+
func parseForBuild(ctx context.Context, parser Parser, relPath string, content []byte) ([]model.Node, []model.Edge, []treesitter.CommentBlock, treesitter.ParseMetadata, string, error) {
55+
if mp, ok := parser.(metadataParserWithLanguage); ok {
56+
nodes, edges, comments, meta, err := mp.ParseWithCommentsAndMetadata(ctx, relPath, content)
57+
return nodes, edges, comments, meta, mp.Language(), err
58+
}
59+
if cp, ok := parser.(commentParserWithLanguage); ok {
60+
nodes, edges, comments, err := cp.ParseWithComments(ctx, relPath, content)
61+
return nodes, edges, comments, treesitter.ParseMetadata{}, cp.Language(), err
62+
}
63+
nodes, edges, err := parser.ParseWithContext(ctx, relPath, content)
64+
return nodes, edges, nil, treesitter.ParseMetadata{}, "", err
65+
}
66+
67+
// unreadableFileSummary aggregates files that could not be stat-ed or read during a build or update pass.
68+
// @intent let callers surface a single structured failure or warning instead of one log entry per file.
69+
type unreadableFileSummary struct {
70+
count int
71+
sample string
72+
files []string
73+
}
74+
75+
// add records one more unreadable file, keeping the first occurrence as the sample.
76+
// @intent collect every offending path while keeping summary output bounded for logs.
77+
// @mutates s.count, s.sample, s.files
78+
func (s *unreadableFileSummary) add(relPath string) {
79+
s.count++
80+
if s.sample == "" {
81+
s.sample = relPath
82+
}
83+
s.files = append(s.files, relPath)
84+
}
85+
86+
// log emits a single warning describing how many files were skipped during a phase.
87+
// @intent prevent log spam by collapsing per-file warnings into one phase-tagged entry.
88+
// @sideEffect writes a warn-level log entry when the summary is non-empty.
89+
func (s unreadableFileSummary) log(logger *slog.Logger, phase string) {
90+
if s.count == 0 || logger == nil {
91+
return
92+
}
93+
logger.Warn("skipped unreadable files", "phase", phase, "count", s.count, "sample", s.sample)
94+
}
95+
96+
// asError converts the summary into an UnreadableFilesError when at least one file failed.
97+
// @intent let callers escalate skipped reads into a structured failure when FailOnUnreadable is set.
98+
func (s unreadableFileSummary) asError() error {
99+
if s.count == 0 {
100+
return nil
101+
}
102+
files := append([]string(nil), s.files...)
103+
return &UnreadableFilesError{Files: files}
104+
}
105+
106+
// @intent reject individual files that exceed the configured per-file parse budget before loading them into memory.
107+
func CheckParseFileSize(relPath string, size int64, maxFileBytes int64) error {
108+
if maxFileBytes > 0 && size > maxFileBytes {
109+
return fmt.Errorf("parse file %s exceeds max file bytes: %d > %d", relPath, size, maxFileBytes)
110+
}
111+
return nil
112+
}
113+
114+
// @intent stop one build or update pass once cumulative parsed bytes would exceed the configured safety limit.
115+
func CheckTotalParsedBytes(relPath string, current int64, next int64, maxTotalBytes int64) error {
116+
if maxTotalBytes > 0 && current+next > maxTotalBytes {
117+
return fmt.Errorf("parse file %s exceeds max total parsed bytes: %d > %d", relPath, current+next, maxTotalBytes)
118+
}
119+
return nil
120+
}
121+
122+
// toBinderComments converts walker comment blocks into binder comment blocks,
123+
// preserving docstring bookkeeping required by the Python docstring binding path.
124+
// @intent keep IsDocstring and OwnerStartLine in sync between walker and binder types
125+
func toBinderComments(tsComments []treesitter.CommentBlock) []parse.CommentBlock {
126+
out := make([]parse.CommentBlock, len(tsComments))
127+
for i, c := range tsComments {
128+
out[i] = parse.CommentBlock{
129+
StartLine: c.StartLine,
130+
EndLine: c.EndLine,
131+
Text: c.Text,
132+
IsDocstring: c.IsDocstring,
133+
OwnerStartLine: c.OwnerStartLine,
134+
}
135+
}
136+
return out
137+
}

internal/service/graphstate.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// @index Existing-graph file-state loading and change/force detection.
2+
package service
3+
4+
import (
5+
"context"
6+
7+
"gorm.io/gorm"
8+
9+
"github.com/tae2089/code-context-graph/internal/analysis/incremental"
10+
"github.com/tae2089/code-context-graph/internal/ctxns"
11+
"github.com/tae2089/code-context-graph/internal/model"
12+
"github.com/tae2089/code-context-graph/internal/pathutil"
13+
)
14+
15+
// ExistingGraphFiles returns namespace-scoped graph file paths currently stored.
16+
// @intent share deletion-scope discovery across CLI and MCP incremental updates
17+
func ExistingGraphFiles(ctx context.Context, db *gorm.DB) ([]string, error) {
18+
filePaths, _, err := existingGraphFileState(ctx, db)
19+
return filePaths, err
20+
}
21+
22+
// existingGraphFileState loads the namespace-scoped current graph state grouped by file path.
23+
// @intent provide both deletion-scope file paths and per-file node projections from a single query.
24+
func existingGraphFileState(ctx context.Context, db *gorm.DB) ([]string, map[string][]model.Node, error) {
25+
if db == nil {
26+
return nil, map[string][]model.Node{}, nil
27+
}
28+
29+
ns := ctxns.FromContext(ctx)
30+
var nodes []graphFileNodeState
31+
if err := db.WithContext(ctx).
32+
Model(&model.Node{}).
33+
Select("id", "file_path", "hash").
34+
Where("namespace = ? AND kind <> ?", ns, model.NodeKindPackage).
35+
Find(&nodes).Error; err != nil {
36+
return nil, nil, err
37+
}
38+
nodesByFile := make(map[string][]model.Node)
39+
fileSeen := make(map[string]struct{})
40+
filePaths := make([]string, 0)
41+
for _, node := range nodes {
42+
minimalNode := model.Node{ID: node.ID, FilePath: node.FilePath, Hash: node.Hash}
43+
nodesByFile[node.FilePath] = append(nodesByFile[node.FilePath], minimalNode)
44+
if _, ok := fileSeen[node.FilePath]; !ok {
45+
fileSeen[node.FilePath] = struct{}{}
46+
filePaths = append(filePaths, node.FilePath)
47+
}
48+
}
49+
return filePaths, nodesByFile, nil
50+
}
51+
52+
// filterExistingStateByInclude restricts existing graph state to file paths that match the include filter.
53+
// @intent prevent partial-scope updates from deleting files that live outside the requested include paths.
54+
func filterExistingStateByInclude(filePaths []string, nodesByFile map[string][]model.Node, includePaths []string) ([]string, map[string][]model.Node) {
55+
filteredFiles := make([]string, 0, len(filePaths))
56+
filteredNodes := make(map[string][]model.Node)
57+
for _, fp := range filePaths {
58+
if !pathutil.MatchIncludePaths(fp, includePaths) {
59+
continue
60+
}
61+
filteredFiles = append(filteredFiles, fp)
62+
filteredNodes[fp] = nodesByFile[fp]
63+
}
64+
return filteredFiles, filteredNodes
65+
}
66+
67+
// forceReparseFiles finds files whose edges reference nodes from changed files and therefore must be reparsed.
68+
// @intent keep cross-file edges consistent by reparsing edge-source files when their referenced nodes change.
69+
func forceReparseFiles(ctx context.Context, db *gorm.DB, existingNodesByFile map[string][]model.Node, currentHashes map[string]string) (map[string]struct{}, error) {
70+
forceFiles := make(map[string]struct{})
71+
if db == nil || len(existingNodesByFile) == 0 || len(currentHashes) == 0 {
72+
return forceFiles, nil
73+
}
74+
if !db.Migrator().HasTable(&model.Edge{}) {
75+
return forceFiles, nil
76+
}
77+
78+
var changedNodeIDs []uint
79+
for filePath, nodes := range existingNodesByFile {
80+
if len(nodes) == 0 {
81+
continue
82+
}
83+
currentHash, stillPresent := currentHashes[filePath]
84+
if !stillPresent || nodes[0].Hash != currentHash {
85+
for _, node := range nodes {
86+
changedNodeIDs = append(changedNodeIDs, node.ID)
87+
}
88+
}
89+
}
90+
if len(changedNodeIDs) == 0 {
91+
return forceFiles, nil
92+
}
93+
94+
ns := ctxns.FromContext(ctx)
95+
edgeFileSeen := make(map[string]struct{})
96+
for start := 0; start < len(changedNodeIDs); start += forceReparseEdgeChunkSize {
97+
end := min(start+forceReparseEdgeChunkSize, len(changedNodeIDs))
98+
chunk := changedNodeIDs[start:end]
99+
var chunkFiles []string
100+
if err := db.WithContext(ctx).
101+
Model(&model.Edge{}).
102+
Where("namespace = ? AND file_path <> '' AND (from_node_id IN ? OR to_node_id IN ?)", ns, chunk, chunk).
103+
Distinct().
104+
Pluck("file_path", &chunkFiles).Error; err != nil {
105+
return nil, err
106+
}
107+
for _, filePath := range chunkFiles {
108+
edgeFileSeen[filePath] = struct{}{}
109+
}
110+
111+
var relatedImplements []model.Edge
112+
if err := db.WithContext(ctx).
113+
Model(&model.Edge{}).
114+
Where("namespace = ? AND kind = ? AND file_path <> '' AND (from_node_id IN ? OR to_node_id IN ?)", ns, model.EdgeKindImplements, chunk, chunk).
115+
Find(&relatedImplements).Error; err != nil {
116+
return nil, err
117+
}
118+
var relatedTypeIDs []uint
119+
seenTypeID := make(map[uint]struct{}, len(relatedImplements)*2)
120+
for _, edge := range relatedImplements {
121+
for _, id := range []uint{edge.FromNodeID, edge.ToNodeID} {
122+
if id == 0 {
123+
continue
124+
}
125+
if _, ok := seenTypeID[id]; ok {
126+
continue
127+
}
128+
seenTypeID[id] = struct{}{}
129+
relatedTypeIDs = append(relatedTypeIDs, id)
130+
}
131+
}
132+
for relatedStart := 0; relatedStart < len(relatedTypeIDs); relatedStart += forceReparseEdgeChunkSize {
133+
relatedEnd := min(relatedStart+forceReparseEdgeChunkSize, len(relatedTypeIDs))
134+
relatedChunk := relatedTypeIDs[relatedStart:relatedEnd]
135+
var dispatchFiles []string
136+
if err := db.WithContext(ctx).
137+
Model(&model.Edge{}).
138+
Where("namespace = ? AND kind IN ? AND file_path <> '' AND (from_node_id IN ? OR to_node_id IN ?)", ns, model.CallEdgeKinds(), relatedChunk, relatedChunk).
139+
Distinct().
140+
Pluck("file_path", &dispatchFiles).Error; err != nil {
141+
return nil, err
142+
}
143+
for _, filePath := range dispatchFiles {
144+
edgeFileSeen[filePath] = struct{}{}
145+
}
146+
}
147+
}
148+
for filePath := range edgeFileSeen {
149+
currentHash, stillPresent := currentHashes[filePath]
150+
if !stillPresent {
151+
continue
152+
}
153+
nodes := existingNodesByFile[filePath]
154+
if len(nodes) == 0 || nodes[0].Hash != currentHash {
155+
continue
156+
}
157+
forceFiles[filePath] = struct{}{}
158+
}
159+
return forceFiles, nil
160+
}
161+
162+
// splitForcedFiles partitions inputs into normal and forced-reparse buckets and marks the forced ones.
163+
// @intent process unchanged-hash forced files separately so the syncer can bypass its hash short-circuit.
164+
func splitForcedFiles(files map[string]incremental.FileInfo, forceFiles map[string]struct{}) (map[string]incremental.FileInfo, map[string]incremental.FileInfo) {
165+
if len(files) == 0 {
166+
return nil, nil
167+
}
168+
if len(forceFiles) == 0 {
169+
return files, nil
170+
}
171+
normal := make(map[string]incremental.FileInfo, len(files))
172+
forced := make(map[string]incremental.FileInfo)
173+
for filePath, info := range files {
174+
if _, ok := forceFiles[filePath]; ok {
175+
info.Force = true
176+
forced[filePath] = info
177+
continue
178+
}
179+
normal[filePath] = info
180+
}
181+
return normal, forced
182+
}

0 commit comments

Comments
 (0)