From 4cdb5cf1944008c04b8cfa48fa3254ee617487f1 Mon Sep 17 00:00:00 2001 From: Bubunyo Nyavor Date: Fri, 13 Mar 2026 18:44:26 +0100 Subject: [PATCH 1/6] test: add failing tests for blank-import side-effect dependency tracking --- pkg/analyzer/analyzer_test.go | 41 ++++++++++++++++++++++- pkg/impact/impact_test.go | 41 +++++++++++++++++++++++ testproject/core/sideeffect/sideeffect.go | 9 +++++ testproject/services/service-a/main.go | 1 + 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 testproject/core/sideeffect/sideeffect.go diff --git a/pkg/analyzer/analyzer_test.go b/pkg/analyzer/analyzer_test.go index 42723cb..b548cd0 100644 --- a/pkg/analyzer/analyzer_test.go +++ b/pkg/analyzer/analyzer_test.go @@ -310,7 +310,46 @@ func TestBuildGraph_ExcludePatternsPreserveOtherFunctions(t *testing.T) { assert.True(t, found, "non-excluded functions from module-a/module-b must still be present") } -// ── serviceDirs integration ─────────────────────────────────────────────────── +// ── blank import / side-effect dependency ──────────────────────────────────── + +// TestBuildGraph_BlankImport_SideEffectTrackedInReverseIndex asserts that a +// package imported only for its side effects (import _ "pkg") creates a +// dependency edge in the call graph so that changes to it trigger a rebuild of +// the importing service. +// +// This test is expected to FAIL until the analyzer synthesises a dependency +// edge from the importer to the blank-imported package's init. +func TestBuildGraph_BlankImport_SideEffectTrackedInReverseIndex(t *testing.T) { + a := loadedAnalyzer(t) + _, graph, err := a.BuildGraph() + require.NoError(t, err) + + const sideeffectPkg = "sideeffect" + const serviceAPkg = "service-a" + + // sideeffect.init must appear in the reverse index — service-a blank-imports it. + var sideeffectKey string + for k := range graph.ReverseIndex { + if strings.Contains(k, sideeffectPkg) { + sideeffectKey = k + break + } + } + require.NotEmpty(t, sideeffectKey, + "sideeffect.init must appear in the reverse index (service-a blank-imports it)") + + // service-a must be listed as a caller of sideeffect. + callers := graph.ReverseIndex[sideeffectKey] + found := false + for _, caller := range callers { + if strings.Contains(caller, serviceAPkg) { + found = true + break + } + } + assert.True(t, found, + "service-a must appear as a caller of sideeffect via blank import; callers=%v", callers) +} // TestBuildGraph_ToolsLoadedButNotServices verifies that when the testproject // has a tools/tool-a package (which imports a shared core module and has a diff --git a/pkg/impact/impact_test.go b/pkg/impact/impact_test.go index fbd0e76..f8b7605 100644 --- a/pkg/impact/impact_test.go +++ b/pkg/impact/impact_test.go @@ -269,6 +269,47 @@ func TestComputeImpact_ToolsExcludedByServiceDir(t *testing.T) { assert.NotContains(t, result.ServicesToBuild, "tools/mytool") } +// ── blank import / side-effect dependency ──────────────────────────────────── + +// TestComputeImpact_BlankImportSideEffectTriggersRebuild asserts that a change +// to a package imported only for its side effects (no explicit call edge) still +// causes the importing service to be scheduled for rebuild, and does NOT cause +// an unrelated service to rebuild. +// +// This test is expected to FAIL until the analyzer synthesises a dependency +// edge for blank imports and ComputeImpact propagates through it. Without the +// edge, ComputeImpact finds no service reachable from sideeffect.init and the +// fallback (rebuild all) would incorrectly include svc-b. +func TestComputeImpact_BlankImportSideEffectTriggersRebuild(t *testing.T) { + // svc-a blank-imports sideeffect; svc-b does not depend on it at all. + // The correct behaviour: only svc-a rebuilds. + g := buildGraph( + map[string]bool{ + "core/sideeffect.init": false, + "services/svc-a.main": true, + "services/svc-b.main": true, + }, + map[string]string{ + "core/sideeffect.init": "core/sideeffect", + "services/svc-a.main": "services/svc-a", + "services/svc-b.main": "services/svc-b", + }, + map[string][]string{ + // The edge from sideeffect.init -> svc-a.main is what the fix must add. + // Currently absent, so this test fails. + "core/sideeffect.init": {"services/svc-a.main"}, + }, + ) + result := NewAnalyzer(g, []string{"services"}).ComputeImpact( + []types.Change{change("core/sideeffect.init")}, + ) + + assert.Contains(t, result.ServicesToBuild, "services/svc-a", + "svc-a must rebuild: it blank-imports sideeffect") + assert.NotContains(t, result.ServicesToBuild, "services/svc-b", + "svc-b must not rebuild: it has no dependency on sideeffect") +} + // TestComputeImpact_EmptyServiceDirsFallsBackToAllMains asserts that when no // serviceDirs are configured (nil), all main packages — including those under // tools/ — are treated as services and emitted by their full owner path. diff --git a/testproject/core/sideeffect/sideeffect.go b/testproject/core/sideeffect/sideeffect.go new file mode 100644 index 0000000..5748e7e --- /dev/null +++ b/testproject/core/sideeffect/sideeffect.go @@ -0,0 +1,9 @@ +package sideeffect + +// Registered is set to true by init, simulating a side-effect registration +// (e.g. a database driver, codec, or plugin). +var Registered bool + +func init() { + Registered = true +} diff --git a/testproject/services/service-a/main.go b/testproject/services/service-a/main.go index 71d5c0d..9aa097c 100644 --- a/testproject/services/service-a/main.go +++ b/testproject/services/service-a/main.go @@ -6,6 +6,7 @@ import ( "github.com/bubunyo/buildgraph/testproject/core/collision" module_a "github.com/bubunyo/buildgraph/testproject/core/module-a" module_b "github.com/bubunyo/buildgraph/testproject/core/module-b" + _ "github.com/bubunyo/buildgraph/testproject/core/sideeffect" ) func main() { From 598c76e26d7c35b7a6f83d37ee83a3a21de60103 Mon Sep 17 00:00:00 2001 From: Bubunyo Nyavor Date: Fri, 13 Mar 2026 18:52:18 +0100 Subject: [PATCH 2/6] add sideefect registering in call graph --- pkg/analyzer/analyzer.go | 111 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 80d84c1..e59d257 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -196,6 +196,18 @@ func (a *Analyzer) BuildGraph() (map[string]*types.Function, *types.CallGraph, e return nil, nil, err } + // Synthesise dependency edges for blank imports (import _ "pkg"). + // + // CHA only walks explicit call instructions, so blank-imported packages + // leave no call edge even though their init() runs at startup — meaning a + // change to a blank-imported package would be silently missed. + // + // For every internal package P that blank-imports another internal package + // Q, we add a synthetic edge: P.init → Q.init in the reverse index. This + // makes ComputeImpact aware that a change in Q must trigger a rebuild of + // any service that (transitively) imports Q for side effects. + a.synthesiseBlankImportEdges(functions, nodes, reverseIndex, functionOwner) + // Also capture functions that have no edges at all (leaf functions with // no callers and no callees) by walking every SSA function directly. for fn := range a.cg.Nodes { @@ -452,6 +464,105 @@ func (a *Analyzer) buildPatterns() []string { return patterns } +// synthesiseBlankImportEdges adds synthetic reverse-index edges for blank +// imports (import _ "pkg"). +// +// CHA only walks explicit SSA call instructions, so blank-imported packages +// leave no edge in the call graph even though their init() function is +// guaranteed to run before the importing package's init(). Without this +// synthesis, a change to a blank-imported package would be silently missed. +// +// Strategy: +// 1. For each loaded *packages.Package P, look at P.Imports. Any entry +// whose import path is not referenced by the syntax tree as a named import +// is a blank import (the go/packages loader still includes it in Imports). +// 2. Find the SSA init function for both P and the blank-imported package Q. +// 3. Register Q.init and P.init in the graph if not already present. +// 4. Add P.init → Q.init to the reverse index, so ComputeImpact knows that +// a change in Q propagates to every service that (transitively) imports P. +func (a *Analyzer) synthesiseBlankImportEdges( + functions map[string]*types.Function, + nodes map[string]types.Function, + reverseIndex map[string][]string, + functionOwner map[string]string, +) { + for _, pkg := range a.allPkgs { + if !strings.HasPrefix(pkg.PkgPath, a.rootModule) { + continue + } + + // Collect the set of import paths that appear as named (non-blank) + // identifiers in the source syntax. Any import in pkg.Imports that is + // NOT in this set was blank-imported. + named := namedImports(pkg) + + for importPath, importedPkg := range pkg.Imports { + if named[importPath] { + continue // regular import — CHA already handles it + } + if !strings.HasPrefix(importPath, a.rootModule) { + continue // only care about internal blank imports + } + + // Resolve SSA packages for importer and importee. + importerSSA := a.prog.Package(pkg.Types) + importedSSA := a.prog.Package(importedPkg.Types) + if importerSSA == nil || importedSSA == nil { + continue + } + + importerInit := importerSSA.Func("init") + importedInit := importedSSA.Func("init") + if importerInit == nil || importedInit == nil { + continue + } + + importerKey := funcKey(importerInit) + importedKey := funcKey(importedInit) + + // Ensure both nodes are registered in the graph. + for _, pair := range []struct { + key string + fn *ssa.Function + }{ + {importerKey, importerInit}, + {importedKey, importedInit}, + } { + if _, exists := functions[pair.key]; !exists { + f := a.toFunction(pair.fn) + functions[pair.key] = f + nodes[pair.key] = *f + functionOwner[pair.key] = a.owner(pair.fn) + } + } + + // Add the synthetic reverse edge: importedInit ← importerInit. + if !hasString(reverseIndex[importedKey], importerKey) { + reverseIndex[importedKey] = append(reverseIndex[importedKey], importerKey) + } + } + } +} + +// namedImports returns the set of import paths that are referenced under a +// non-blank local name in the given package's syntax files. Any import path +// present in pkg.Imports but absent from this set was blank-imported. +func namedImports(pkg *packages.Package) map[string]bool { + named := make(map[string]bool) + for _, file := range pkg.Syntax { + for _, imp := range file.Imports { + // imp.Name == nil → default name (not blank) + // imp.Name.Name == "_" → blank import + if imp.Name == nil || imp.Name.Name != "_" { + // Unquote the import path string literal. + p := strings.Trim(imp.Path.Value, `"`) + named[p] = true + } + } + } + return named +} + // applyExcludeFilters removes from functions and graph any function whose // source file matches a configured exclude glob pattern or whose file path // contains /vendor/ when SkipVendor is enabled. Entries are also purged from From a125f060935a14b5462191e29f9d2e9db0a60e84 Mon Sep 17 00:00:00 2001 From: Bubunyo Nyavor Date: Fri, 13 Mar 2026 19:02:07 +0100 Subject: [PATCH 3/6] apply call optimizations --- pkg/analyzer/analyzer.go | 59 ++++++++++++++--------- testproject/core/sideeffect/sideeffect.go | 2 +- 2 files changed, 37 insertions(+), 24 deletions(-) diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index e59d257..12606bf 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -19,6 +19,7 @@ import ( "path" "path/filepath" "slices" + "strconv" "strings" "golang.org/x/tools/go/callgraph" @@ -496,6 +497,23 @@ func (a *Analyzer) synthesiseBlankImportEdges( // NOT in this set was blank-imported. named := namedImports(pkg) + // Fast path: if every import is named, there is nothing to synthesise. + if len(named) == len(pkg.Imports) { + continue + } + + // Resolve the importer's SSA package once — it is shared across all + // blank imports of this package. + importerSSA := a.prog.Package(pkg.Types) + if importerSSA == nil { + continue + } + importerInit := importerSSA.Func("init") + if importerInit == nil { + continue + } + importerKey := funcKey(importerInit) + for importPath, importedPkg := range pkg.Imports { if named[importPath] { continue // regular import — CHA already handles it @@ -504,36 +522,28 @@ func (a *Analyzer) synthesiseBlankImportEdges( continue // only care about internal blank imports } - // Resolve SSA packages for importer and importee. - importerSSA := a.prog.Package(pkg.Types) importedSSA := a.prog.Package(importedPkg.Types) - if importerSSA == nil || importedSSA == nil { + if importedSSA == nil { continue } - - importerInit := importerSSA.Func("init") importedInit := importedSSA.Func("init") - if importerInit == nil || importedInit == nil { + if importedInit == nil { continue } - - importerKey := funcKey(importerInit) importedKey := funcKey(importedInit) - // Ensure both nodes are registered in the graph. - for _, pair := range []struct { - key string - fn *ssa.Function - }{ - {importerKey, importerInit}, - {importedKey, importedInit}, - } { - if _, exists := functions[pair.key]; !exists { - f := a.toFunction(pair.fn) - functions[pair.key] = f - nodes[pair.key] = *f - functionOwner[pair.key] = a.owner(pair.fn) - } + // Ensure both init functions are registered in the graph. + if _, exists := functions[importerKey]; !exists { + f := a.toFunction(importerInit) + functions[importerKey] = f + nodes[importerKey] = *f + functionOwner[importerKey] = a.owner(importerInit) + } + if _, exists := functions[importedKey]; !exists { + f := a.toFunction(importedInit) + functions[importedKey] = f + nodes[importedKey] = *f + functionOwner[importedKey] = a.owner(importedInit) } // Add the synthetic reverse edge: importedInit ← importerInit. @@ -555,7 +565,10 @@ func namedImports(pkg *packages.Package) map[string]bool { // imp.Name.Name == "_" → blank import if imp.Name == nil || imp.Name.Name != "_" { // Unquote the import path string literal. - p := strings.Trim(imp.Path.Value, `"`) + p, err := strconv.Unquote(imp.Path.Value) + if err != nil { + continue + } named[p] = true } } diff --git a/testproject/core/sideeffect/sideeffect.go b/testproject/core/sideeffect/sideeffect.go index 5748e7e..db7fbcf 100644 --- a/testproject/core/sideeffect/sideeffect.go +++ b/testproject/core/sideeffect/sideeffect.go @@ -5,5 +5,5 @@ package sideeffect var Registered bool func init() { - Registered = true + Registered = false } From 4498d471e055d6a2d078518736562e93d413f56e Mon Sep 17 00:00:00 2001 From: Bubunyo Nyavor Date: Sat, 14 Mar 2026 18:19:46 +0100 Subject: [PATCH 4/6] apply fixes from review --- pkg/analyzer/analyzer.go | 13 +++++++++++++ pkg/analyzer/analyzer_test.go | 4 ++-- pkg/impact/impact_test.go | 11 +++++------ testproject/core/sideeffect/sideeffect.go | 2 +- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 12606bf..362fe88 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -546,6 +546,19 @@ func (a *Analyzer) synthesiseBlankImportEdges( functionOwner[importedKey] = a.owner(importedInit) } + // Add the synthetic forward edge: importerInit depends on importedInit. + // This keeps Function.Deps consistent with the reverse index so that DOT + // output and transitive hashing correctly reflect blank-import dependencies. + dep := a.toDependency(importedInit) + fn := functions[importerKey] + if !hasDep(fn.Deps, importedKey) { + fn.Deps = append(fn.Deps, dep) + functions[importerKey] = fn + updated := nodes[importerKey] + updated.Deps = fn.Deps + nodes[importerKey] = updated + } + // Add the synthetic reverse edge: importedInit ← importerInit. if !hasString(reverseIndex[importedKey], importerKey) { reverseIndex[importedKey] = append(reverseIndex[importedKey], importerKey) diff --git a/pkg/analyzer/analyzer_test.go b/pkg/analyzer/analyzer_test.go index b548cd0..9f81d25 100644 --- a/pkg/analyzer/analyzer_test.go +++ b/pkg/analyzer/analyzer_test.go @@ -317,8 +317,8 @@ func TestBuildGraph_ExcludePatternsPreserveOtherFunctions(t *testing.T) { // dependency edge in the call graph so that changes to it trigger a rebuild of // the importing service. // -// This test is expected to FAIL until the analyzer synthesises a dependency -// edge from the importer to the blank-imported package's init. +// synthesiseBlankImportEdges adds a synthetic edge from the importer's init to +// the blank-imported package's init in the reverse index. func TestBuildGraph_BlankImport_SideEffectTrackedInReverseIndex(t *testing.T) { a := loadedAnalyzer(t) _, graph, err := a.BuildGraph() diff --git a/pkg/impact/impact_test.go b/pkg/impact/impact_test.go index f8b7605..2937c92 100644 --- a/pkg/impact/impact_test.go +++ b/pkg/impact/impact_test.go @@ -276,10 +276,9 @@ func TestComputeImpact_ToolsExcludedByServiceDir(t *testing.T) { // causes the importing service to be scheduled for rebuild, and does NOT cause // an unrelated service to rebuild. // -// This test is expected to FAIL until the analyzer synthesises a dependency -// edge for blank imports and ComputeImpact propagates through it. Without the -// edge, ComputeImpact finds no service reachable from sideeffect.init and the -// fallback (rebuild all) would incorrectly include svc-b. +// The graph includes a reverse-index edge from core/sideeffect.init to +// services/svc-a.main (representing svc-a's blank import of sideeffect). +// ComputeImpact must propagate through that edge and schedule only svc-a. func TestComputeImpact_BlankImportSideEffectTriggersRebuild(t *testing.T) { // svc-a blank-imports sideeffect; svc-b does not depend on it at all. // The correct behaviour: only svc-a rebuilds. @@ -295,8 +294,8 @@ func TestComputeImpact_BlankImportSideEffectTriggersRebuild(t *testing.T) { "services/svc-b.main": "services/svc-b", }, map[string][]string{ - // The edge from sideeffect.init -> svc-a.main is what the fix must add. - // Currently absent, so this test fails. + // ReverseIndex is keyed by callee; the entry below means + // svc-a.main (caller/importer) depends on sideeffect.init (callee/importee). "core/sideeffect.init": {"services/svc-a.main"}, }, ) diff --git a/testproject/core/sideeffect/sideeffect.go b/testproject/core/sideeffect/sideeffect.go index db7fbcf..5748e7e 100644 --- a/testproject/core/sideeffect/sideeffect.go +++ b/testproject/core/sideeffect/sideeffect.go @@ -5,5 +5,5 @@ package sideeffect var Registered bool func init() { - Registered = false + Registered = true } From 2bd0a55a61f1067e32f5a8747a89e1959e236b84 Mon Sep 17 00:00:00 2001 From: Bubunyo Nyavor Date: Fri, 20 Mar 2026 06:00:22 +0100 Subject: [PATCH 5/6] fix errors from review --- pkg/analyzer/analyzer.go | 17 ++++++++++++++--- pkg/analyzer/analyzer_test.go | 24 ++++++++++++++---------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 362fe88..f2b86c9 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -514,7 +514,16 @@ func (a *Analyzer) synthesiseBlankImportEdges( } importerKey := funcKey(importerInit) - for importPath, importedPkg := range pkg.Imports { + // Sort import paths so that the order in which synthetic edges are + // appended to Deps and ReverseIndex is deterministic across runs. + importPaths := make([]string, 0, len(pkg.Imports)) + for p := range pkg.Imports { + importPaths = append(importPaths, p) + } + slices.Sort(importPaths) + + for _, importPath := range importPaths { + importedPkg := pkg.Imports[importPath] if named[importPath] { continue // regular import — CHA already handles it } @@ -577,10 +586,12 @@ func namedImports(pkg *packages.Package) map[string]bool { // imp.Name == nil → default name (not blank) // imp.Name.Name == "_" → blank import if imp.Name == nil || imp.Name.Name != "_" { - // Unquote the import path string literal. + // Unquote the import path string literal. On failure + // (malformed syntax), treat the import as named so we + // never synthesise a false blank-import edge. p, err := strconv.Unquote(imp.Path.Value) if err != nil { - continue + p = strings.Trim(imp.Path.Value, `"`) } named[p] = true } diff --git a/pkg/analyzer/analyzer_test.go b/pkg/analyzer/analyzer_test.go index 9f81d25..ef5f855 100644 --- a/pkg/analyzer/analyzer_test.go +++ b/pkg/analyzer/analyzer_test.go @@ -328,27 +328,31 @@ func TestBuildGraph_BlankImport_SideEffectTrackedInReverseIndex(t *testing.T) { const serviceAPkg = "service-a" // sideeffect.init must appear in the reverse index — service-a blank-imports it. - var sideeffectKey string + // Collect all matching keys in case SSA produces more than one (e.g. init#1). + var sideeffectKeys []string for k := range graph.ReverseIndex { if strings.Contains(k, sideeffectPkg) { - sideeffectKey = k - break + sideeffectKeys = append(sideeffectKeys, k) } } - require.NotEmpty(t, sideeffectKey, + require.NotEmpty(t, sideeffectKeys, "sideeffect.init must appear in the reverse index (service-a blank-imports it)") - // service-a must be listed as a caller of sideeffect. - callers := graph.ReverseIndex[sideeffectKey] + // At least one sideeffect key must list service-a as a caller. found := false - for _, caller := range callers { - if strings.Contains(caller, serviceAPkg) { - found = true + for _, k := range sideeffectKeys { + for _, caller := range graph.ReverseIndex[k] { + if strings.Contains(caller, serviceAPkg) { + found = true + break + } + } + if found { break } } assert.True(t, found, - "service-a must appear as a caller of sideeffect via blank import; callers=%v", callers) + "service-a must appear as a caller of sideeffect via blank import; keys=%v", sideeffectKeys) } // TestBuildGraph_ToolsLoadedButNotServices verifies that when the testproject From af18d370afe2521e126fc092de003483a8f43403 Mon Sep 17 00:00:00 2001 From: Bubunyo Nyavor Date: Fri, 20 Mar 2026 06:26:27 +0100 Subject: [PATCH 6/6] add synthetic edges init calls --- pkg/analyzer/analyzer.go | 33 +++++++++++++++++++++++++++++++++ pkg/analyzer/analyzer_test.go | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index f2b86c9..bffd9bf 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -572,6 +572,39 @@ func (a *Analyzer) synthesiseBlankImportEdges( if !hasString(reverseIndex[importedKey], importerKey) { reverseIndex[importedKey] = append(reverseIndex[importedKey], importerKey) } + + // Wire each real init body (init#1, init#2, …) of the imported + // package into the reverse index pointing at the synthetic wrapper. + // + // SSA splits package initialisation into a synthetic coordinator + // (Func("init"), isSynthetic=true) that calls the real user-written + // bodies (init#1, init#2, …). CHA does not emit edges for those + // calls, so without this wiring a change detected on init#1 would + // have no path in the reverse index to reach the importing service. + // + // By adding init#1 → sideeffect.init here we complete the chain: + // init#1 → sideeffect.init → service-a.init → service-a + for name, member := range importedSSA.Members { + if !strings.HasPrefix(name, "init#") { + continue + } + realInit, ok := member.(*ssa.Function) + if !ok || realInit == nil { + continue + } + realKey := funcKey(realInit) + // Register the real init body if not already in the graph. + if _, exists := functions[realKey]; !exists { + f := a.toFunction(realInit) + functions[realKey] = f + nodes[realKey] = *f + functionOwner[realKey] = a.owner(realInit) + } + // Edge: realInit → synthetic importedInit + if !hasString(reverseIndex[realKey], importedKey) { + reverseIndex[realKey] = append(reverseIndex[realKey], importedKey) + } + } } } } diff --git a/pkg/analyzer/analyzer_test.go b/pkg/analyzer/analyzer_test.go index ef5f855..f3943c1 100644 --- a/pkg/analyzer/analyzer_test.go +++ b/pkg/analyzer/analyzer_test.go @@ -355,6 +355,40 @@ func TestBuildGraph_BlankImport_SideEffectTrackedInReverseIndex(t *testing.T) { "service-a must appear as a caller of sideeffect via blank import; keys=%v", sideeffectKeys) } +// TestBuildGraph_BlankImport_RealInitBodyInReverseIndex asserts that the +// real user-written init body (init#1 in SSA) of a blank-imported package is +// also present in the reverse index, so that a change detected on that +// function propagates to the importing service. +// +// SSA splits package initialisation into a synthetic wrapper (init, isSynthetic) +// and one or more real bodies (init#1, init#2, …). synthesiseBlankImportEdges +// must wire each init#N → synthetic init in the reverse index so that +// ComputeImpact can walk: init#1 → sideeffect.init → service-a.init → service-a. +func TestBuildGraph_BlankImport_RealInitBodyInReverseIndex(t *testing.T) { + a := loadedAnalyzer(t) + _, graph, err := a.BuildGraph() + require.NoError(t, err) + + const sideeffectPkg = "sideeffect" + + // Collect every sideeffect key that is a numbered init body (init#N). + var realInitKeys []string + for k := range graph.FunctionOwner { + if strings.Contains(k, sideeffectPkg) && strings.Contains(k, "init#") { + realInitKeys = append(realInitKeys, k) + } + } + require.NotEmpty(t, realInitKeys, + "sideeffect must have at least one real init body (init#N) in FunctionOwner") + + // Every real init body must have an entry in the reverse index so that + // impact propagation can reach the importing service. + for _, k := range realInitKeys { + assert.Contains(t, graph.ReverseIndex, k, + "real init body %q must be in ReverseIndex so changes propagate to service-a", k) + } +} + // TestBuildGraph_ToolsLoadedButNotServices verifies that when the testproject // has a tools/tool-a package (which imports a shared core module and has a // main function), the analyzer loads it as part of the graph — but that it is