diff --git a/buildgraph b/buildgraph new file mode 100755 index 0000000..9797337 Binary files /dev/null and b/buildgraph differ diff --git a/cli/graph.go b/cli/graph.go new file mode 100644 index 0000000..1c0e9d7 --- /dev/null +++ b/cli/graph.go @@ -0,0 +1,61 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var graphCmd = &cobra.Command{ + Use: "graph", + Short: "Dump the full call graph as a DOT diagram", + Long: `Analyses the current source tree, builds the complete call graph, and +emits it as a Graphviz DOT digraph. + +Pipe the output into dot(1) to render an image: + + buildgraph graph | dot -Tpng -o graph.png + buildgraph graph | dot -Tsvg -o graph.svg + +The diagram groups functions into clusters by owner (service / library). +Main-package entry points are highlighted in light blue. + +Standard library functions (fmt.Println, os.Exit, etc.) are excluded by +default to keep the graph focused on your own code. Use --stdlib to include +them.`, + RunE: runGraph, +} + +func init() { + graphCmd.Flags().StringP("format", "f", "dot", "Output format (dot)") + graphCmd.Flags().StringP("output", "o", "", "Output file (default: stdout)") + graphCmd.Flags().Bool("stdlib", false, "Include standard library functions in the graph") +} + +func runGraph(cmd *cobra.Command, _ []string) error { + cfg := loadConfig() + + rootPath, err := getWorkDir() + if err != nil { + return fmt.Errorf("getting working directory: %w", err) + } + + rootModule, err := detectRootModule(rootPath) + if err != nil { + return fmt.Errorf("detecting root module: %w", err) + } + + // Parse the project without a previous baseline — we want the full current + // graph, not a diff against anything. + _, graph, _, _, _, err := parseProject(rootPath, rootModule, cfg, nil) + if err != nil { + return fmt.Errorf("analysis failed: %w", err) + } + + format, _ := cmd.Flags().GetString("format") + output, _ := cmd.Flags().GetString("output") + showStdlib, _ := cmd.Flags().GetBool("stdlib") + writeGraphOutput(graph, format, output, showStdlib) + + return nil +} diff --git a/cli/output.go b/cli/output.go index 562b2fe..efd67b9 100644 --- a/cli/output.go +++ b/cli/output.go @@ -10,6 +10,143 @@ import ( "github.com/bubunyo/buildgraph/pkg/types" ) +// isStdlib reports whether a function key belongs to the Go standard library. +// +// Stdlib keys have no "/" in their name (e.g. "fmt.Println", "os.Exit"). +// All module-internal and third-party keys contain at least one "/" as part of +// their import path (e.g. "github.com/org/pkg.Func"). +func isStdlib(key string) bool { + return !strings.Contains(key, "/") +} + +// writeGraphOutput serialises a full call graph in the requested format and +// writes it to outputPath (or stdout if outputPath is empty). +// Currently only "dot" is supported; any other value is treated as "dot". +func writeGraphOutput(graph *types.CallGraph, format, outputPath string, showStdlib bool) { + var output []byte + // Only dot is supported for now; treat anything else as dot too. + _ = format + output = []byte(formatFullDot(graph, showStdlib)) + + if outputPath != "" { + if err := os.WriteFile(outputPath, output, 0644); err != nil { + fmt.Fprintf(os.Stderr, "failed to write output: %v\n", err) + os.Exit(1) + } + return + } + fmt.Println(string(output)) +} + +// formatFullDot renders the entire call graph as a Graphviz DOT digraph. +// +// Unlike formatDot (which shows only the impact of a change), this function +// dumps every function in the graph so you can visualise the full call +// structure: +// +// - One subgraph cluster per owner (service / tool / library), sorted +// alphabetically for deterministic output. +// - Main-package entry points are filled light-blue (#d0e8ff) to make them +// easy to spot. +// - All other nodes are white. +// - Every edge from node.Deps is emitted, including cross-cluster edges. +// - Node IDs are always double-quoted (same as formatDot) so the output is +// safe to pipe directly into `dot -Tpng`. +// +// When showStdlib is false (the default), stdlib dependency edges are omitted. +// Stdlib functions are identified by the absence of "/" in their key — all +// module-internal and third-party packages have at least one "/" in their +// import path, while stdlib functions like fmt.Println do not. +func formatFullDot(graph *types.CallGraph, showStdlib bool) string { + dotID := func(fn string) string { + escaped := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(fn) + return `"` + escaped + `"` + } + + // Build owner → []funcKey map from FunctionOwner, sorted for determinism. + ownerFuncs := make(map[string][]string) + for key, owner := range graph.FunctionOwner { + ownerFuncs[owner] = append(ownerFuncs[owner], key) + } + // Also ensure any node not in FunctionOwner (edge-only nodes) is captured + // under a synthetic "(unknown)" owner so it still appears in the graph. + for key := range graph.Nodes { + if _, ok := graph.FunctionOwner[key]; !ok { + ownerFuncs["(unknown)"] = append(ownerFuncs["(unknown)"], key) + } + } + + owners := make([]string, 0, len(ownerFuncs)) + for o := range ownerFuncs { + owners = append(owners, o) + } + sort.Strings(owners) + + sb := &strings.Builder{} + fmt.Fprintln(sb, "digraph buildgraph {") + fmt.Fprintln(sb, ` rankdir=LR;`) + fmt.Fprintln(sb, ` node [fontname="Helvetica", fontsize=11, style=filled, fillcolor=white];`) + fmt.Fprintln(sb, ` edge [fontsize=9];`) + fmt.Fprintln(sb) + + clusterIdx := 0 + for _, owner := range owners { + keys := ownerFuncs[owner] + if len(keys) == 0 { + continue + } + sort.Strings(keys) + + fmt.Fprintf(sb, " subgraph cluster_%d {\n", clusterIdx) + fmt.Fprintf(sb, " label=%q;\n", owner) + fmt.Fprintln(sb, ` style=rounded;`) + fmt.Fprintln(sb, ` color="#888888";`) + fmt.Fprintln(sb) + + for _, key := range keys { + id := dotID(key) + lbl := shortLabel(key) + node, inNodes := graph.Nodes[key] + if inNodes && node.IsMain { + fmt.Fprintf(sb, " %s [label=%q, fillcolor=\"#d0e8ff\"];\n", id, lbl) + } else { + fmt.Fprintf(sb, " %s [label=%q];\n", id, lbl) + } + } + fmt.Fprintln(sb, " }") + fmt.Fprintln(sb) + clusterIdx++ + } + + // Emit edges from Deps. When showStdlib is false, skip edges whose target + // is a stdlib function (no "/" in the key). + fmt.Fprintln(sb, " // edges") + edgesSeen := make(map[string]bool) + // Iterate in sorted key order for determinism. + nodeKeys := make([]string, 0, len(graph.Nodes)) + for k := range graph.Nodes { + nodeKeys = append(nodeKeys, k) + } + sort.Strings(nodeKeys) + for _, key := range nodeKeys { + node := graph.Nodes[key] + for _, dep := range node.Deps { + if !showStdlib && isStdlib(dep.FullName) { + continue + } + edgeKey := key + "->" + dep.FullName + if edgesSeen[edgeKey] { + continue + } + edgesSeen[edgeKey] = true + fmt.Fprintf(sb, " %s -> %s;\n", dotID(key), dotID(dep.FullName)) + } + } + + fmt.Fprintln(sb, "}") + return sb.String() +} + // writeOutput serialises result in the requested format and writes it to // outputPath (or stdout if outputPath is empty). func writeOutput(result *types.Result, graph *types.CallGraph, format, outputPath string) { @@ -70,6 +207,37 @@ func formatText(result *types.Result) string { return sb.String() } +// shortLabel strips the module path prefix from a fully-qualified Go function +// key for readability in DOT labels. +// +// Normal form: "github.com/org/pkg/sub.Func" → "sub.Func" +// Pointer form: "(*github.com/org/pkg/sub.Type).Method" → "(*sub.Type).Method" +// +// The pointer form is handled explicitly: the import path inside the parens is +// stripped by taking the last slash-separated segment, and the result is +// reassembled as "(*).". +func shortLabel(fn string) string { + if strings.HasPrefix(fn, "(*") { + // Strip leading "(*" and find the closing ")". + inner := fn[2:] // e.g. "github.com/org/pkg/sub.Type).Method" + closing := strings.Index(inner, ")") + if closing >= 0 { + typePath := inner[:closing] // e.g. "github.com/org/pkg/sub.Type" + rest := inner[closing+1:] // e.g. ".Method" + // Take the last slash-segment of typePath. + if i := strings.LastIndex(typePath, "/"); i >= 0 { + typePath = typePath[i+1:] + } + return "(*" + typePath + ")" + rest + } + } + // Normal form: take the last slash-separated segment. + if i := strings.LastIndex(fn, "/"); i >= 0 { + return fn[i+1:] + } + return fn +} + // formatDot renders the impact as a Graphviz DOT digraph. // // Layout: @@ -103,21 +271,14 @@ func formatDot(result *types.Result, graph *types.CallGraph) string { rebuiltServices[s] = true } - // dotID converts a fully-qualified function name to a safe DOT node ID. + // dotID converts a fully-qualified function name to a safe DOT node ID by + // quoting it as a DOT string literal. DOT accepts any double-quoted string + // as a valid ID, so this handles all special characters that appear in Go + // function keys: *, #, (, ), /, ., -, @, [, ], etc. + // Internal double-quotes and backslashes are escaped so the literal is valid. dotID := func(fn string) string { - r := strings.NewReplacer(".", "_", "/", "_", "-", "_", "(", "_", ")", "_") - return r.Replace(fn) - } - - // shortLabel strips the module prefix for readability. - shortLabel := func(fn string) string { - // Keep only "package.Func" — last two dot-separated segments. - parts := strings.Split(fn, "/") - if len(parts) == 0 { - return fn - } - last := parts[len(parts)-1] - return last + escaped := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(fn) + return `"` + escaped + `"` } sb := &strings.Builder{} diff --git a/cli/output_test.go b/cli/output_test.go index 69be4d2..7eb9ef0 100644 --- a/cli/output_test.go +++ b/cli/output_test.go @@ -99,6 +99,273 @@ func TestFormatDot_ServiceMarkedRebuild_WithFullPath(t *testing.T) { assert.Contains(t, out, "[rebuild]", "cluster for services/svc-a should be marked [rebuild]") } +// TestFormatDot_NodeIDsAreQuoted asserts that every node ID in the DOT output +// is a double-quoted string rather than a bare identifier. Go function keys +// contain characters that are invalid in unquoted DOT IDs (*, #, (, ), /, @) +// and must always be quoted so that `dot -Tpng` does not reject the output. +func TestFormatDot_NodeIDsAreQuoted(t *testing.T) { + // Use function keys that contain the problematic characters. + result := &types.Result{ + HasChanges: true, + Changes: []types.Change{ + {Function: "(*pkg.Type).Method", Type: "modified"}, + {Function: "pkg.init#1", Type: "modified"}, + }, + Impact: types.Impact{ + ServicesToBuild: []string{"services/svc"}, + AffectedFunctions: map[string][]string{ + "services/svc": { + "(*pkg.Type).Method", + "pkg.init#1", + "services/svc.main", + }, + }, + }, + } + graph := &types.CallGraph{ + Nodes: map[string]types.Function{ + "(*pkg.Type).Method": {FullName: "(*pkg.Type).Method"}, + "pkg.init#1": {FullName: "pkg.init#1"}, + "services/svc.main": {FullName: "services/svc.main", IsMain: true}, + }, + } + + out := formatDot(result, graph) + + // Every line that declares a node or edge must use quoted IDs. + // Quoted IDs start with '"'; bare identifiers that contain special chars + // would start directly with *, (, or a letter followed by # etc. + for _, line := range strings.Split(out, "\n") { + trimmed := strings.TrimSpace(line) + // Skip comments, subgraph lines, attribute lines, and blank lines. + if trimmed == "" || + strings.HasPrefix(trimmed, "//") || + strings.HasPrefix(trimmed, "digraph") || + strings.HasPrefix(trimmed, "subgraph") || + strings.HasPrefix(trimmed, "rankdir") || + strings.HasPrefix(trimmed, "node ") || + strings.HasPrefix(trimmed, "edge ") || + strings.HasPrefix(trimmed, "label=") || + strings.HasPrefix(trimmed, "style=") || + strings.HasPrefix(trimmed, "color=") || + strings.HasPrefix(trimmed, "{") || + strings.HasPrefix(trimmed, "}") { + continue + } + // Any remaining non-blank line that references a node ID must start + // with a double-quote (quoted identifier). + assert.True(t, strings.HasPrefix(trimmed, `"`), + "expected quoted node ID on line: %q", trimmed) + } + + // Spot-check: the raw special characters must not appear unquoted. + // They may appear inside quoted strings, but not as bare tokens. + // We verify by checking the output is accepted as valid DOT syntax — + // proxy this by ensuring no bare `*` or `#` appears at the start of a token. + assert.NotContains(t, out, "\n *", "bare * must not start a node ID line") + assert.NotContains(t, out, "\n *", "bare * must not start a node ID line inside cluster") +} + +// TestShortLabel_NormalForm checks that a plain fully-qualified function name +// loses its import path prefix and retains only "pkg.Func". +func TestShortLabel_NormalForm(t *testing.T) { + assert.Equal(t, "collision.Run", shortLabel("github.com/org/repo/collision.Run")) + assert.Equal(t, "sub.Func", shortLabel("github.com/org/pkg/sub.Func")) + assert.Equal(t, "pkg.Func", shortLabel("pkg.Func")) // no slash — returned as-is + assert.Equal(t, "sideeffect.init#1", shortLabel("github.com/org/repo/core/sideeffect.init#1")) +} + +// TestShortLabel_PointerReceiverForm checks that pointer-receiver method keys +// are rendered correctly. The import path inside the parens must be stripped +// while keeping the leading "(*" and closing ")." intact. +// +// Regression: the previous split-on-"/" approach produced "collision.A).Run" +// (dropping the "(*" prefix) instead of "(*collision.A).Run". +func TestShortLabel_PointerReceiverForm(t *testing.T) { + assert.Equal(t, "(*collision.A).Run", + shortLabel("(*github.com/org/repo/collision.A).Run")) + assert.Equal(t, "(*sub.Type).Method", + shortLabel("(*github.com/org/pkg/sub.Type).Method")) + // No path prefix inside the parens — returned unchanged inside parens. + assert.Equal(t, "(*pkg.Type).Method", + shortLabel("(*pkg.Type).Method")) +} + +// ── formatFullDot ───────────────────────────────────────────────────────────── + +func fullDotGraph() *types.CallGraph { + return &types.CallGraph{ + Nodes: map[string]types.Function{ + "github.com/org/repo/services/svc-a.main": { + FullName: "github.com/org/repo/services/svc-a.main", + IsMain: true, + Deps: []types.Dependency{ + {FullName: "github.com/org/repo/core/lib.Process"}, + }, + }, + "github.com/org/repo/core/lib.Process": { + FullName: "github.com/org/repo/core/lib.Process", + Deps: []types.Dependency{ + {FullName: "github.com/org/repo/core/lib.Helper"}, + }, + }, + "github.com/org/repo/core/lib.Helper": { + FullName: "github.com/org/repo/core/lib.Helper", + }, + "(*github.com/org/repo/core/lib.T).Run": { + FullName: "(*github.com/org/repo/core/lib.T).Run", + }, + }, + FunctionOwner: map[string]string{ + "github.com/org/repo/services/svc-a.main": "services/svc-a", + "github.com/org/repo/core/lib.Process": "core/lib", + "github.com/org/repo/core/lib.Helper": "core/lib", + "(*github.com/org/repo/core/lib.T).Run": "core/lib", + }, + } +} + +// TestFormatFullDot_AllNodesPresent asserts that every key in graph.Nodes +// appears as a quoted node ID somewhere in the DOT output. +func TestFormatFullDot_AllNodesPresent(t *testing.T) { + graph := fullDotGraph() + out := formatFullDot(graph, false) + + for key := range graph.Nodes { + escaped := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(key) + quotedID := `"` + escaped + `"` + assert.Contains(t, out, quotedID, + "node %q must appear as a quoted ID in the DOT output", key) + } +} + +// TestFormatFullDot_EdgesEmitted asserts that every Deps entry in graph.Nodes +// produces a corresponding "->" edge line in the DOT output. +func TestFormatFullDot_EdgesEmitted(t *testing.T) { + graph := fullDotGraph() + out := formatFullDot(graph, false) + + // svc-a.main → lib.Process (cross-cluster edge) + assert.Contains(t, out, + `"github.com/org/repo/services/svc-a.main" -> "github.com/org/repo/core/lib.Process"`, + "cross-cluster edge svc-a.main → lib.Process must be present") + + // lib.Process → lib.Helper (intra-cluster edge) + assert.Contains(t, out, + `"github.com/org/repo/core/lib.Process" -> "github.com/org/repo/core/lib.Helper"`, + "intra-cluster edge lib.Process → lib.Helper must be present") +} + +// TestFormatFullDot_ClustersPerOwner asserts that there is exactly one +// subgraph cluster per distinct owner in graph.FunctionOwner. +func TestFormatFullDot_ClustersPerOwner(t *testing.T) { + graph := fullDotGraph() + out := formatFullDot(graph, false) + + // Two distinct owners: "services/svc-a" and "core/lib". + assert.Contains(t, out, `"services/svc-a"`, "cluster label services/svc-a must appear") + assert.Contains(t, out, `"core/lib"`, "cluster label core/lib must appear") + + // Count subgraph declarations — must be exactly 2. + count := strings.Count(out, "subgraph cluster_") + assert.Equal(t, 2, count, "expected exactly 2 subgraph clusters, got %d", count) +} + +// TestFormatFullDot_NodeIDsQuoted asserts that all node IDs in the DOT output +// are double-quoted, including keys containing special characters (* # /). +func TestFormatFullDot_NodeIDsQuoted(t *testing.T) { + graph := fullDotGraph() + out := formatFullDot(graph, false) + + for _, line := range strings.Split(out, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" || + strings.HasPrefix(trimmed, "digraph") || + strings.HasPrefix(trimmed, "subgraph") || + strings.HasPrefix(trimmed, "rankdir") || + strings.HasPrefix(trimmed, "node ") || + strings.HasPrefix(trimmed, "edge ") || + strings.HasPrefix(trimmed, "label=") || + strings.HasPrefix(trimmed, "style=") || + strings.HasPrefix(trimmed, "color=") || + strings.HasPrefix(trimmed, "//") || + strings.HasPrefix(trimmed, "{") || + strings.HasPrefix(trimmed, "}") { + continue + } + assert.True(t, strings.HasPrefix(trimmed, `"`), + "expected quoted node ID on line: %q", trimmed) + } +} + +// TestFormatFullDot_MainNodeHighlighted asserts that main-package entry points +// are rendered with the light-blue fill colour (#d0e8ff). +func TestFormatFullDot_MainNodeHighlighted(t *testing.T) { + graph := fullDotGraph() + out := formatFullDot(graph, false) + + assert.Contains(t, out, `fillcolor="#d0e8ff"`, + "main function node must use light-blue fill to mark it as an entry point") + // Non-main nodes must not carry the main highlight. + assert.NotContains(t, out, + `"github.com/org/repo/core/lib.Process" [label="lib.Process", fillcolor="#d0e8ff"`, + "non-main node must not use the main-highlight fill colour") +} + +// stdlibDotGraph returns a graph that has one internal dep and one stdlib dep +// (fmt.Println — no "/" in the key) so we can test stdlib filtering. +func stdlibDotGraph() *types.CallGraph { + return &types.CallGraph{ + Nodes: map[string]types.Function{ + "github.com/org/repo/services/svc.main": { + FullName: "github.com/org/repo/services/svc.main", + IsMain: true, + Deps: []types.Dependency{ + {FullName: "github.com/org/repo/core/lib.Process"}, + {FullName: "fmt.Println"}, // stdlib — no "/" + }, + }, + "github.com/org/repo/core/lib.Process": { + FullName: "github.com/org/repo/core/lib.Process", + Deps: []types.Dependency{ + {FullName: "fmt.Sprintf"}, // stdlib + }, + }, + }, + FunctionOwner: map[string]string{ + "github.com/org/repo/services/svc.main": "services/svc", + "github.com/org/repo/core/lib.Process": "core/lib", + }, + } +} + +// TestFormatFullDot_StdlibFilteredByDefault asserts that stdlib function edges +// (keys with no "/") are excluded when showStdlib is false. +func TestFormatFullDot_StdlibFilteredByDefault(t *testing.T) { + out := formatFullDot(stdlibDotGraph(), false) + + assert.NotContains(t, out, "fmt.Println", + "stdlib fmt.Println must be absent when showStdlib=false") + assert.NotContains(t, out, "fmt.Sprintf", + "stdlib fmt.Sprintf must be absent when showStdlib=false") + + // Internal edges must still be present. + assert.Contains(t, out, + `"github.com/org/repo/services/svc.main" -> "github.com/org/repo/core/lib.Process"`, + "internal edge must still be emitted when stdlib is filtered") +} + +// TestFormatFullDot_StdlibShownWithFlag asserts that stdlib function edges are +// included when showStdlib is true. +func TestFormatFullDot_StdlibShownWithFlag(t *testing.T) { + out := formatFullDot(stdlibDotGraph(), true) + + assert.Contains(t, out, "fmt.Println", + "stdlib fmt.Println must appear when showStdlib=true") + assert.Contains(t, out, "fmt.Sprintf", + "stdlib fmt.Sprintf must appear when showStdlib=true") +} + func TestCountFiles(t *testing.T) { fns := map[string]*types.Function{ "pkg.Foo": {File: "core/foo.go"}, diff --git a/cli/root.go b/cli/root.go index 739e4b0..fbb728d 100644 --- a/cli/root.go +++ b/cli/root.go @@ -43,7 +43,7 @@ func init() { _ = viper.BindPFlag("exclude.skip_vendor", rootCmd.PersistentFlags().Lookup("skip-vendor")) _ = viper.BindPFlag("exclude.skip_tests", rootCmd.PersistentFlags().Lookup("skip-tests")) - rootCmd.AddCommand(analyzeCmd, generateCmd, initCmd) + rootCmd.AddCommand(analyzeCmd, generateCmd, graphCmd, initCmd) } // initConfig reads buildgraph.yaml (or the path from --config) into viper. diff --git a/pkg/analyzer/analyzer.go b/pkg/analyzer/analyzer.go index bffd9bf..ac61518 100644 --- a/pkg/analyzer/analyzer.go +++ b/pkg/analyzer/analyzer.go @@ -465,22 +465,39 @@ func (a *Analyzer) buildPatterns() []string { return patterns } -// synthesiseBlankImportEdges adds synthetic reverse-index edges for blank -// imports (import _ "pkg"). +// synthesiseBlankImportEdges adds synthetic graph 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. +// leave no edge in the call graph even though their init bodies are guaranteed +// to run before the importing package initialises. 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. +// Design — direct wiring to init#N bodies: +// +// SSA represents package initialisation as: +// - A synthetic coordinator pkg.init (Func("init"), isSynthetic=true) +// - One real body per source init() or package-level var: pkg.init#1, #2, … +// +// We skip the synthetic coordinator entirely and wire the importer's init +// directly to each real init#N body of the imported package. This keeps the +// graph free of invisible intermediary nodes and makes every edge visible and +// meaningful: +// +// service-a.init → sideeffect.init#1 +// service-a.init → sideeffect.init#2 (one edge per real init body) +// +// If the imported package has no init#N bodies (nothing to run at init time) +// we skip it — there is nothing to represent. +// +// Importer side wiring (symmetric): +// - service-a.init#N → service-a.init (real importer bodies → coordinator) +// - service-a.init → service-a.main (coordinator → main, for main pkgs) +// +// Full propagation chain example: +// +// sideeffect.init#1 ← service-a.init ← service-a.main +// sideeffect.init#2 ← service-a.init func (a *Analyzer) synthesiseBlankImportEdges( functions map[string]*types.Function, nodes map[string]types.Function, @@ -514,8 +531,8 @@ func (a *Analyzer) synthesiseBlankImportEdges( } importerKey := funcKey(importerInit) - // Sort import paths so that the order in which synthetic edges are - // appended to Deps and ReverseIndex is deterministic across runs. + // Sort import paths so that append order into Deps and ReverseIndex is + // deterministic across runs. importPaths := make([]string, 0, len(pkg.Imports)) for p := range pkg.Imports { importPaths = append(importPaths, p) @@ -528,81 +545,125 @@ func (a *Analyzer) synthesiseBlankImportEdges( continue // regular import — CHA already handles it } if !strings.HasPrefix(importPath, a.rootModule) { - continue // only care about internal blank imports + continue // only track internal blank imports } importedSSA := a.prog.Package(importedPkg.Types) if importedSSA == nil { continue } - importedInit := importedSSA.Func("init") - if importedInit == nil { + + // Collect real init bodies (init#1, init#2, …) of the imported + // package, sorted for determinism. We deliberately skip the + // synthetic coordinator (importedSSA.Func("init")) — only the + // numbered bodies represent actual user code or variable init. + var realInitNames []string + for name := range importedSSA.Members { + if strings.HasPrefix(name, "init#") { + realInitNames = append(realInitNames, name) + } + } + slices.Sort(realInitNames) + + // If the imported package has no real init bodies there is nothing + // to represent — skip it entirely. + if len(realInitNames) == 0 { continue } - importedKey := funcKey(importedInit) - // Ensure both init functions are registered in the graph. + // ── Register the importer's synthetic init coordinator ──────── + 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) - } - // Wire each real init body (init#1, init#2, …) of the imported - // package into the reverse index pointing at the synthetic wrapper. + // ── Wire importer.init → each imported init#N ───────────────── // - // 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) + // For every real init body of the blank-imported package, add: + // • a forward dep on importerInit (visible in DOT / hashing) + // • a reverse edge init#N ← importerInit (drives BFS in impact) + + for _, name := range realInitNames { + realInit, ok := importedSSA.Members[name].(*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) + + // Forward dep: importerInit → realInit + dep := a.toDependency(realInit) + fn := functions[importerKey] + if !hasDep(fn.Deps, realKey) { + fn.Deps = append(fn.Deps, dep) + functions[importerKey] = fn + updated := nodes[importerKey] + updated.Deps = fn.Deps + nodes[importerKey] = updated + } + + // Reverse edge: realInit ← importerInit + if !hasString(reverseIndex[realKey], importerKey) { + reverseIndex[realKey] = append(reverseIndex[realKey], importerKey) + } + } + + // ── Importer side: wire init#N → importer.init ─────────────── + // + // If the importer itself has real init bodies, wire each one to + // the importer's synthetic coordinator so changes to those bodies + // also propagate up: + // service-a.init#N → service-a.init → service-a.main + var importerRealNames []string + for name := range importerSSA.Members { + if strings.HasPrefix(name, "init#") { + importerRealNames = append(importerRealNames, name) + } + } + slices.Sort(importerRealNames) + + for _, name := range importerRealNames { + realImporterInit, ok := importerSSA.Members[name].(*ssa.Function) + if !ok || realImporterInit == nil { + continue + } + realImporterKey := funcKey(realImporterInit) + if _, exists := functions[realImporterKey]; !exists { + f := a.toFunction(realImporterInit) + functions[realImporterKey] = f + nodes[realImporterKey] = *f + functionOwner[realImporterKey] = a.owner(realImporterInit) + } + if !hasString(reverseIndex[realImporterKey], importerKey) { + reverseIndex[realImporterKey] = append(reverseIndex[realImporterKey], importerKey) + } + } + + // ── Wire importer.init → importer.main (main packages only) ── + // + // Without this edge the BFS would stop at service-a.init and never + // reach service-a.main. + importerMain := importerSSA.Func("main") + if importerMain != nil && importerMain.Package() != nil && + importerMain.Package().Pkg.Name() == "main" { + importerMainKey := funcKey(importerMain) + if _, exists := functions[importerMainKey]; !exists { + f := a.toFunction(importerMain) + functions[importerMainKey] = f + nodes[importerMainKey] = *f + functionOwner[importerMainKey] = a.owner(importerMain) + } + if !hasString(reverseIndex[importerKey], importerMainKey) { + reverseIndex[importerKey] = append(reverseIndex[importerKey], importerMainKey) } } } diff --git a/pkg/analyzer/analyzer_test.go b/pkg/analyzer/analyzer_test.go index f3943c1..05aba9f 100644 --- a/pkg/analyzer/analyzer_test.go +++ b/pkg/analyzer/analyzer_test.go @@ -389,6 +389,166 @@ func TestBuildGraph_BlankImport_RealInitBodyInReverseIndex(t *testing.T) { } } +// TestBuildGraph_BlankImport_ImporterInitLinkedToMain asserts that the +// importer's synthetic init coordinator is wired to the importer's main +// function in the reverse index. +// +// Without this edge the BFS in ComputeImpact stops at service-a.init and +// never reaches service-a.main, so no rebuild is triggered even though the +// blank-imported package changed. +// +// Expected chain: sideeffect.init#1 → sideeffect.init → service-a.init → service-a.main +func TestBuildGraph_BlankImport_ImporterInitLinkedToMain(t *testing.T) { + a := loadedAnalyzer(t) + _, graph, err := a.BuildGraph() + require.NoError(t, err) + + const serviceAPkg = "service-a" + + // Locate the service-a synthetic init key. + serviceAInitKey := "" + for k := range graph.FunctionOwner { + if strings.Contains(k, serviceAPkg) && strings.HasSuffix(k, ".init") && !strings.Contains(k, "init#") { + serviceAInitKey = k + break + } + } + require.NotEmpty(t, serviceAInitKey, + "service-a synthetic init must be present in FunctionOwner") + + // Locate the service-a main key. + serviceAMainKey := "" + for k := range graph.Nodes { + if strings.Contains(k, serviceAPkg) && strings.HasSuffix(k, ".main") { + serviceAMainKey = k + break + } + } + require.NotEmpty(t, serviceAMainKey, + "service-a main function must be present in graph nodes") + + // The synthetic init must list service-a.main as a caller in the reverse index. + assert.Contains(t, graph.ReverseIndex, serviceAInitKey, + "service-a.init must have an entry in ReverseIndex so changes propagate to service-a.main") + assert.Contains(t, graph.ReverseIndex[serviceAInitKey], serviceAMainKey, + "service-a.main must be listed as a caller of service-a.init in the reverse index") +} + +// TestBuildGraph_BlankImport_ImporterRealInitInReverseIndex asserts that if +// the importer (service-a) has real user-written init bodies (init#N), each of +// those is wired to the importer's synthetic init coordinator in the reverse +// index — mirroring the wiring already applied for the imported (sideeffect) +// side. +// +// Note: service-a/main.go currently has no func init(), so this test verifies +// the absence of init#N keys for service-a rather than their presence. If a +// func init() is added to service-a in future, the test will correctly demand +// that those keys are wired. +func TestBuildGraph_BlankImport_ImporterRealInitInReverseIndex(t *testing.T) { + a := loadedAnalyzer(t) + _, graph, err := a.BuildGraph() + require.NoError(t, err) + + const serviceAPkg = "service-a" + + // Collect every service-a key that is a numbered init body (init#N). + var realInitKeys []string + for k := range graph.FunctionOwner { + if strings.Contains(k, serviceAPkg) && strings.Contains(k, "init#") { + realInitKeys = append(realInitKeys, k) + } + } + + // Every real init#N that exists must be wired in the reverse index. + for _, k := range realInitKeys { + assert.Contains(t, graph.ReverseIndex, k, + "service-a real init body %q must be in ReverseIndex pointing at service-a.init", k) + callers := graph.ReverseIndex[k] + found := false + for _, c := range callers { + if strings.Contains(c, serviceAPkg) && strings.HasSuffix(c, ".init") && !strings.Contains(c, "init#") { + found = true + break + } + } + assert.True(t, found, + "service-a.init must be in the callers of %q", k) + } +} + +// TestBuildGraph_BlankImport_FullChainReachesService is an integration test +// that runs a real ComputeImpact over the testproject graph and asserts that +// a simulated change to sideeffect.init#1 causes service-a to be scheduled +// for rebuild. +func TestBuildGraph_BlankImport_FullChainReachesService(t *testing.T) { + a := loadedAnalyzer(t) + fns, graph, err := a.BuildGraph() + require.NoError(t, err) + require.NoError(t, a.ComputeHashes(fns, nil, nil)) + + // Find the sideeffect init#1 key (the real init body). + sideeffectRealInitKey := "" + for k := range graph.FunctionOwner { + if strings.Contains(k, "sideeffect") && strings.Contains(k, "init#") { + sideeffectRealInitKey = k + break + } + } + require.NotEmpty(t, sideeffectRealInitKey, + "sideeffect must have a real init body (init#N) in FunctionOwner") + + impactAnalyzer := impact.NewAnalyzer(graph, []string{"services"}) + result := impactAnalyzer.ComputeImpact([]types.Change{ + {Function: sideeffectRealInitKey, Type: "modified"}, + }) + + // service-a blank-imports sideeffect — it must be scheduled for rebuild. + found := false + for _, svc := range result.ServicesToBuild { + if strings.Contains(svc, "service-a") { + found = true + break + } + } + assert.True(t, found, + "service-a must be in ServicesToBuild when sideeffect.init#1 changes; got %v", + result.ServicesToBuild) + + // service-b does not depend on sideeffect — it must not be scheduled. + for _, svc := range result.ServicesToBuild { + assert.NotContains(t, svc, "service-b", + "service-b must not rebuild: it has no dependency on sideeffect") + } +} + +// TestBuildGraph_BlankImport_SyntheticInitNotInGraph asserts that the +// synthetic SSA init coordinator of a blank-imported package does NOT appear +// in graph.Nodes or graph.ReverseIndex. +// +// synthesiseBlankImportEdges must skip importedSSA.Func("init") (which has +// Synthetic != "") and wire the importer directly to each real init#N body. +// Allowing the synthetic node into the graph would produce invisible +// intermediary nodes that cannot be attributed to any source location. +func TestBuildGraph_BlankImport_SyntheticInitNotInGraph(t *testing.T) { + a := loadedAnalyzer(t) + _, graph, err := a.BuildGraph() + require.NoError(t, err) + + // The synthetic coordinator key looks like + // "github.com/bubunyo/buildgraph/testproject/core/sideeffect.init" + // (no trailing "#N"). It must not appear in Nodes or ReverseIndex. + for k := range graph.Nodes { + if strings.Contains(k, "sideeffect") && strings.HasSuffix(k, ".init") && !strings.Contains(k, "init#") { + t.Errorf("synthetic sideeffect.init coordinator must not appear in graph.Nodes; found key %q", k) + } + } + for k := range graph.ReverseIndex { + if strings.Contains(k, "sideeffect") && strings.HasSuffix(k, ".init") && !strings.Contains(k, "init#") { + t.Errorf("synthetic sideeffect.init coordinator must not appear in graph.ReverseIndex; found key %q", 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 diff --git a/testproject/.buildgraph.bck/baseline.json b/testproject/.buildgraph.bck/baseline.json new file mode 100644 index 0000000..107fa30 --- /dev/null +++ b/testproject/.buildgraph.bck/baseline.json @@ -0,0 +1,315 @@ +{ + "version": "1.0", + "generated_at": "2026-03-08T04:17:49.934595+01:00", + "commit": "eb20a183665091ed92e356c6b034bafcd11b04c8", + "go_version": "go1.24.2", + "module_path": "github.com/bubunyo/buildgraph/testproject", + "graph": { + "nodes": { + "github.com/bubunyo/buildgraph/testproject/core/module-a.Fetch": { + "name": "Fetch", + "full_name": "github.com/bubunyo/buildgraph/testproject/core/module-a.Fetch", + "package": "github.com/bubunyo/buildgraph/testproject/core/module-a", + "file": "core/module-a/foo.go", + "start_line": 12, + "end_line": 12, + "is_exported": true, + "is_main": false, + "ast_hash": "", + "transitive_hash": "", + "deps": null + }, + "github.com/bubunyo/buildgraph/testproject/core/module-a.Process": { + "name": "Process", + "full_name": "github.com/bubunyo/buildgraph/testproject/core/module-a.Process", + "package": "github.com/bubunyo/buildgraph/testproject/core/module-a", + "file": "core/module-a/foo.go", + "start_line": 5, + "end_line": 5, + "is_exported": true, + "is_main": false, + "ast_hash": "", + "transitive_hash": "", + "deps": [ + { + "package": { + "path": "github.com/bubunyo/buildgraph/testproject/core/module-a", + "name": "module_a", + "version": "", + "module": "github.com/bubunyo/buildgraph/testproject/core/module-a" + }, + "type": "internal", + "name": "Transform", + "full_name": "github.com/bubunyo/buildgraph/testproject/core/module-a.Transform" + }, + { + "package": { + "path": "fmt", + "name": "fmt", + "version": "", + "module": "fmt" + }, + "type": "external", + "name": "Println", + "full_name": "fmt.Println" + } + ] + }, + "github.com/bubunyo/buildgraph/testproject/core/module-a.Transform": { + "name": "Transform", + "full_name": "github.com/bubunyo/buildgraph/testproject/core/module-a.Transform", + "package": "github.com/bubunyo/buildgraph/testproject/core/module-a", + "file": "core/module-a/foo.go", + "start_line": 17, + "end_line": 17, + "is_exported": true, + "is_main": false, + "ast_hash": "", + "transitive_hash": "", + "deps": [ + { + "package": { + "path": "fmt", + "name": "fmt", + "version": "", + "module": "fmt" + }, + "type": "external", + "name": "Sprintf", + "full_name": "fmt.Sprintf" + } + ] + }, + "github.com/bubunyo/buildgraph/testproject/core/module-b.Delete": { + "name": "Delete", + "full_name": "github.com/bubunyo/buildgraph/testproject/core/module-b.Delete", + "package": "github.com/bubunyo/buildgraph/testproject/core/module-b", + "file": "core/module-b/bar.go", + "start_line": 11, + "end_line": 11, + "is_exported": true, + "is_main": false, + "ast_hash": "", + "transitive_hash": "", + "deps": [ + { + "package": { + "path": "fmt", + "name": "fmt", + "version": "", + "module": "fmt" + }, + "type": "external", + "name": "Println", + "full_name": "fmt.Println" + } + ] + }, + "github.com/bubunyo/buildgraph/testproject/core/module-b.Save": { + "name": "Save", + "full_name": "github.com/bubunyo/buildgraph/testproject/core/module-b.Save", + "package": "github.com/bubunyo/buildgraph/testproject/core/module-b", + "file": "core/module-b/bar.go", + "start_line": 5, + "end_line": 5, + "is_exported": true, + "is_main": false, + "ast_hash": "", + "transitive_hash": "", + "deps": [ + { + "package": { + "path": "fmt", + "name": "fmt", + "version": "", + "module": "fmt" + }, + "type": "external", + "name": "Println", + "full_name": "fmt.Println" + } + ] + }, + "github.com/bubunyo/buildgraph/testproject/services/service-a.main": { + "name": "main", + "full_name": "github.com/bubunyo/buildgraph/testproject/services/service-a.main", + "package": "github.com/bubunyo/buildgraph/testproject/services/service-a", + "file": "services/service-a/main.go", + "start_line": 10, + "end_line": 10, + "is_exported": false, + "is_main": true, + "ast_hash": "", + "transitive_hash": "", + "deps": [ + { + "package": { + "path": "fmt", + "name": "fmt", + "version": "", + "module": "fmt" + }, + "type": "external", + "name": "Println", + "full_name": "fmt.Println" + }, + { + "package": { + "path": "github.com/bubunyo/buildgraph/testproject/core/module-a", + "name": "module_a", + "version": "", + "module": "github.com/bubunyo/buildgraph/testproject/core/module-a" + }, + "type": "internal", + "name": "Process", + "full_name": "github.com/bubunyo/buildgraph/testproject/core/module-a.Process" + }, + { + "package": { + "path": "github.com/bubunyo/buildgraph/testproject/core/module-b", + "name": "module_b", + "version": "", + "module": "github.com/bubunyo/buildgraph/testproject/core/module-b" + }, + "type": "internal", + "name": "Save", + "full_name": "github.com/bubunyo/buildgraph/testproject/core/module-b.Save" + } + ] + }, + "github.com/bubunyo/buildgraph/testproject/services/service-b.main": { + "name": "main", + "full_name": "github.com/bubunyo/buildgraph/testproject/services/service-b.main", + "package": "github.com/bubunyo/buildgraph/testproject/services/service-b", + "file": "services/service-b/main.go", + "start_line": 9, + "end_line": 9, + "is_exported": false, + "is_main": true, + "ast_hash": "", + "transitive_hash": "", + "deps": [ + { + "package": { + "path": "fmt", + "name": "fmt", + "version": "", + "module": "fmt" + }, + "type": "external", + "name": "Println", + "full_name": "fmt.Println" + }, + { + "package": { + "path": "github.com/bubunyo/buildgraph/testproject/core/module-a", + "name": "module_a", + "version": "", + "module": "github.com/bubunyo/buildgraph/testproject/core/module-a" + }, + "type": "internal", + "name": "Fetch", + "full_name": "github.com/bubunyo/buildgraph/testproject/core/module-a.Fetch" + }, + { + "package": { + "path": "github.com/bubunyo/buildgraph/testproject/core/module-a", + "name": "module_a", + "version": "", + "module": "github.com/bubunyo/buildgraph/testproject/core/module-a" + }, + "type": "internal", + "name": "Transform", + "full_name": "github.com/bubunyo/buildgraph/testproject/core/module-a.Transform" + } + ] + } + }, + "reverse_index": { + "github.com/bubunyo/buildgraph/testproject/core/module-a.Fetch": [ + "github.com/bubunyo/buildgraph/testproject/services/service-b.main" + ], + "github.com/bubunyo/buildgraph/testproject/core/module-a.Process": [ + "github.com/bubunyo/buildgraph/testproject/services/service-a.main" + ], + "github.com/bubunyo/buildgraph/testproject/core/module-a.Transform": [ + "github.com/bubunyo/buildgraph/testproject/core/module-a.Process", + "github.com/bubunyo/buildgraph/testproject/services/service-b.main" + ], + "github.com/bubunyo/buildgraph/testproject/core/module-b.Save": [ + "github.com/bubunyo/buildgraph/testproject/services/service-a.main" + ] + }, + "function_owner": { + "github.com/bubunyo/buildgraph/testproject/core/module-a.Fetch": "core/module-a", + "github.com/bubunyo/buildgraph/testproject/core/module-a.Process": "core/module-a", + "github.com/bubunyo/buildgraph/testproject/core/module-a.Transform": "core/module-a", + "github.com/bubunyo/buildgraph/testproject/core/module-b.Delete": "core/module-b", + "github.com/bubunyo/buildgraph/testproject/core/module-b.Save": "core/module-b", + "github.com/bubunyo/buildgraph/testproject/services/service-a.main": "services/service-a", + "github.com/bubunyo/buildgraph/testproject/services/service-b.main": "services/service-b" + } + }, + "function_hashes": { + "github.com/bubunyo/buildgraph/testproject/core/module-a.Fetch": { + "ast_hash": "sha256:3021ccf5567e3c72f785030c2051fcf0b781826084b2d7682040fa1306677a22", + "transitive_hash": "sha256:d20792fcba5f1ab337e8084c060562e81531e21f93a4d2a70b98daa973f49669", + "deps_hash": "", + "external_deps": null + }, + "github.com/bubunyo/buildgraph/testproject/core/module-a.Process": { + "ast_hash": "sha256:7e3acc042a595f937d69d71d830bead9b5a8fecf80eb712fd560862b144013a9", + "transitive_hash": "sha256:cb63b3f89736f46e972dad5d870c9d436d1856307f54486458116de770ad02e2", + "deps_hash": "", + "external_deps": [ + "fmt" + ] + }, + "github.com/bubunyo/buildgraph/testproject/core/module-a.Transform": { + "ast_hash": "sha256:2479b9059a0497952bb56c013f24aa0514ee9f685be16b6920252d29c64215a3", + "transitive_hash": "sha256:62b22b56e613ee61695a05c6a8cfd481f400653e9e5c7b805d7719f8ae031b76", + "deps_hash": "", + "external_deps": [ + "fmt" + ] + }, + "github.com/bubunyo/buildgraph/testproject/core/module-b.Delete": { + "ast_hash": "sha256:32a122d9d36f00b8d0b164a36ef8240f67cd698e6bbc6783b351128483c9c389", + "transitive_hash": "sha256:4b3161289620ba7dfc7fd712a9873d62d9f7e1f11a3fa91f53f8eb2d5e855cdd", + "deps_hash": "", + "external_deps": [ + "fmt" + ] + }, + "github.com/bubunyo/buildgraph/testproject/core/module-b.Save": { + "ast_hash": "sha256:936b0ee1277d02990e33da59e15c14e4a9cb1d708d410c8ccc4b267c46e229ad", + "transitive_hash": "sha256:d8c16d7c4469d04af6032207eac425cfeac079a97fc477a998131f70ad5b701b", + "deps_hash": "", + "external_deps": [ + "fmt" + ] + }, + "github.com/bubunyo/buildgraph/testproject/services/service-a.main": { + "ast_hash": "sha256:1a597aedbc5452adebf65614dd3e540a46c94ca959fad3d20a8702ec4b6ad934", + "transitive_hash": "sha256:c0777cdd121ac0357fdfd9dd1bb37c67dcc06942e9bc2e575a45885e870f79f9", + "deps_hash": "", + "external_deps": [ + "fmt" + ] + }, + "github.com/bubunyo/buildgraph/testproject/services/service-b.main": { + "ast_hash": "sha256:9cee7f741285afcc51ac1c37c1e34fd3880420f417a3d374091911e1187b2a13", + "transitive_hash": "sha256:091b00e1706de1df7463c31567025ba5f3f831224961a8f512802adcdedc46d2", + "deps_hash": "", + "external_deps": [ + "fmt" + ] + } + }, + "external_deps": {}, + "external_deps_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "source_hashes": { + "services/service-a/main.go": "sha256:5a8f8e2cb1a473badd1aa59209f140f27a7d91522baba1143fc76c6e6fef868b", + "services/service-b/main.go": "sha256:b6b95535130fa7c255e79eeaad1b1ad4f67a64ce59be39b33f3d6a2f7d17d79e" + } +} \ No newline at end of file diff --git a/testproject/.buildgraph/baseline.json b/testproject/.buildgraph/baseline.json index 107fa30..8980255 100644 --- a/testproject/.buildgraph/baseline.json +++ b/testproject/.buildgraph/baseline.json @@ -1,18 +1,44 @@ { "version": "1.0", - "generated_at": "2026-03-08T04:17:49.934595+01:00", - "commit": "eb20a183665091ed92e356c6b034bafcd11b04c8", + "generated_at": "2026-03-25T23:33:30.11098+01:00", + "commit": "420b927f3526821aed326062cdce25457596775b", "go_version": "go1.24.2", "module_path": "github.com/bubunyo/buildgraph/testproject", "graph": { "nodes": { + "(*github.com/bubunyo/buildgraph/testproject/core/collision.A).Run": { + "name": "Run", + "full_name": "(*github.com/bubunyo/buildgraph/testproject/core/collision.A).Run", + "package": "github.com/bubunyo/buildgraph/testproject/core/collision", + "file": "core/collision/collision.go", + "start_line": 12, + "end_line": 12, + "is_exported": true, + "is_main": false, + "ast_hash": "", + "transitive_hash": "", + "deps": null + }, + "(*github.com/bubunyo/buildgraph/testproject/core/collision.B).Run": { + "name": "Run", + "full_name": "(*github.com/bubunyo/buildgraph/testproject/core/collision.B).Run", + "package": "github.com/bubunyo/buildgraph/testproject/core/collision", + "file": "core/collision/collision.go", + "start_line": 15, + "end_line": 15, + "is_exported": true, + "is_main": false, + "ast_hash": "", + "transitive_hash": "", + "deps": null + }, "github.com/bubunyo/buildgraph/testproject/core/module-a.Fetch": { "name": "Fetch", "full_name": "github.com/bubunyo/buildgraph/testproject/core/module-a.Fetch", "package": "github.com/bubunyo/buildgraph/testproject/core/module-a", "file": "core/module-a/foo.go", - "start_line": 12, - "end_line": 12, + "start_line": 11, + "end_line": 11, "is_exported": true, "is_main": false, "ast_hash": "", @@ -60,8 +86,8 @@ "full_name": "github.com/bubunyo/buildgraph/testproject/core/module-a.Transform", "package": "github.com/bubunyo/buildgraph/testproject/core/module-a", "file": "core/module-a/foo.go", - "start_line": 17, - "end_line": 17, + "start_line": 15, + "end_line": 15, "is_exported": true, "is_main": false, "ast_hash": "", @@ -85,8 +111,8 @@ "full_name": "github.com/bubunyo/buildgraph/testproject/core/module-b.Delete", "package": "github.com/bubunyo/buildgraph/testproject/core/module-b", "file": "core/module-b/bar.go", - "start_line": 11, - "end_line": 11, + "start_line": 10, + "end_line": 10, "is_exported": true, "is_main": false, "ast_hash": "", @@ -130,13 +156,75 @@ } ] }, + "github.com/bubunyo/buildgraph/testproject/core/sideeffect.init#1": { + "name": "init#1", + "full_name": "github.com/bubunyo/buildgraph/testproject/core/sideeffect.init#1", + "package": "github.com/bubunyo/buildgraph/testproject/core/sideeffect", + "file": "core/sideeffect/sideeffect.go", + "start_line": 8, + "end_line": 8, + "is_exported": false, + "is_main": false, + "ast_hash": "", + "transitive_hash": "", + "deps": null + }, + "github.com/bubunyo/buildgraph/testproject/core/sideeffect.init#2": { + "name": "init#2", + "full_name": "github.com/bubunyo/buildgraph/testproject/core/sideeffect.init#2", + "package": "github.com/bubunyo/buildgraph/testproject/core/sideeffect", + "file": "core/sideeffect/sideeffect.go", + "start_line": 12, + "end_line": 12, + "is_exported": false, + "is_main": false, + "ast_hash": "", + "transitive_hash": "", + "deps": null + }, + "github.com/bubunyo/buildgraph/testproject/services/service-a.init": { + "name": "init", + "full_name": "github.com/bubunyo/buildgraph/testproject/services/service-a.init", + "package": "github.com/bubunyo/buildgraph/testproject/services/service-a", + "file": "", + "start_line": 0, + "end_line": 0, + "is_exported": false, + "is_main": false, + "ast_hash": "", + "transitive_hash": "", + "deps": [ + { + "package": { + "path": "github.com/bubunyo/buildgraph/testproject/core/sideeffect", + "name": "sideeffect", + "version": "", + "module": "github.com/bubunyo/buildgraph/testproject/core/sideeffect" + }, + "type": "internal", + "name": "init#1", + "full_name": "github.com/bubunyo/buildgraph/testproject/core/sideeffect.init#1" + }, + { + "package": { + "path": "github.com/bubunyo/buildgraph/testproject/core/sideeffect", + "name": "sideeffect", + "version": "", + "module": "github.com/bubunyo/buildgraph/testproject/core/sideeffect" + }, + "type": "internal", + "name": "init#2", + "full_name": "github.com/bubunyo/buildgraph/testproject/core/sideeffect.init#2" + } + ] + }, "github.com/bubunyo/buildgraph/testproject/services/service-a.main": { "name": "main", "full_name": "github.com/bubunyo/buildgraph/testproject/services/service-a.main", "package": "github.com/bubunyo/buildgraph/testproject/services/service-a", "file": "services/service-a/main.go", - "start_line": 10, - "end_line": 10, + "start_line": 12, + "end_line": 12, "is_exported": false, "is_main": true, "ast_hash": "", @@ -174,6 +262,28 @@ "type": "internal", "name": "Save", "full_name": "github.com/bubunyo/buildgraph/testproject/core/module-b.Save" + }, + { + "package": { + "path": "github.com/bubunyo/buildgraph/testproject/core/collision", + "name": "collision", + "version": "", + "module": "github.com/bubunyo/buildgraph/testproject/core/collision" + }, + "type": "internal", + "name": "Run", + "full_name": "(*github.com/bubunyo/buildgraph/testproject/core/collision.A).Run" + }, + { + "package": { + "path": "github.com/bubunyo/buildgraph/testproject/core/collision", + "name": "collision", + "version": "", + "module": "github.com/bubunyo/buildgraph/testproject/core/collision" + }, + "type": "internal", + "name": "Run", + "full_name": "(*github.com/bubunyo/buildgraph/testproject/core/collision.B).Run" } ] }, @@ -226,6 +336,12 @@ } }, "reverse_index": { + "(*github.com/bubunyo/buildgraph/testproject/core/collision.A).Run": [ + "github.com/bubunyo/buildgraph/testproject/services/service-a.main" + ], + "(*github.com/bubunyo/buildgraph/testproject/core/collision.B).Run": [ + "github.com/bubunyo/buildgraph/testproject/services/service-a.main" + ], "github.com/bubunyo/buildgraph/testproject/core/module-a.Fetch": [ "github.com/bubunyo/buildgraph/testproject/services/service-b.main" ], @@ -238,44 +354,70 @@ ], "github.com/bubunyo/buildgraph/testproject/core/module-b.Save": [ "github.com/bubunyo/buildgraph/testproject/services/service-a.main" + ], + "github.com/bubunyo/buildgraph/testproject/core/sideeffect.init#1": [ + "github.com/bubunyo/buildgraph/testproject/services/service-a.init" + ], + "github.com/bubunyo/buildgraph/testproject/core/sideeffect.init#2": [ + "github.com/bubunyo/buildgraph/testproject/services/service-a.init" + ], + "github.com/bubunyo/buildgraph/testproject/services/service-a.init": [ + "github.com/bubunyo/buildgraph/testproject/services/service-a.main" ] }, "function_owner": { + "(*github.com/bubunyo/buildgraph/testproject/core/collision.A).Run": "core/collision", + "(*github.com/bubunyo/buildgraph/testproject/core/collision.B).Run": "core/collision", "github.com/bubunyo/buildgraph/testproject/core/module-a.Fetch": "core/module-a", "github.com/bubunyo/buildgraph/testproject/core/module-a.Process": "core/module-a", "github.com/bubunyo/buildgraph/testproject/core/module-a.Transform": "core/module-a", "github.com/bubunyo/buildgraph/testproject/core/module-b.Delete": "core/module-b", "github.com/bubunyo/buildgraph/testproject/core/module-b.Save": "core/module-b", + "github.com/bubunyo/buildgraph/testproject/core/sideeffect.init#1": "core/sideeffect", + "github.com/bubunyo/buildgraph/testproject/core/sideeffect.init#2": "core/sideeffect", + "github.com/bubunyo/buildgraph/testproject/services/service-a.init": "services/service-a", "github.com/bubunyo/buildgraph/testproject/services/service-a.main": "services/service-a", "github.com/bubunyo/buildgraph/testproject/services/service-b.main": "services/service-b" } }, "function_hashes": { + "(*github.com/bubunyo/buildgraph/testproject/core/collision.A).Run": { + "ast_hash": "sha256:992303ce198a718ac560cc409be0209a4981f1322600557c4187af963fbe6567", + "transitive_hash": "sha256:91d981d1dab858c0e4413a2f5d75e334b5cce9c89339bd1849f98fd011248afb", + "deps_hash": "", + "external_deps": null + }, + "(*github.com/bubunyo/buildgraph/testproject/core/collision.B).Run": { + "ast_hash": "sha256:3de085bc131d451c5fd06ac20d66e163fe7af10e19ab4fb4723ccc462bf215dd", + "transitive_hash": "sha256:9cc9fbc51e8ebdecf9ce241de25c8e3ab7183736e78f280b53d49230be0a67ba", + "deps_hash": "", + "external_deps": null + }, "github.com/bubunyo/buildgraph/testproject/core/module-a.Fetch": { - "ast_hash": "sha256:3021ccf5567e3c72f785030c2051fcf0b781826084b2d7682040fa1306677a22", - "transitive_hash": "sha256:d20792fcba5f1ab337e8084c060562e81531e21f93a4d2a70b98daa973f49669", + "ast_hash": "sha256:30ceb06f466012657dabfa116a7049c91146bb36c6cb9a7904cdb395f69e7259", + "transitive_hash": "sha256:298ebf1dd947627d60a38803df885be4f32d5cb7a2a35928566f1f46d770cd6b", "deps_hash": "", "external_deps": null }, "github.com/bubunyo/buildgraph/testproject/core/module-a.Process": { - "ast_hash": "sha256:7e3acc042a595f937d69d71d830bead9b5a8fecf80eb712fd560862b144013a9", - "transitive_hash": "sha256:cb63b3f89736f46e972dad5d870c9d436d1856307f54486458116de770ad02e2", + "ast_hash": "sha256:3ee62ce17c6a42dbfa6096cc5779feda527ef75fc5e37c696f1fe356bc89a282", + "transitive_hash": "sha256:c4825d87dfc2e803b4b45b532427921f7d02c838bb6f7a3c0dbf583506c258b4", "deps_hash": "", "external_deps": [ "fmt" ] }, "github.com/bubunyo/buildgraph/testproject/core/module-a.Transform": { - "ast_hash": "sha256:2479b9059a0497952bb56c013f24aa0514ee9f685be16b6920252d29c64215a3", - "transitive_hash": "sha256:62b22b56e613ee61695a05c6a8cfd481f400653e9e5c7b805d7719f8ae031b76", + "ast_hash": "sha256:bcedf373f9dc382a5dfd0f853807153233a002ba44d207f9812bd6bd94c1794e", + "transitive_hash": "sha256:5f7f43a618711991f4d06da4198a085e641a3c1bad87342a8af8e352cba5ca47", "deps_hash": "", "external_deps": [ "fmt" ] }, "github.com/bubunyo/buildgraph/testproject/core/module-b.Delete": { - "ast_hash": "sha256:32a122d9d36f00b8d0b164a36ef8240f67cd698e6bbc6783b351128483c9c389", - "transitive_hash": "sha256:4b3161289620ba7dfc7fd712a9873d62d9f7e1f11a3fa91f53f8eb2d5e855cdd", + "ast_hash": "sha256:2f493b90b3b1eccda25859e3e3b25c2042bba69fbf6c258a96d844262078851d", + "transitive_hash": "sha256:2e0f94d48b710de5db0de8cecb9cac3ac002ba88428d70d8848534eac7c0272c", "deps_hash": "", "external_deps": [ "fmt" @@ -289,9 +431,27 @@ "fmt" ] }, + "github.com/bubunyo/buildgraph/testproject/core/sideeffect.init#1": { + "ast_hash": "sha256:8916f6bdae69470074e4d3423ec1972108f10a395518f029c47a3770b80626ba", + "transitive_hash": "sha256:a278a8642ca9fb2ec0f6e8484ee95e420e7cafee2f0d6e36a8723cb89dd14655", + "deps_hash": "", + "external_deps": null + }, + "github.com/bubunyo/buildgraph/testproject/core/sideeffect.init#2": { + "ast_hash": "sha256:14c1048e7d24f2ce1ca49aebe2d4767057b9d3e5eb52298c74b7e68fc57b0c55", + "transitive_hash": "sha256:e0657ba8a684a545848984718df9e2a575ee5556f011c5c874eb3f53aa1b47e7", + "deps_hash": "", + "external_deps": null + }, + "github.com/bubunyo/buildgraph/testproject/services/service-a.init": { + "ast_hash": "", + "transitive_hash": "sha256:b76c420536d0b7281a659b45d1f54dd96dba1c38ae5dc103fec63aebe13500c7", + "deps_hash": "", + "external_deps": null + }, "github.com/bubunyo/buildgraph/testproject/services/service-a.main": { - "ast_hash": "sha256:1a597aedbc5452adebf65614dd3e540a46c94ca959fad3d20a8702ec4b6ad934", - "transitive_hash": "sha256:c0777cdd121ac0357fdfd9dd1bb37c67dcc06942e9bc2e575a45885e870f79f9", + "ast_hash": "sha256:3b3e32396799c131ff602785f566bc3d968e35bd7d224008faf0439e37655c50", + "transitive_hash": "sha256:3dd88b004a4adfe5562ecf30740881679101eba77f767d538a37209b36998fe0", "deps_hash": "", "external_deps": [ "fmt" @@ -299,7 +459,7 @@ }, "github.com/bubunyo/buildgraph/testproject/services/service-b.main": { "ast_hash": "sha256:9cee7f741285afcc51ac1c37c1e34fd3880420f417a3d374091911e1187b2a13", - "transitive_hash": "sha256:091b00e1706de1df7463c31567025ba5f3f831224961a8f512802adcdedc46d2", + "transitive_hash": "sha256:1f048756fb79a213e82d09be09facd1aa86d232e576c02c8e9d9c27977a48180", "deps_hash": "", "external_deps": [ "fmt" @@ -309,7 +469,8 @@ "external_deps": {}, "external_deps_hash": "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "source_hashes": { - "services/service-a/main.go": "sha256:5a8f8e2cb1a473badd1aa59209f140f27a7d91522baba1143fc76c6e6fef868b", - "services/service-b/main.go": "sha256:b6b95535130fa7c255e79eeaad1b1ad4f67a64ce59be39b33f3d6a2f7d17d79e" + "services/service-a/main.go": "sha256:67f1323291b653200f5cfa56da94dc4b698b514740257d2e9df816aee412df96", + "services/service-b/main.go": "sha256:b6b95535130fa7c255e79eeaad1b1ad4f67a64ce59be39b33f3d6a2f7d17d79e", + "services/service-c/main.go": "sha256:0759b440fff5fc41cedaa95884f1ccefd2ccabf3bb511907a7839c6e49b47e0d" } } \ No newline at end of file diff --git a/testproject/core/sideeffect/sideeffect.go b/testproject/core/sideeffect/sideeffect.go index 5748e7e..e299076 100644 --- a/testproject/core/sideeffect/sideeffect.go +++ b/testproject/core/sideeffect/sideeffect.go @@ -3,7 +3,13 @@ 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 +var Done bool func init() { Registered = true } + +func init() { + _ = 2 + Done = true +} diff --git a/testproject/graph.png b/testproject/graph.png new file mode 100644 index 0000000..6baaf16 Binary files /dev/null and b/testproject/graph.png differ diff --git a/testproject/impact.png b/testproject/impact.png index 9f3c14b..bcaf83a 100644 Binary files a/testproject/impact.png and b/testproject/impact.png differ