Skip to content

Commit 2210713

Browse files
committed
fix: rebuild updates for new packages
1 parent e71608c commit 2210713

3 files changed

Lines changed: 230 additions & 32 deletions

File tree

internal/app/ingest/workflow/indexer_test.go

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,26 @@ type recordingSyncCall struct {
422422
existingFiles []string
423423
}
424424

425+
type countingIncrementalSyncer struct {
426+
delegate *incremental.Syncer
427+
calls int
428+
}
429+
430+
func (s *countingIncrementalSyncer) SyncWithExisting(ctx context.Context, files map[string]ingest.FileInfo, existingFiles []string) (*ingest.SyncStats, error) {
431+
s.calls++
432+
return s.delegate.SyncWithExisting(ctx, files, existingFiles)
433+
}
434+
435+
func (s *countingIncrementalSyncer) SyncWithExistingStore(ctx context.Context, graphStore ingest.GraphStore, files map[string]ingest.FileInfo, existingFiles []string) (*ingest.SyncStats, error) {
436+
s.calls++
437+
return s.delegate.SyncWithExistingStore(ctx, graphStore, files, existingFiles)
438+
}
439+
440+
func (s *countingIncrementalSyncer) SyncBatchesWithExistingStore(ctx context.Context, graphStore ingest.GraphStore, source ingest.FileBatchSource, deletedFiles []string) (*ingest.SyncStats, error) {
441+
s.calls++
442+
return s.delegate.SyncBatchesWithExistingStore(ctx, graphStore, source, deletedFiles)
443+
}
444+
425445
func (r *recordingIncrementalSyncer) Sync(ctx context.Context, files map[string]incremental.FileInfo) (*incremental.SyncStats, error) {
426446
panic("unexpected Sync call")
427447
}
@@ -1121,14 +1141,18 @@ func TestUpdate_ReconcilesExistingCallerAfterTargetFileAdded(t *testing.T) {
11211141
}
11221142
writeFile("target.go", "package sample\n\nfunc Target() {}\n")
11231143

1124-
syncer := incremental.NewWithRegistry(st, map[string]incremental.Parser{".go": walker}, incremental.WithLogger(slog.Default()))
1144+
delegate := incremental.NewWithRegistry(st, map[string]incremental.Parser{".go": walker}, incremental.WithLogger(slog.Default()))
1145+
syncer := &countingIncrementalSyncer{delegate: delegate}
11251146
stats, err := svc.Update(ctx, UpdateOptions{BuildOptions: BuildOptions{Dir: tmpDir}, Syncer: syncer})
11261147
if err != nil {
11271148
t.Fatalf("Update after target add: %v", err)
11281149
}
11291150
if stats.Added != 1 {
11301151
t.Fatalf("Added = %d, want one newly added target", stats.Added)
11311152
}
1153+
if syncer.calls != 1 {
1154+
t.Fatalf("incremental sync calls = %d, want existing-package update path", syncer.calls)
1155+
}
11321156

