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
9 changes: 6 additions & 3 deletions .github/workflows/buildgraph.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}

Expand All @@ -38,6 +38,7 @@ jobs:
id: buildgraph
uses: ./action
with:
version: source
working-directory: testproject
go-version-file: testproject/go.mod

Expand All @@ -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 }}/...
2 changes: 1 addition & 1 deletion action
Submodule action updated 2 files
+4 −4 README.md
+22 −9 action.yml
2 changes: 1 addition & 1 deletion cli/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := ""
Expand Down
7 changes: 2 additions & 5 deletions cli/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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;`)
Expand Down
29 changes: 29 additions & 0 deletions cli/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
140 changes: 118 additions & 22 deletions pkg/analyzer/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"fmt"
"go/token"
"os"
"path"
"path/filepath"
"slices"
"strings"
Expand Down Expand Up @@ -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
Expand All @@ -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),
}
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
Loading
Loading