From 54b07375ee18ad29976712a84d90f4fa1f75b295 Mon Sep 17 00:00:00 2001 From: yushan Date: Tue, 22 Sep 2026 19:08:35 +0000 Subject: [PATCH] fix(itg): preserve dependency edges when re-synthesizing external rule targets UpdateGraph re-synthesizes a targethasher.Target for each external rule target already present in the optimized graph but absent from the current query result (i.e. carried over unchanged). That reconstruction previously left Deps unset, so upsertTarget treated the target as having zero dependencies and dropped its existing dependency edges on every update where the target wasn't freshly re-queried. This silently broke reverse-dep invalidation for anything depending on that external target transitively. Rebuild Deps from the target's recorded dependency IDs via TargetIDToString so the edges survive being carried over. Co-Authored-By: Claude Sonnet 5 --- core/itg/graph/update.go | 5 ++++ core/itg/graph/update_test.go | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/core/itg/graph/update.go b/core/itg/graph/update.go index 9d0849b9..5e3ffa72 100644 --- a/core/itg/graph/update.go +++ b/core/itg/graph/update.go @@ -75,9 +75,14 @@ func (g *OptimizedGraph) UpdateGraph( for id, target := range g.ExternalRuleTargets { name := g.TargetIDToString[id] if _, exists := targets[name]; !exists { + deps := make([]string, 0, len(target.Deps)) + for depID := range target.Deps { + deps = append(deps, g.TargetIDToString[depID]) + } targets[name] = &targethasher.Target{ Name: name, RuleType: targethasher.ExternalRuleType, + Deps: deps, Hash: target.Hash, HashWithoutDeps: target.HashWithoutDeps, External: target.External, diff --git a/core/itg/graph/update_test.go b/core/itg/graph/update_test.go index 19b79b34..da4d63ee 100644 --- a/core/itg/graph/update_test.go +++ b/core/itg/graph/update_test.go @@ -234,6 +234,61 @@ func TestComputeHashes(t *testing.T) { }) } +// --- UpdateGraph: external rule target dep preservation --- + +func TestUpdateGraphPreservesExternalRuleTargetDeps(t *testing.T) { + t.Parallel() + + t.Run("carried-over external rule target keeps its dependency edges", func(t *testing.T) { + t.Parallel() + g := OptimizeGraph(map[string]*targethasher.Target{ + "//pkg:dep": {Name: "//pkg:dep", RuleType: "go_library", HashWithoutDeps: []byte{0x01}, Hash: []byte{0x01}}, + "//external:repo": { + Name: "//external:repo", + RuleType: targethasher.ExternalRuleType, + Deps: []string{"//pkg:dep"}, + Hash: []byte{0xCA, 0xFE}, + HashWithoutDeps: []byte{0xCA, 0xFE}, + }, + }) + depID := g.TargetNameToID["//pkg:dep"] + externalID := g.TargetNameToID["//external:repo"] + require.True(t, g.OptimizedTargets[externalID].Deps.Contains(depID), + "test setup: external target should start with the dep edge") + + // A query result with no targets at all: //external:repo is not + // rediscovered fresh, so UpdateGraph must reconstruct it from + // g.ExternalRuleTargets rather than dropping it. + err := g.UpdateGraph(context.Background(), &fakeSourceHasher{}, UpdateGraphInput{ + QueryResult: &buildpb.QueryResult{}, + }) + require.NoError(t, err) + + assert.True(t, g.OptimizedTargets[externalID].Deps.Contains(depID), + "external rule target should keep its dependency edge after being carried over unchanged") + }) + + t.Run("carried-over external rule target with no deps stays empty without error", func(t *testing.T) { + t.Parallel() + g := OptimizeGraph(map[string]*targethasher.Target{ + "//external:repo": { + Name: "//external:repo", + RuleType: targethasher.ExternalRuleType, + Hash: []byte{0xCA, 0xFE}, + HashWithoutDeps: []byte{0xCA, 0xFE}, + }, + }) + externalID := g.TargetNameToID["//external:repo"] + + err := g.UpdateGraph(context.Background(), &fakeSourceHasher{}, UpdateGraphInput{ + QueryResult: &buildpb.QueryResult{}, + }) + require.NoError(t, err) + + assert.Empty(t, g.OptimizedTargets[externalID].Deps) + }) +} + func TestComputeInvalidatedHashesCycleOrderInvariance(t *testing.T) { t.Parallel()