11331157
source, err := st.GetNode(ctx, "sample.Source")
11341158
if err != nil || source == nil {
@@ -1150,6 +1174,78 @@ func TestUpdate_ReconcilesExistingCallerAfterTargetFileAdded(t *testing.T) {
11501174
t.Fatalf("expected existing caller edge from %d to newly added target %d, got %+v", source.ID, target.ID, edges)
11511175
}
11521176

1177+
func TestUpdate_ReconcilesExistingCrossPackageCallerAfterTargetPackageAdded(t *testing.T) {
1178+
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: gormlogger.Discard})
1179+
if err != nil {
1180+
t.Fatalf("open db: %v", err)
1181+
}
1182+
st := graphgorm.New(db)
1183+
if err := st.AutoMigrate(); err != nil {
1184+
t.Fatalf("migrate: %v", err)
1185+
}
1186+
walker := treesitter.NewWalker(treesitter.GoSpec)
1187+
svc := &Service{Store: st, UnitOfWork: newTestUnitOfWork(db, nil), Walkers: map[string]Parser{".go": walker}, Logger: slog.Default()}
1188+
1189+
tmpDir := t.TempDir()
1190+
writeFile := func(rel, content string) {
1191+
full := filepath.Join(tmpDir, rel)
1192+
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
1193+
t.Fatalf("mkdir %s: %v", rel, err)
1194+
}
1195+
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
1196+
t.Fatalf("write %s: %v", rel, err)
1197+
}
1198+
}
1199+
writeFile("go.mod", "module github.com/example/project\n\ngo 1.25.0\n")
1200+
writeFile("app/source.go", `package app
1201+
1202+
import "github.com/example/project/api"
1203+
1204+
func Source() { api.Target() }
1205+
`)
1206+
1207+
ctx := context.Background()
1208+
if _, err := svc.Build(ctx, BuildOptions{Dir: tmpDir}); err != nil {
1209+
t.Fatalf("Build before target package exists: %v", err)
1210+
}
1211+
writeFile("api/target.go", "package api\n\nfunc Target() {}\n")
1212+
1213+
delegate := incremental.NewWithRegistry(st, map[string]incremental.Parser{".go": walker}, incremental.WithLogger(slog.Default()))
1214+
syncer := &countingIncrementalSyncer{delegate: delegate}
1215+
stats, err := svc.Update(ctx, UpdateOptions{BuildOptions: BuildOptions{Dir: tmpDir}, Syncer: syncer})
1216+
if err != nil {
1217+
t.Fatalf("Update after target package add: %v", err)
1218+
}
1219+
if stats.Added != 1 {
1220+
t.Fatalf("Added = %d, want one newly added target file", stats.Added)
1221+
}
1222+
if stats.Modified != 0 || stats.Skipped != 1 || stats.Deleted != 0 {
1223+
t.Fatalf("fallback change counts = %+v, want added=1 modified=0 skipped=1 deleted=0", stats)
1224+
}
1225+
if syncer.calls != 0 {
1226+
t.Fatalf("incremental sync calls = %d, want full-build fallback", syncer.calls)
1227+
}
1228+
1229+
source, err := st.GetNode(ctx, "app.Source")
1230+
if err != nil || source == nil {
1231+
t.Fatalf("GetNode source: node=%v err=%v", source, err)
1232+
}
1233+
target, err := st.GetNode(ctx, "api.Target")
1234+
if err != nil || target == nil {
1235+
t.Fatalf("GetNode target: node=%v err=%v", target, err)
1236+
}
1237+
edges, err := st.GetEdgesFrom(ctx, source.ID)
1238+
if err != nil {
1239+
t.Fatalf("GetEdgesFrom: %v", err)
1240+
}
1241+
for _, edge := range edges {
1242+
if edge.Kind == graph.EdgeKindCalls && edge.ToNodeID == target.ID {
1243+
return
1244+
}
1245+
}
1246+
t.Fatalf("expected cross-package caller edge from %d to newly added target %d, got %+v", source.ID, target.ID, edges)
1247+
}
1248+
11531249
func TestUpdate_RemovesStaleCrossFileGoStructuralImplements(t *testing.T) {
11541250
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: gormlogger.Discard})
11551251
if err != nil {
@@ -3393,6 +3489,15 @@ func TestUpdate_IncludePaths_FiltersExistingFilesWhenReplaceFalse(t *testing.T)
33933489
if got, want := deleteCall.existingFiles, []string{filepath.Join("src", "api", "handler.go")}; !reflect.DeepEqual(got, want) {
33943490
t.Fatalf("delete pass existingFiles mismatch: got=%v want=%v (helper.go outside include path must be scoped out)", got, want)
33953491
}
3492+
var outsideCount int64
3493+
if err := db.Model(&graph.Node{}).
3494+
Where("namespace = ? AND file_path = ?", requestctx.DefaultNamespace, filepath.Join("src", "other", "helper.go")).
3495+
Count(&outsideCount).Error; err != nil {
3496+
t.Fatalf("count out-of-scope node: %v", err)
3497+
}
3498+
if outsideCount != 1 {
3499+
t.Fatalf("out-of-scope node count = %d, want preserved graph state", outsideCount)
3500+
}
33963501
}
33973502

33983503
func TestUpdate_ExcludePatterns_LeavesMatchingFilesOutOfSync(t *testing.T) {

internal/app/ingest/workflow/packages.go

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -504,13 +504,13 @@ func affectedPackageImportPaths(packages map[string]languagePackageInfo, affecte
504504
return affected
505505
}
506506

507-
// addUnchangedPeersForAddedFiles marks unchanged persisted peers for reparse when a new file joins their package or directory.
508-
// @intent let local symbol lookup observe declarations introduced by newly added source files during incremental update.
509-
// @domainRule discovered packages cover multi-directory language packages; a same-directory fallback preserves local resolution when discovery is unavailable.
507+
// addUnchangedPeersForAddedFiles marks local unchanged peers and reports whether added files require graph-wide reconciliation.
508+
// @intent let existing-package additions stay incremental while signaling new package/directory additions that can affect unresolved external callers.
509+
// @domainRule additions to existing packages reparse package/directory peers; a wholly new package or source directory requires full-graph reconciliation because unresolved external callers are not persisted.
510510
// @mutates forceFiles
511-
func addUnchangedPeersForAddedFiles(forceFiles map[string]struct{}, packages map[string]languagePackageInfo, existingNodesByFile map[string][]graph.Node, currentHashes map[string]string) {
512-
if forceFiles == nil || len(existingNodesByFile) == 0 || len(currentHashes) == 0 {
513-
return
511+
func addUnchangedPeersForAddedFiles(forceFiles map[string]struct{}, packages map[string]languagePackageInfo, existingNodesByFile map[string][]graph.Node, currentHashes map[string]string) bool {
512+
if forceFiles == nil || len(currentHashes) == 0 {
513+
return false
514514
}
515515
addedFiles := make([]string, 0)
516516
addedDirs := make(map[string]struct{})
@@ -521,20 +521,36 @@ func addUnchangedPeersForAddedFiles(forceFiles map[string]struct{}, packages map
521521
}
522522
}
523523
if len(addedFiles) == 0 {
524-
return
524+
return false
525525
}
526526
peerFiles := make(map[string]struct{})
527+
wideReparse := false
527528
for _, importPath := range affectedPackageImportPaths(packages, addedFiles) {
528-
for _, filePath := range packages[importPath].Files {
529+
pkg := packages[importPath]
530+
hasPersistedMember := false
531+
for _, filePath := range pkg.Files {
532+
if len(existingNodesByFile[filepath.ToSlash(filePath)]) > 0 {
533+
hasPersistedMember = true
534+
}
529535
peerFiles[filepath.ToSlash(filePath)] = struct{}{}
530536
}
537+
if !hasPersistedMember {
538+
wideReparse = true
539+
}
531540
}
541+
existingDirs := make(map[string]struct{})
532542
for filePath := range existingNodesByFile {
533543
filePath = filepath.ToSlash(filePath)
544+
existingDirs[path.Dir(filePath)] = struct{}{}
534545
if _, sameDir := addedDirs[path.Dir(filePath)]; sameDir {
535546
peerFiles[filePath] = struct{}{}
536547
}
537548
}
549+
for dir := range addedDirs {
550+
if _, existed := existingDirs[dir]; !existed {
551+
wideReparse = true
552+
}
553+
}
538554
for filePath := range peerFiles {
539555
nodes := existingNodesByFile[filePath]
540556
currentHash, present := currentHashes[filePath]
@@ -543,6 +559,20 @@ func addUnchangedPeersForAddedFiles(forceFiles map[string]struct{}, packages map
543559
}
544560
forceFiles[filePath] = struct{}{}
545561
}
562+
return wideReparse
563+
}
564+
565+
// addAllUnchangedFiles expands a conservative incremental fallback to every unchanged file in the current scope.
566+
// @intent preserve graph correctness when full-build fallback is unsafe or unavailable.
567+
// @mutates forceFiles
568+
func addAllUnchangedFiles(forceFiles map[string]struct{}, existingNodesByFile map[string][]graph.Node, currentHashes map[string]string) {
569+
for filePath, nodes := range existingNodesByFile {
570+
currentHash, present := currentHashes[filePath]
571+
if len(nodes) == 0 || !present || nodes[0].Hash != currentHash {
572+
continue
573+
}
574+
forceFiles[filepath.ToSlash(filePath)] = struct{}{}
575+
}
546576
}
547577

548578
// appendUniqueString appends a string to a slice only if it is not already present.

0 commit comments

Comments
 (0)