diff --git a/.github/workflows/buildgraph.yml b/.github/workflows/buildgraph.yml index 1b1f306..c5bcbf1 100644 --- a/.github/workflows/buildgraph.yml +++ b/.github/workflows/buildgraph.yml @@ -24,7 +24,7 @@ jobs: name: Analyze changed services runs-on: ubuntu-latest outputs: - # JSON array of service names, e.g. '["service-a","service-b"]' + # JSON array of relative service paths, e.g. '["services/service-a","services/service-b"]' services: ${{ steps.buildgraph.outputs.services }} has_changes: ${{ steps.buildgraph.outputs.has_changes }} @@ -38,6 +38,7 @@ jobs: id: buildgraph uses: ./action with: + version: source working-directory: testproject go-version-file: testproject/go.mod @@ -64,8 +65,10 @@ jobs: # ── Customize this step to match your project layout ── - name: Build ${{ matrix.service }} working-directory: testproject - run: go build -v -o bin/${{ matrix.service }} ./services/${{ matrix.service }}/... + run: | + mkdir -p bin/$(dirname ${{ matrix.service }}) + go build -v -o bin/${{ matrix.service }} ./${{ matrix.service }}/... - name: Test ${{ matrix.service }} working-directory: testproject - run: go test -v ./services/${{ matrix.service }}/... + run: go test -v ./${{ matrix.service }}/... diff --git a/action b/action index d540b64..d944555 160000 --- a/action +++ b/action @@ -1 +1 @@ -Subproject commit d540b64e26dc195256b424d21ee2f4d95faf0820 +Subproject commit d944555d26a821f2b008a2d4f09c46847e907f43 diff --git a/cli/analyze.go b/cli/analyze.go index 5caecc6..0c5eb35 100644 --- a/cli/analyze.go +++ b/cli/analyze.go @@ -56,7 +56,7 @@ func runAnalyze(cmd *cobra.Command, _ []string) error { detector := diff.NewDetector(functions, extDeps, extHash, previousBaseline) changes := detector.DetectChanges() - impactAnalyzer := impact.NewAnalyzer(graph) + impactAnalyzer := impact.NewAnalyzer(graph, cfg.Services) impactResult := impactAnalyzer.ComputeImpact(changes) previousCommit := "" diff --git a/cli/output.go b/cli/output.go index 4e8de23..562b2fe 100644 --- a/cli/output.go +++ b/cli/output.go @@ -144,17 +144,14 @@ func formatDot(result *types.Result, graph *types.CallGraph) string { // Determine cluster label — append "(rebuild)" for services being rebuilt. label := owner - // Extract the last path segment as the service short name. - parts := strings.Split(owner, "/") - shortOwner := parts[len(parts)-1] - if rebuiltServices[shortOwner] { + if rebuiltServices[owner] { label = owner + " [rebuild]" } fmt.Fprintf(sb, " subgraph cluster_%d {\n", clusterIdx) fmt.Fprintf(sb, " label=%q;\n", label) fmt.Fprintln(sb, ` style=rounded;`) - if rebuiltServices[shortOwner] { + if rebuiltServices[owner] { fmt.Fprintln(sb, ` color=red;`) } else { fmt.Fprintln(sb, ` color=orange;`) diff --git a/cli/output_test.go b/cli/output_test.go index 31e33ee..69be4d2 100644 --- a/cli/output_test.go +++ b/cli/output_test.go @@ -70,6 +70,35 @@ func TestFormatText_ServicesSorted(t *testing.T) { assert.True(t, idxA < idxB && idxB < idxC, "expected services sorted a < b < c in output:\n%s", out) } +// TestFormatDot_ServiceMarkedRebuild_WithFullPath asserts that when +// ServicesToBuild contains a full owner path (e.g. "services/svc-a"), the +// corresponding cluster in the DOT output is marked [rebuild]. +// +// This test is expected to FAIL because formatDot currently extracts only the +// last path segment (shortOwner) for the rebuiltServices lookup, so a full-path +// key like "services/svc-a" never matches. +func TestFormatDot_ServiceMarkedRebuild_WithFullPath(t *testing.T) { + result := &types.Result{ + HasChanges: true, + Changes: []types.Change{{Function: "core.Fn", Type: "modified"}}, + Impact: types.Impact{ + ServicesToBuild: []string{"services/svc-a"}, + AffectedFunctions: map[string][]string{ + "services/svc-a": {"services/svc-a.main"}, + }, + }, + } + graph := &types.CallGraph{ + Nodes: map[string]types.Function{ + "services/svc-a.main": {FullName: "services/svc-a.main", IsMain: true}, + }, + } + + out := formatDot(result, graph) + + assert.Contains(t, out, "[rebuild]", "cluster for services/svc-a should be marked [rebuild]") +} + func TestCountFiles(t *testing.T) { fns := map[string]*types.Function{ "pkg.Foo": {File: "core/foo.go"}, diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index 7e2299a..80d84c1 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -16,6 +16,7 @@ import ( "fmt" "go/token" "os" + "path" "path/filepath" "slices" "strings" @@ -44,6 +45,11 @@ type Analyzer struct { cg *callgraph.Graph allPkgs []*packages.Package + // ssaIndex is a key→*ssa.Function map built once in BuildGraph() so that + // findSSAFunc can resolve a function in O(1) instead of scanning all CHA + // nodes on every call. + ssaIndex map[string]*ssa.Function + // sourceHashCache memoizes SHA-256 digests of source files within a // single analysis run to avoid redundant disk reads. sourceHashCache map[string]string @@ -54,6 +60,7 @@ func New(cfg *config.Config, rootModule, rootPath string) *Analyzer { cfg: cfg, rootModule: rootModule, rootPath: rootPath, + ssaIndex: make(map[string]*ssa.Function), sourceHashCache: make(map[string]string), } } @@ -210,6 +217,19 @@ func (a *Analyzer) BuildGraph() (map[string]*types.Function, *types.CallGraph, e FunctionOwner: functionOwner, } + // Build the O(1) SSA index so that ComputeHashes can look up functions + // by key without scanning all CHA nodes on every call. + for fn := range a.cg.Nodes { + if fn != nil { + a.ssaIndex[funcKey(fn)] = fn + } + } + + // Apply exclude patterns: remove functions whose source file matches any + // configured glob pattern or whose file path contains /vendor/ when + // SkipVendor is enabled, keeping the graph consistent. + a.applyExcludeFilters(functions, graph) + return functions, graph, nil } @@ -318,21 +338,16 @@ func (a *Analyzer) isInternal(fn *ssa.Function) bool { return strings.HasPrefix(fn.Package().Pkg.Path(), a.rootModule) } +// owner returns the relative package path for a function, e.g. +// "services/service-a" or "core/module-a". Using the full package path +// (rather than a fixed two-component prefix) means that main packages at any +// depth in the tree are correctly identified as distinct services. func (a *Analyzer) owner(fn *ssa.Function) string { if fn.Package() == nil { return "" } pkgPath := fn.Package().Pkg.Path() - rel := strings.TrimPrefix(pkgPath, a.rootModule+"/") - // Return the top two path components (e.g. "services/service-a" or - // "core/module-a") so that individual services are distinguished from - // one another. If the package lives directly under the root (no slash), - // return the single component as-is. - parts := strings.SplitN(rel, "/", 3) - if len(parts) >= 2 { - return parts[0] + "/" + parts[1] - } - return parts[0] + return strings.TrimPrefix(pkgPath, a.rootModule+"/") } func (a *Analyzer) toFunction(fn *ssa.Function) *types.Function { @@ -402,13 +417,10 @@ func (a *Analyzer) astHash(fn *types.Function) (string, error) { return fmt.Sprintf("sha256:%x", h), nil } +// findSSAFunc returns the *ssa.Function for the given key using the pre-built +// ssaIndex for O(1) lookup. Returns nil if the key is not found. func (a *Analyzer) findSSAFunc(fullName string) *ssa.Function { - for fn := range a.cg.Nodes { - if fn != nil && funcKey(fn) == fullName { - return fn - } - } - return nil + return a.ssaIndex[fullName] } func (a *Analyzer) relPath(abs string) string { @@ -440,14 +452,98 @@ func (a *Analyzer) buildPatterns() []string { return patterns } -// funcKey returns a stable, human-readable identifier for an SSA function: +// 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 +// the reverse index and function-owner map to keep the graph consistent. +func (a *Analyzer) applyExcludeFilters(functions map[string]*types.Function, graph *types.CallGraph) { + if !a.cfg.Exclude.SkipVendor && len(a.cfg.Exclude.Patterns) == 0 { + return + } + + excluded := make(map[string]bool) + for key, fn := range functions { + if a.fileMatchesExclude(fn.File) { + excluded[key] = true + } + } + + for key := range excluded { + delete(functions, key) + delete(graph.Nodes, key) + delete(graph.FunctionOwner, key) + delete(graph.ReverseIndex, key) + } + + // Also scrub excluded keys out of callers' reverse-index slices. + for callee, callers := range graph.ReverseIndex { + filtered := callers[:0] + for _, caller := range callers { + if !excluded[caller] { + filtered = append(filtered, caller) + } + } + if len(filtered) == 0 { + delete(graph.ReverseIndex, callee) + } else { + graph.ReverseIndex[callee] = filtered + } + } +} + +// fileMatchesExclude reports whether the given relative file path should be +// excluded according to the configured SkipVendor flag and Patterns list. +// Pattern matching uses path.Match (forward-slash semantics) and also handles +// the common "**/" prefix by stripping it and matching against each path +// component suffix. +func (a *Analyzer) fileMatchesExclude(relFile string) bool { + if relFile == "" { + return false + } + // Normalise to forward slashes for cross-platform consistency. + relFile = filepath.ToSlash(relFile) + + if a.cfg.Exclude.SkipVendor && (strings.HasPrefix(relFile, "vendor/") || strings.Contains(relFile, "/vendor/")) { + return true + } + + for _, pattern := range a.cfg.Exclude.Patterns { + pattern = filepath.ToSlash(pattern) + + // Support "**/" prefix: match the pattern against every suffix of the path. + trimmed := strings.TrimPrefix(pattern, "**/") + if trimmed != pattern { + // The pattern had a "**/" prefix — check all trailing sub-paths. + parts := strings.Split(relFile, "/") + for i := range parts { + candidate := strings.Join(parts[i:], "/") + if ok, _ := path.Match(trimmed, candidate); ok { + return true + } + } + continue + } + + // Plain pattern — match against the whole relative path. + if ok, _ := path.Match(pattern, relFile); ok { + return true + } + } + return false +} + +// funcKey returns a stable, globally-unique identifier for an SSA function. +// It uses fn.String() (≡ fn.RelString(nil)) which includes the receiver type +// for methods, preventing collisions between methods of the same name on +// different receiver types within the same package. +// +// Examples: // -// "github.com/user/repo/pkg.FuncName" +// "github.com/user/repo/pkg.FuncName" // package-level function +// "(*github.com/user/repo/pkg.TypeA).Save" // pointer-receiver method +// "(github.com/user/repo/pkg.TypeB).Save" // value-receiver method func funcKey(fn *ssa.Function) string { - if fn.Package() == nil { - return fn.String() - } - return fn.Package().Pkg.Path() + "." + fn.Name() + return fn.String() } func fnPosition(fn *ssa.Function, fset *token.FileSet) (file string, start, end int) { diff --git a/pkg/analyzer/analyzer_test.go b/pkg/analyzer/analyzer_test.go index 9d77292..42723cb 100644 --- a/pkg/analyzer/analyzer_test.go +++ b/pkg/analyzer/analyzer_test.go @@ -11,6 +11,7 @@ import ( "github.com/bubunyo/buildgraph/pkg/analyzer" "github.com/bubunyo/buildgraph/pkg/config" + "github.com/bubunyo/buildgraph/pkg/impact" "github.com/bubunyo/buildgraph/pkg/types" ) @@ -225,3 +226,165 @@ func TestExtractExternalDeps_ReturnsNonEmptyHash(t *testing.T) { require.NoError(t, err) assert.NotEmpty(t, hash) } + +// ── funcKey collision ───────────────────────────────────────────────────────── + +// TestBuildGraph_ReceiverMethodsHaveDistinctKeys verifies that two methods +// with the same name on different receiver types in the same package produce +// distinct keys in the functions map. The testproject/core/collision package +// defines (*A).Run and (*B).Run for exactly this purpose. +func TestBuildGraph_ReceiverMethodsHaveDistinctKeys(t *testing.T) { + a := loadedAnalyzer(t) + + fns, _, err := a.BuildGraph() + require.NoError(t, err) + + // Collect all keys that contain the collision package and the method name "Run". + var runKeys []string + for key := range fns { + if strings.Contains(key, "collision") && strings.HasSuffix(key, ".Run") { + runKeys = append(runKeys, key) + } + } + + // We expect exactly two distinct keys: one for (*A).Run and one for (*B).Run. + require.Len(t, runKeys, 2, + "expected two distinct keys for (*A).Run and (*B).Run, got: %v", runKeys) + assert.NotEqual(t, runKeys[0], runKeys[1], + "(*A).Run and (*B).Run must not share the same funcKey") +} + +// ── Exclude patterns ───────────────────────────────────────────────────────── + +// TestBuildGraph_ExcludePatternsRemoveFunctions verifies that functions whose +// source file matches a configured exclude glob pattern are omitted from the +// returned functions map and call graph. +func TestBuildGraph_ExcludePatternsRemoveFunctions(t *testing.T) { + // The collision package lives in core/collision/collision.go. + // Excluding "**/collision.go" should remove (*A).Run and (*B).Run. + cfg := &config.Config{ + Services: []string{"services"}, + Exclude: config.ExcludeConfig{ + Patterns: []string{"**/collision.go"}, + }, + } + a := analyzer.New(cfg, "github.com/bubunyo/buildgraph/testproject", testprojectDir()) + require.NoError(t, a.Load()) + + fns, graph, err := a.BuildGraph() + require.NoError(t, err) + + for key := range fns { + assert.NotContains(t, key, "collision", + "function from excluded file must not appear in functions map: %s", key) + } + for key := range graph.Nodes { + assert.NotContains(t, key, "collision", + "function from excluded file must not appear in graph nodes: %s", key) + } +} + +// TestBuildGraph_ExcludePatternsPreserveOtherFunctions verifies that only the +// matched functions are removed and the rest of the graph is intact. +func TestBuildGraph_ExcludePatternsPreserveOtherFunctions(t *testing.T) { + cfg := &config.Config{ + Services: []string{"services"}, + Exclude: config.ExcludeConfig{ + Patterns: []string{"**/collision.go"}, + }, + } + a := analyzer.New(cfg, "github.com/bubunyo/buildgraph/testproject", testprojectDir()) + require.NoError(t, a.Load()) + + fns, _, err := a.BuildGraph() + require.NoError(t, err) + + // Functions from module-a and module-b must still be present. + found := false + for key := range fns { + if strings.Contains(key, "module") { + found = true + break + } + } + assert.True(t, found, "non-excluded functions from module-a/module-b must still be present") +} + +// ── serviceDirs integration ─────────────────────────────────────────────────── + +// 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 +// NOT treated as a deployable service when serviceDirs is restricted to +// ["services"]. +// +// service-c lives under services/ but is added to the exclude patterns, so it +// must also be absent from ServicesToBuild. +// +// This test is expected to FAIL until impact.NewAnalyzer accepts a serviceDirs +// parameter and filters the serviceSet accordingly, and until ServicesToBuild +// emits full owner paths. It asserts the currently-broken behaviour: tool-a +// appears in ServicesToBuild even though it lives under tools/, not services/. +func TestBuildGraph_ToolsLoadedButNotServices(t *testing.T) { + // Load with both "services" and "tools" so tool-a is in the graph. + // Exclude service-c so it must not appear in ServicesToBuild either. + cfg := &config.Config{ + Services: []string{"services", "tools"}, + Exclude: config.ExcludeConfig{ + Patterns: []string{"**/service-c/**"}, + }, + } + a := analyzer.New(cfg, "github.com/bubunyo/buildgraph/testproject", testprojectDir()) + require.NoError(t, a.Load()) + + _, graph, err := a.BuildGraph() + require.NoError(t, err) + + // tool-a's main must be present in the graph since we loaded tools/. + toolMainKey := "" + for key := range graph.Nodes { + if strings.Contains(key, "tool-a") && strings.HasSuffix(key, ".main") { + toolMainKey = key + break + } + } + require.NotEmpty(t, toolMainKey, "tools/tool-a main function must be present in the graph") + + // service-c must have been excluded from the graph. + for key := range graph.Nodes { + assert.NotContains(t, key, "service-c", + "service-c was excluded and must not appear in graph nodes") + } + + // Now run impact analysis restricted to serviceDirs=["services"] only. + // tool-a's owner ("tools/tool-a") must NOT appear in ServicesToBuild even + // though it has a main function reachable in the graph. + // + // Currently FAILS because impact.NewAnalyzer has no serviceDirs param and + // treats every main as a service, emitting "tool-a" (path.Base) in the result. + impactAnalyzer := impact.NewAnalyzer(graph, []string{"services"}) + + // Simulate a change to module-a which tool-a also depends on. + var moduleAFunc string + for key := range graph.Nodes { + if strings.Contains(key, "module-a") { + moduleAFunc = key + break + } + } + require.NotEmpty(t, moduleAFunc, "module-a function must exist in graph") + + result := impactAnalyzer.ComputeImpact([]types.Change{{Function: moduleAFunc, Type: "modified"}}) + + // After the fix: ServicesToBuild should contain full paths like + // "services/service-a", "services/service-b" and NOT "tools/tool-a" or + // "services/service-c" (excluded). + for _, svc := range result.ServicesToBuild { + assert.True(t, + strings.HasPrefix(svc, "services/"), + "ServicesToBuild must only contain services/ entries, got %q", svc, + ) + assert.NotContains(t, svc, "service-c", + "excluded service-c must not appear in ServicesToBuild") + } +} diff --git a/pkg/analyzer/ssa_index_test.go b/pkg/analyzer/ssa_index_test.go new file mode 100644 index 0000000..f32bc60 --- /dev/null +++ b/pkg/analyzer/ssa_index_test.go @@ -0,0 +1,95 @@ +package analyzer + +import ( + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/bubunyo/buildgraph/pkg/config" +) + +// testprojectDirInternal returns the absolute path to the testproject fixture. +// It is separate from the one in analyzer_test.go because that file lives in +// the external test package (analyzer_test) while this file is in the internal +// package (analyzer). +func testprojectDirInternal() string { + _, thisFile, _, _ := runtime.Caller(0) + repoRoot := filepath.Join(filepath.Dir(thisFile), "..", "..") + return filepath.Join(repoRoot, "testproject") +} + +func loadedAnalyzerInternal(t *testing.T) *Analyzer { + t.Helper() + cfg := &config.Config{Services: []string{"services"}} + a := New(cfg, "github.com/bubunyo/buildgraph/testproject", testprojectDirInternal()) + require.NoError(t, a.Load()) + return a +} + +// ── ssaIndex ────────────────────────────────────────────────────────────────── + +// TestSSAIndex_PopulatedAfterBuildGraph verifies that BuildGraph() populates +// ssaIndex with at least one entry. +func TestSSAIndex_PopulatedAfterBuildGraph(t *testing.T) { + a := loadedAnalyzerInternal(t) + _, _, err := a.BuildGraph() + require.NoError(t, err) + + assert.NotEmpty(t, a.ssaIndex, "ssaIndex must be non-empty after BuildGraph") +} + +// TestSSAIndex_FindSSAFunc_ReturnsNilForUnknown verifies that findSSAFunc +// returns nil when the key does not exist in the index. +func TestSSAIndex_FindSSAFunc_ReturnsNilForUnknown(t *testing.T) { + a := loadedAnalyzerInternal(t) + _, _, err := a.BuildGraph() + require.NoError(t, err) + + result := a.findSSAFunc("definitely.not.a.real.function.key") + assert.Nil(t, result, "findSSAFunc must return nil for unknown keys") +} + +// TestSSAIndex_FindSSAFunc_ReturnsCorrectFunction verifies that findSSAFunc +// returns a non-nil *ssa.Function for a key that is known to be in the graph. +func TestSSAIndex_FindSSAFunc_ReturnsCorrectFunction(t *testing.T) { + a := loadedAnalyzerInternal(t) + fns, _, err := a.BuildGraph() + require.NoError(t, err) + require.NotEmpty(t, fns) + + // Pick any key from the functions map — it must resolve via findSSAFunc. + var knownKey string + for k := range fns { + knownKey = k + break + } + + ssaFn := a.findSSAFunc(knownKey) + assert.NotNil(t, ssaFn, "findSSAFunc(%q) must return a non-nil *ssa.Function", knownKey) +} + +// TestSSAIndex_KeyConsistency verifies that every key in the functions map +// resolves to an *ssa.Function via findSSAFunc, proving the index is complete. +func TestSSAIndex_KeyConsistency(t *testing.T) { + a := loadedAnalyzerInternal(t) + fns, _, err := a.BuildGraph() + require.NoError(t, err) + + missing := 0 + for k, fn := range fns { + ssaFn := a.findSSAFunc(k) + if ssaFn == nil && fn.File == "" { + // Functions with no source file are external/synthetic wrappers that + // the CHA graph may include without a real SSA node — allow absence. + continue + } + if ssaFn == nil { + missing++ + t.Logf("findSSAFunc returned nil for key %q (File=%q)", k, fn.File) + } + } + assert.Equal(t, 0, missing, "%d functions in the map could not be resolved via findSSAFunc", missing) +} diff --git a/pkg/impact/impact.go b/pkg/impact/impact.go index 718595c..d36fee1 100644 --- a/pkg/impact/impact.go +++ b/pkg/impact/impact.go @@ -12,16 +12,54 @@ type Analyzer struct { graph *types.CallGraph functionOwners map[string]string reverseIndex map[string][]string + // serviceSet is a pre-computed set of owners (e.g. "services/service-a") + // that contain a main function, built once in NewAnalyzer for O(1) lookup. + // When serviceDirs is non-empty, only owners whose path starts with one of + // the configured directories are included. + serviceSet map[string]bool } -func NewAnalyzer(graph *types.CallGraph) *Analyzer { +// NewAnalyzer creates an impact Analyzer for the given call graph. +// +// serviceDirs is the list of directory prefixes that contain deployable +// services (e.g. ["services"]). Only main packages whose owner path starts +// with one of these prefixes are treated as services. If serviceDirs is nil +// or empty, all main packages are treated as services. +func NewAnalyzer(graph *types.CallGraph, serviceDirs []string) *Analyzer { + // Pre-compute the service set by scanning graph nodes once. + serviceSet := make(map[string]bool) + for key, fn := range graph.Nodes { + if fn.IsMain { + owner, ok := graph.FunctionOwner[key] + if !ok || owner == "" { + continue + } + if len(serviceDirs) == 0 || ownerMatchesAnyDir(owner, serviceDirs) { + serviceSet[owner] = true + } + } + } + return &Analyzer{ graph: graph, functionOwners: graph.FunctionOwner, reverseIndex: graph.ReverseIndex, + serviceSet: serviceSet, } } +// ownerMatchesAnyDir reports whether owner lives under any of the given +// directory prefixes. It matches "services/svc-a" against prefix "services" +// by checking that owner == dir or strings.HasPrefix(owner, dir+"/"). +func ownerMatchesAnyDir(owner string, dirs []string) bool { + for _, dir := range dirs { + if owner == dir || strings.HasPrefix(owner, dir+"/") { + return true + } + } + return false +} + func (a *Analyzer) ComputeImpact(changes []types.Change) types.Impact { impact := types.Impact{ AffectedFunctions: make(map[string][]string), @@ -97,14 +135,7 @@ func (a *Analyzer) ComputeImpact(changes []types.Change) types.Impact { } for svc := range serviceSet { - // Emit the short service name (last path component) rather than the - // full relative path (e.g. "service-a" instead of "services/service-a"). - parts := strings.SplitN(svc, "/", 2) - name := svc - if len(parts) == 2 { - name = parts[1] - } - impact.ServicesToBuild = append(impact.ServicesToBuild, name) + impact.ServicesToBuild = append(impact.ServicesToBuild, svc) } sort.Strings(impact.ServicesToBuild) @@ -118,12 +149,7 @@ func (a *Analyzer) ComputeImpact(changes []types.Change) types.Impact { continue } seen[owner] = true - parts := strings.SplitN(owner, "/", 2) - name := owner - if len(parts) == 2 { - name = parts[1] - } - impact.ServicesToBuild = append(impact.ServicesToBuild, name) + impact.ServicesToBuild = append(impact.ServicesToBuild, owner) } sort.Strings(impact.ServicesToBuild) } @@ -131,17 +157,8 @@ func (a *Analyzer) ComputeImpact(changes []types.Change) types.Impact { return impact } +// isService reports whether the given owner has a main function, using the +// pre-computed serviceSet for O(1) lookup. func (a *Analyzer) isService(owner string) bool { - // A service is identified by having a main function - // We check if any function in this owner has IsMain = true - for funcName, o := range a.functionOwners { - if o == owner { - if fn, exists := a.graph.Nodes[funcName]; exists { - if fn.IsMain { - return true - } - } - } - } - return false + return a.serviceSet[owner] } diff --git a/pkg/impact/impact_test.go b/pkg/impact/impact_test.go index c0c12b4..fbd0e76 100644 --- a/pkg/impact/impact_test.go +++ b/pkg/impact/impact_test.go @@ -43,7 +43,7 @@ func TestComputeImpact_NoChanges(t *testing.T) { map[string]string{"svc.main": "services/svc"}, map[string][]string{}, ) - result := NewAnalyzer(g).ComputeImpact(nil) + result := NewAnalyzer(g, nil).ComputeImpact(nil) assert.Empty(t, result.ServicesToBuild) assert.Empty(t, result.AffectedFunctions) @@ -55,9 +55,9 @@ func TestComputeImpact_DirectServiceChange(t *testing.T) { map[string]string{"services/svc.main": "services/svc"}, map[string][]string{}, ) - result := NewAnalyzer(g).ComputeImpact([]types.Change{change("services/svc.main")}) + result := NewAnalyzer(g, nil).ComputeImpact([]types.Change{change("services/svc.main")}) - assert.Contains(t, result.ServicesToBuild, "svc") + assert.Contains(t, result.ServicesToBuild, "services/svc") } func TestComputeImpact_CoreChangePropagatesToService(t *testing.T) { @@ -74,9 +74,9 @@ func TestComputeImpact_CoreChangePropagatesToService(t *testing.T) { "core.Save": {"services/svc-a.main"}, }, ) - result := NewAnalyzer(g).ComputeImpact([]types.Change{change("core.Save")}) + result := NewAnalyzer(g, nil).ComputeImpact([]types.Change{change("core.Save")}) - assert.Contains(t, result.ServicesToBuild, "svc-a") + assert.Contains(t, result.ServicesToBuild, "services/svc-a") } func TestComputeImpact_UnrelatedServiceNotAffected(t *testing.T) { @@ -95,10 +95,10 @@ func TestComputeImpact_UnrelatedServiceNotAffected(t *testing.T) { "core.Save": {"services/svc-a.main"}, }, ) - result := NewAnalyzer(g).ComputeImpact([]types.Change{change("core.Save")}) + result := NewAnalyzer(g, nil).ComputeImpact([]types.Change{change("core.Save")}) - assert.NotContains(t, result.ServicesToBuild, "svc-b") - assert.Contains(t, result.ServicesToBuild, "svc-a") + assert.NotContains(t, result.ServicesToBuild, "services/svc-b") + assert.Contains(t, result.ServicesToBuild, "services/svc-a") } func TestComputeImpact_MultiHopPropagation(t *testing.T) { @@ -118,9 +118,9 @@ func TestComputeImpact_MultiHopPropagation(t *testing.T) { "core.Mid": {"services/svc.main"}, }, ) - result := NewAnalyzer(g).ComputeImpact([]types.Change{change("core.Low")}) + result := NewAnalyzer(g, nil).ComputeImpact([]types.Change{change("core.Low")}) - assert.Contains(t, result.ServicesToBuild, "svc") + assert.Contains(t, result.ServicesToBuild, "services/svc") } func TestComputeImpact_ServicesToBuildSorted(t *testing.T) { @@ -139,9 +139,9 @@ func TestComputeImpact_ServicesToBuildSorted(t *testing.T) { "core.Fn": {"services/svc-b.main", "services/svc-a.main"}, }, ) - result := NewAnalyzer(g).ComputeImpact([]types.Change{change("core.Fn")}) + result := NewAnalyzer(g, nil).ComputeImpact([]types.Change{change("core.Fn")}) - assert.Equal(t, []string{"svc-a", "svc-b"}, result.ServicesToBuild) + assert.Equal(t, []string{"services/svc-a", "services/svc-b"}, result.ServicesToBuild) } func TestComputeImpact_FallbackBuildsAllServicesWhenCoreChanges(t *testing.T) { @@ -156,11 +156,11 @@ func TestComputeImpact_FallbackBuildsAllServicesWhenCoreChanges(t *testing.T) { }, map[string][]string{}, // no callers of core.Orphan ) - result := NewAnalyzer(g).ComputeImpact([]types.Change{change("core.Orphan")}) + result := NewAnalyzer(g, nil).ComputeImpact([]types.Change{change("core.Orphan")}) // The fallback kicks in: since no service was reached, all known services // should be included. - assert.Contains(t, result.ServicesToBuild, "svc-a") + assert.Contains(t, result.ServicesToBuild, "services/svc-a") } // TestComputeImpact_ChangedFunctionWithNoOwner covers the branch where a @@ -179,7 +179,7 @@ func TestComputeImpact_ChangedFunctionWithNoOwner(t *testing.T) { ) // Should not panic. assert.NotPanics(t, func() { - NewAnalyzer(g).ComputeImpact([]types.Change{change("orphan.Fn")}) + NewAnalyzer(g, nil).ComputeImpact([]types.Change{change("orphan.Fn")}) }) } @@ -195,8 +195,104 @@ func TestIsService_IdentifiesByMainFunction(t *testing.T) { }, map[string][]string{}, ) - a := NewAnalyzer(g) + a := NewAnalyzer(g, nil) assert.True(t, a.isService("services/svc")) assert.False(t, a.isService("core/mod")) } + +// ── serviceSet precomputation ───────────────────────────────────────────────── + +// TestNewAnalyzer_ServiceSetPrecomputed verifies that NewAnalyzer builds +// the serviceSet from graph nodes so that isService is an O(1) map lookup. +func TestNewAnalyzer_ServiceSetPrecomputed(t *testing.T) { + g := buildGraph( + map[string]bool{ + "services/svc-a.main": true, + "services/svc-b.main": true, + "core/mod.Helper": false, + }, + map[string]string{ + "services/svc-a.main": "services/svc-a", + "services/svc-b.main": "services/svc-b", + "core/mod.Helper": "core/mod", + }, + map[string][]string{}, + ) + a := NewAnalyzer(g, nil) + + // Both services must be pre-indexed. + assert.True(t, a.serviceSet["services/svc-a"], "svc-a must be in serviceSet") + assert.True(t, a.serviceSet["services/svc-b"], "svc-b must be in serviceSet") + // Non-service owners must not appear. + assert.False(t, a.serviceSet["core/mod"], "core/mod must not be in serviceSet") +} + +// TestNewAnalyzer_ServiceSet_EmptyGraphProducesEmptySet verifies that an +// empty graph does not panic and produces an empty serviceSet. +func TestNewAnalyzer_ServiceSet_EmptyGraphProducesEmptySet(t *testing.T) { + g := buildGraph(map[string]bool{}, map[string]string{}, map[string][]string{}) + a := NewAnalyzer(g, nil) + + assert.Empty(t, a.serviceSet) + assert.False(t, a.isService("services/anything")) +} + +// ── serviceDirs filtering ───────────────────────────────────────────────────── + +// TestComputeImpact_ToolsExcludedByServiceDir asserts that when serviceDirs is +// configured, only main packages under those directories appear in +// ServicesToBuild — tools/mytool must not be included even though it has a +// main function reachable from the changed function. +// +// This test is expected to FAIL until NewAnalyzer accepts a serviceDirs +// parameter and filters the serviceSet accordingly. +func TestComputeImpact_ToolsExcludedByServiceDir(t *testing.T) { + g := buildGraph( + map[string]bool{ + "core.Fn": false, + "tools/mytool.main": true, + "services/svc.main": true, + }, + map[string]string{ + "core.Fn": "core/mod", + "tools/mytool.main": "tools/mytool", + "services/svc.main": "services/svc", + }, + map[string][]string{ + "core.Fn": {"tools/mytool.main", "services/svc.main"}, + }, + ) + result := NewAnalyzer(g, []string{"services"}).ComputeImpact([]types.Change{change("core.Fn")}) + + assert.Contains(t, result.ServicesToBuild, "services/svc") + assert.NotContains(t, result.ServicesToBuild, "tools/mytool") +} + +// 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. +// +// This test is expected to FAIL until NewAnalyzer accepts a serviceDirs +// parameter and ServicesToBuild emits full owner paths instead of path.Base. +func TestComputeImpact_EmptyServiceDirsFallsBackToAllMains(t *testing.T) { + g := buildGraph( + map[string]bool{ + "core.Fn": false, + "tools/mytool.main": true, + "services/svc.main": true, + }, + map[string]string{ + "core.Fn": "core/mod", + "tools/mytool.main": "tools/mytool", + "services/svc.main": "services/svc", + }, + map[string][]string{ + "core.Fn": {"tools/mytool.main", "services/svc.main"}, + }, + ) + result := NewAnalyzer(g, nil).ComputeImpact([]types.Change{change("core.Fn")}) + + assert.Contains(t, result.ServicesToBuild, "tools/mytool") + assert.Contains(t, result.ServicesToBuild, "services/svc") +} diff --git a/pkg/storage/storage.go b/pkg/storage/storage.go index 8b47d84..1373710 100644 --- a/pkg/storage/storage.go +++ b/pkg/storage/storage.go @@ -2,12 +2,18 @@ package storage import ( "encoding/json" + "fmt" "os" "path/filepath" "github.com/bubunyo/buildgraph/pkg/types" ) +// CurrentVersion is the baseline format version this build of buildgraph +// produces and understands. LoadBaseline rejects baselines with any other +// version so that stale snapshots never silently produce wrong diffs. +const CurrentVersion = "1.0" + // Storage handles reading and writing baseline snapshots. // The path is always provided explicitly by the caller (from config or flag). type Storage struct{} @@ -30,6 +36,13 @@ func (s *Storage) LoadBaseline(path string) (*types.Baseline, error) { return nil, err } + if baseline.Version != CurrentVersion { + return nil, fmt.Errorf( + "baseline version %q is not supported (expected %q): re-run 'buildgraph generate' to create a fresh baseline", + baseline.Version, CurrentVersion, + ) + } + return &baseline, nil } diff --git a/pkg/storage/storage_test.go b/pkg/storage/storage_test.go index 9e05e87..7fc50cb 100644 --- a/pkg/storage/storage_test.go +++ b/pkg/storage/storage_test.go @@ -102,3 +102,35 @@ func TestSaveAndLoad_RoundTrip(t *testing.T) { assert.Equal(t, want.FunctionHashes["pkg.Foo"].ASTHash, got.FunctionHashes["pkg.Foo"].ASTHash) assert.Equal(t, want.SourceHashes["foo.go"], got.SourceHashes["foo.go"]) } + +// ── version validation ──────────────────────────────────────────────────────── + +// TestLoadBaseline_WrongVersion_ReturnsError verifies that loading a baseline +// whose version does not match CurrentVersion returns an error. +func TestLoadBaseline_WrongVersion_ReturnsError(t *testing.T) { + path := filepath.Join(t.TempDir(), "baseline.json") + // SaveBaseline marshals whatever Version is set on the struct, so saving + // with "0.9" produces a file that LoadBaseline must reject. + stale := &types.Baseline{Version: "0.9", Commit: "oldcommit"} + require.NoError(t, New().SaveBaseline(stale, path)) + + _, err := New().LoadBaseline(path) + require.Error(t, err) + assert.Contains(t, err.Error(), "not supported") + assert.Contains(t, err.Error(), CurrentVersion) +} + +// TestLoadBaseline_CurrentVersion_Succeeds verifies that a baseline written +// with the current version is accepted by LoadBaseline. +func TestLoadBaseline_CurrentVersion_Succeeds(t *testing.T) { + path := filepath.Join(t.TempDir(), "baseline.json") + b := &types.Baseline{Version: CurrentVersion, Commit: "abc"} + + s := New() + require.NoError(t, s.SaveBaseline(b, path)) + + got, err := s.LoadBaseline(path) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, CurrentVersion, got.Version) +} diff --git a/pkg/types/types.go b/pkg/types/types.go index 56d6675..79ee0d7 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -92,14 +92,3 @@ type Result struct { Impact Impact `json:"impact"` Debug *DebugInfo `json:"debug,omitempty"` } - -type SourceFileInfo struct { - File string `json:"file"` - Hash string `json:"hash"` - Parsed bool `json:"parsed"` -} - -type ImpactIndex struct { - FunctionToServices map[string][]string `json:"function_to_services"` - ServiceToModules map[string][]string `json:"service_to_modules"` -} diff --git a/testproject/buildgraph.yaml b/testproject/buildgraph.yaml index 7369032..d6eeddd 100644 --- a/testproject/buildgraph.yaml +++ b/testproject/buildgraph.yaml @@ -13,6 +13,7 @@ exclude: patterns: - "**/*_gen.go" - "**/mock_*.go" + - "services/service-c/*" # Path where the baseline snapshot is stored. # Add .buildgraph/ to your .gitignore. diff --git a/testproject/core/collision/collision.go b/testproject/core/collision/collision.go new file mode 100644 index 0000000..b9cd021 --- /dev/null +++ b/testproject/core/collision/collision.go @@ -0,0 +1,15 @@ +// Package collision exists solely to test that funcKey correctly distinguishes +// methods of the same name on different receiver types within the same package. +package collision + +// A is a type whose Run method must not collide with (*B).Run. +type A struct{} + +// B is a type whose Run method must not collide with (*A).Run. +type B struct{} + +// Run on A does nothing; it exists to create a same-name method collision scenario. +func (a *A) Run() {} + +// Run on B does nothing; it exists to create a same-name method collision scenario. +func (b *B) Run() {} diff --git a/testproject/services/service-a/main.go b/testproject/services/service-a/main.go index 62c6891..71d5c0d 100644 --- a/testproject/services/service-a/main.go +++ b/testproject/services/service-a/main.go @@ -3,8 +3,9 @@ package main import ( "fmt" - "github.com/bubunyo/buildgraph/testproject/core/module-a" - "github.com/bubunyo/buildgraph/testproject/core/module-b" + "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" ) func main() { @@ -12,4 +13,10 @@ func main() { result := module_a.Process("test") module_b.Save(result) + + // Exercise both collision types so they appear in the call graph. + a := &collision.A{} + b := &collision.B{} + a.Run() + b.Run() } diff --git a/testproject/services/service-c/main.go b/testproject/services/service-c/main.go new file mode 100644 index 0000000..8f270c9 --- /dev/null +++ b/testproject/services/service-c/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "fmt" + + module_a "github.com/bubunyo/buildgraph/testproject/core/module-a" +) + +func main() { + fmt.Println("Starting service-c") + + data := module_a.Fetch() + fmt.Println(module_a.Transform(data)) +} diff --git a/testproject/tools/tool-a/main.go b/testproject/tools/tool-a/main.go new file mode 100644 index 0000000..498f957 --- /dev/null +++ b/testproject/tools/tool-a/main.go @@ -0,0 +1,17 @@ +package main + +import ( + "fmt" + + module_a "github.com/bubunyo/buildgraph/testproject/core/module-a" +) + +func main() { + fmt.Println("Running tool-a") + + // tool-a uses the same shared core as services, so a change to module-a + // would propagate here too — but tool-a must never appear in + // ServicesToBuild when serviceDirs is restricted to ["services"]. + result := module_a.Process("tool-input") + fmt.Println(result) +}