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
4 changes: 2 additions & 2 deletions cli/analyze.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
}
10 changes: 5 additions & 5 deletions cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,15 +45,15 @@ 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) {
result := &types.Result{
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) {
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand Down
140 changes: 139 additions & 1 deletion cli/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Comment on lines +15 to +21

Copilot AI Mar 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new output format (“dot” / formatDot) is introduced, but there are no tests exercising it (e.g., ensuring clusters/nodes/edges are emitted and that changed nodes are styled correctly). Since cli/output_test.go already covers text formatting, adding a focused unit test for DOT output would help prevent regressions.

Copilot uses AI. Check for mistakes.
default:
var marshalErr error
output, marshalErr = json.MarshalIndent(result, "", " ")
Expand Down Expand Up @@ -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.
Comment on lines +73 to +80

Copilot AI Mar 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The formatDot doc comment says the layout emits “one cluster per service that needs to be rebuilt”, but the implementation iterates over result.Impact.AffectedFunctions owners (which includes non-service owners like core modules). Please align the comment with the actual behavior (cluster per owner, with service owners highlighted when they’re in ServicesToBuild) to avoid misleading users/maintainers.

Copilot uses AI. Check for mistakes.
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
Comment on lines +112 to +120

Copilot AI Mar 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shortLabel’s comment says it keeps only the last two dot-separated segments (e.g. “package.Func”), but the code only strips path segments separated by “/” and returns the last path segment unchanged (e.g. “module-a.Process”). Update either the logic or the comment so they match.

Copilot uses AI. Check for mistakes.
}

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)
}
}
Comment on lines +164 to +177

Copilot AI Mar 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DOT output is likely nondeterministic: nodes are emitted in the order they appear in the Impact.AffectedFunctions slices (which are built via map iteration in impact.ComputeImpact), so consecutive runs can produce different node ordering within a cluster. Consider sorting the unique function list before emitting nodes to make output stable (helps diffs, caching, and testing).

Copilot uses AI. Check for mistakes.
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))
}
}
Comment on lines +185 to +203

Copilot AI Mar 8, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edge emission order is nondeterministic because it ranges over the affected map; the resulting DOT output can change between runs even when the graph is identical. Consider collecting edges into a slice, sorting, then printing to make the output stable and easier to diff/test.

Copilot uses AI. Check for mistakes.

fmt.Fprintln(sb, "}")
return sb.String()
}
Binary file added testproject/impact.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading