diff --git a/cli/analyze.go b/cli/analyze.go index fb40ece..5caecc6 100644 --- a/cli/analyze.go +++ b/cli/analyze.go @@ -20,7 +20,7 @@ and outputs which services are affected by the detected changes.`, } func init() { - analyzeCmd.Flags().StringP("format", "f", "text", "Output format: text, json") + analyzeCmd.Flags().StringP("format", "f", "text", "Output format: text, json, dot") analyzeCmd.Flags().StringP("output", "o", "", "Output file (default: stdout)") analyzeCmd.Flags().BoolP("verbose", "v", false, "Include debug info in output") analyzeCmd.Flags().Bool("no-cache", false, "Ignore baseline, treat everything as new") @@ -86,7 +86,7 @@ func runAnalyze(cmd *cobra.Command, _ []string) error { format, _ := cmd.Flags().GetString("format") output, _ := cmd.Flags().GetString("output") - writeOutput(result, format, output) + writeOutput(result, graph, format, output) return nil } diff --git a/cli/cli_test.go b/cli/cli_test.go index e2c747a..c0e1ff0 100644 --- a/cli/cli_test.go +++ b/cli/cli_test.go @@ -45,7 +45,7 @@ func TestWriteOutput_JSON_ToStdout(t *testing.T) { Impact: types.Impact{ServicesToBuild: []string{}}, } // Should not panic. - assert.NotPanics(t, func() { writeOutput(result, "json", "") }) + assert.NotPanics(t, func() { writeOutput(result, nil, "json", "") }) } func TestWriteOutput_Text_ToStdout(t *testing.T) { @@ -53,7 +53,7 @@ func TestWriteOutput_Text_ToStdout(t *testing.T) { HasChanges: false, Impact: types.Impact{ServicesToBuild: []string{}}, } - assert.NotPanics(t, func() { writeOutput(result, "text", "") }) + assert.NotPanics(t, func() { writeOutput(result, nil, "text", "") }) } func TestWriteOutput_JSON_ToFile(t *testing.T) { @@ -65,7 +65,7 @@ func TestWriteOutput_JSON_ToFile(t *testing.T) { Impact: types.Impact{ServicesToBuild: []string{"service-a"}}, } - writeOutput(result, "json", path) + writeOutput(result, nil, "json", path) data, err := os.ReadFile(path) require.NoError(t, err) @@ -81,7 +81,7 @@ func TestWriteOutput_Text_ToFile(t *testing.T) { Impact: types.Impact{ServicesToBuild: []string{}}, } - writeOutput(result, "text", path) + writeOutput(result, nil, "text", path) data, err := os.ReadFile(path) require.NoError(t, err) @@ -96,7 +96,7 @@ func TestWriteOutput_UnknownFormat_FallsBackToJSON(t *testing.T) { Impact: types.Impact{ServicesToBuild: []string{}}, } - writeOutput(result, "unknown", path) + writeOutput(result, nil, "unknown", path) data, err := os.ReadFile(path) require.NoError(t, err) diff --git a/cli/output.go b/cli/output.go index 1a7fa4f..4e8de23 100644 --- a/cli/output.go +++ b/cli/output.go @@ -12,11 +12,13 @@ import ( // writeOutput serialises result in the requested format and writes it to // outputPath (or stdout if outputPath is empty). -func writeOutput(result *types.Result, format, outputPath string) { +func writeOutput(result *types.Result, graph *types.CallGraph, format, outputPath string) { var output []byte switch format { case "text": output = []byte(formatText(result)) + case "dot": + output = []byte(formatDot(result, graph)) default: var marshalErr error output, marshalErr = json.MarshalIndent(result, "", " ") @@ -67,3 +69,139 @@ func formatText(result *types.Result) string { } return sb.String() } + +// formatDot renders the impact as a Graphviz DOT digraph. +// +// Layout: +// - One cluster (subgraph) per service that needs to be rebuilt. +// - Nodes are short function names; changed functions are filled red, +// transitively-affected functions are filled orange. +// - Edges represent caller → callee relationships drawn from the call graph, +// restricted to nodes that appear in the impact set. +func formatDot(result *types.Result, graph *types.CallGraph) string { + // Index changed functions for quick lookup. + changed := make(map[string]bool, len(result.Changes)) + for _, c := range result.Changes { + changed[c.Function] = true + } + + // Collect all affected functions across all owners. + affected := make(map[string]bool) + for _, fns := range result.Impact.AffectedFunctions { + for _, fn := range fns { + affected[fn] = true + } + } + // Changed functions are implicitly affected too. + for fn := range changed { + affected[fn] = true + } + + // Build a set of services to rebuild for quick lookup. + rebuiltServices := make(map[string]bool, len(result.Impact.ServicesToBuild)) + for _, s := range result.Impact.ServicesToBuild { + rebuiltServices[s] = true + } + + // dotID converts a fully-qualified function name to a safe DOT node ID. + 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 + } + + 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) + + // Emit one cluster per owner that has affected functions. + // Sort owners for deterministic output. + owners := make([]string, 0, len(result.Impact.AffectedFunctions)) + for owner := range result.Impact.AffectedFunctions { + owners = append(owners, owner) + } + sort.Strings(owners) + + clusterIdx := 0 + for _, owner := range owners { + fns := result.Impact.AffectedFunctions[owner] + if len(fns) == 0 { + continue + } + + // 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] { + 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] { + fmt.Fprintln(sb, ` color=red;`) + } else { + fmt.Fprintln(sb, ` color=orange;`) + } + fmt.Fprintln(sb) + + seen := make(map[string]bool) + for _, fn := range fns { + if seen[fn] { + continue + } + seen[fn] = true + id := dotID(fn) + lbl := shortLabel(fn) + if changed[fn] { + fmt.Fprintf(sb, " %s [label=%q, fillcolor=\"#ff6b6b\", fontcolor=white];\n", id, lbl) + } else { + fmt.Fprintf(sb, " %s [label=%q, fillcolor=\"#ffd580\"];\n", id, lbl) + } + } + fmt.Fprintln(sb, " }") + fmt.Fprintln(sb) + clusterIdx++ + } + + // Emit edges: for every affected function, draw edges to its callees + // that are also in the affected set, using the call graph. + fmt.Fprintln(sb, " // edges") + edgesSeen := make(map[string]bool) + for fn := range affected { + node, ok := graph.Nodes[fn] + if !ok { + continue + } + for _, dep := range node.Deps { + if !affected[dep.FullName] { + continue + } + key := dotID(fn) + "->" + dotID(dep.FullName) + if edgesSeen[key] { + continue + } + edgesSeen[key] = true + fmt.Fprintf(sb, " %s -> %s;\n", dotID(fn), dotID(dep.FullName)) + } + } + + fmt.Fprintln(sb, "}") + return sb.String() +} diff --git a/testproject/impact.png b/testproject/impact.png new file mode 100644 index 0000000..9f3c14b Binary files /dev/null and b/testproject/impact.png differ