Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions pkg/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"path"
"path/filepath"
"slices"
"strconv"
"strings"

"golang.org/x/tools/go/callgraph"
Expand Down Expand Up @@ -196,6 +197,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 {
Expand Down Expand Up @@ -452,6 +465,174 @@ 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)

// 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)

// 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
}
if !strings.HasPrefix(importPath, a.rootModule) {
continue // only care about internal blank imports
}

importedSSA := a.prog.Package(importedPkg.Types)
if importedSSA == nil {
continue
}
importedInit := importedSSA.Func("init")
if importedInit == nil {
continue
}
importedKey := funcKey(importedInit)
Comment thread
bubunyo marked this conversation as resolved.

// 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 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)
}
Comment thread
bubunyo marked this conversation as resolved.

// 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)
}
}
}
}
}

// 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. 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 {
p = strings.Trim(imp.Path.Value, `"`)
Comment thread
bubunyo marked this conversation as resolved.
}
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
Expand Down
79 changes: 78 additions & 1 deletion pkg/analyzer/analyzer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,84 @@ 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.
//
// 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()
require.NoError(t, err)

const sideeffectPkg = "sideeffect"
const serviceAPkg = "service-a"

// sideeffect.init must appear in the reverse index — service-a blank-imports it.
// 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) {
sideeffectKeys = append(sideeffectKeys, k)
}
}
require.NotEmpty(t, sideeffectKeys,
"sideeffect.init must appear in the reverse index (service-a blank-imports it)")

// At least one sideeffect key must list service-a as a caller.
found := false
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; 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
Expand Down
40 changes: 40 additions & 0 deletions pkg/impact/impact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,46 @@ 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.
//
// 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.
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{
// 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"},
},
)
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.
Expand Down
9 changes: 9 additions & 0 deletions testproject/core/sideeffect/sideeffect.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions testproject/services/service-a/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading