Skip to content

Commit 265c729

Browse files
greynewellclaude
andauthored
Fix non-deterministic shard rendering (#76)
* Fix non-deterministic shard rendering in renderCallsSection Two issues caused shard files to be unnecessarily rewritten on each watch cycle: 1. The function sort lacked a tiebreaker: same-named functions (e.g. String() or Error() methods on different types in the same file) could appear in different orders between runs due to map iteration non-determinism. Added ID as a secondary sort key. 2. Callers and callees were appended in relationship processing order, which reflects the API response order and can vary between requests. Now copies are sorted by FuncID before rendering. Adds render_test.go with four tests covering the determinism guarantee, same-name function tiebreaker, basic content correctness, and the empty section case. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Add callee-ordering determinism test (CodeRabbit suggestion) Mirror of TestRenderCallsSection_Deterministic for the callee path: a caller with two callees whose relationships appear in reversed order must produce identical renderCallsSection output. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5236381 commit 265c729

File tree

2 files changed

+179
-3
lines changed

2 files changed

+179
-3
lines changed

internal/shards/render.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,30 @@ func renderCallsSection(filePath string, cache *Cache, prefix string) string {
6969
return ""
7070
}
7171

72-
sort.Slice(fns, func(i, j int) bool { return fns[i].name < fns[j].name })
72+
sort.Slice(fns, func(i, j int) bool {
73+
if fns[i].name != fns[j].name {
74+
return fns[i].name < fns[j].name
75+
}
76+
return fns[i].id < fns[j].id
77+
})
7378

7479
var lines []string
7580
lines = append(lines, fmt.Sprintf("%s [calls]", prefix))
7681
for _, fe := range fns {
77-
for _, caller := range cache.Callers[fe.id] {
82+
callers := make([]CallerRef, len(cache.Callers[fe.id]))
83+
copy(callers, cache.Callers[fe.id])
84+
sort.Slice(callers, func(i, j int) bool { return callers[i].FuncID < callers[j].FuncID })
85+
86+
callees := make([]CallerRef, len(cache.Callees[fe.id]))
87+
copy(callees, cache.Callees[fe.id])
88+
sort.Slice(callees, func(i, j int) bool { return callees[i].FuncID < callees[j].FuncID })
89+
90+
for _, caller := range callers {
7891
callerName := cache.FuncName(caller.FuncID)
7992
loc := formatLoc(caller.File, caller.Line)
8093
lines = append(lines, fmt.Sprintf("%s %s ← %s %s", prefix, fe.name, callerName, loc))
8194
}
82-
for _, callee := range cache.Callees[fe.id] {
95+
for _, callee := range callees {
8396
calleeName := cache.FuncName(callee.FuncID)
8497
loc := formatLoc(callee.File, callee.Line)
8598
lines = append(lines, fmt.Sprintf("%s %s → %s %s", prefix, fe.name, calleeName, loc))

internal/shards/render_test.go

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
package shards
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/supermodeltools/cli/internal/api"
8+
)
9+
10+
// makeRenderCache builds a Cache from a ShardIR for render tests.
11+
func makeRenderCache(ir *api.ShardIR) *Cache {
12+
c := NewCache()
13+
c.Build(ir)
14+
return c
15+
}
16+
17+
func shardIR(nodes []api.Node, rels []api.Relationship) *api.ShardIR {
18+
return &api.ShardIR{
19+
Graph: api.ShardGraph{
20+
Nodes: nodes,
21+
Relationships: rels,
22+
},
23+
}
24+
}
25+
26+
// TestRenderCallsSection_Deterministic verifies that renderCallsSection produces
27+
// the same output regardless of the order relationships were appended to the cache.
28+
// This catches non-determinism from map iteration or relationship ordering in the
29+
// API response, which would cause unnecessary shard file rewrites on each run.
30+
func TestRenderCallsSection_Deterministic(t *testing.T) {
31+
nodes := []api.Node{
32+
{ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "handle", "filePath": "src/a.go"}},
33+
{ID: "fn2", Labels: []string{"Function"}, Properties: map[string]any{"name": "parse", "filePath": "src/b.go"}},
34+
{ID: "fn3", Labels: []string{"Function"}, Properties: map[string]any{"name": "validate", "filePath": "src/c.go"}},
35+
}
36+
37+
// Build two equivalent graphs with relationships in reversed order.
38+
// If rendering is deterministic, both must produce identical output.
39+
ir1 := shardIR(nodes, []api.Relationship{
40+
{ID: "r1", Type: "calls", StartNode: "fn2", EndNode: "fn1"},
41+
{ID: "r2", Type: "calls", StartNode: "fn3", EndNode: "fn1"},
42+
})
43+
ir2 := shardIR(nodes, []api.Relationship{
44+
{ID: "r2", Type: "calls", StartNode: "fn3", EndNode: "fn1"},
45+
{ID: "r1", Type: "calls", StartNode: "fn2", EndNode: "fn1"},
46+
})
47+
48+
c1 := makeRenderCache(ir1)
49+
c2 := makeRenderCache(ir2)
50+
51+
out1 := renderCallsSection("src/a.go", c1, "//")
52+
out2 := renderCallsSection("src/a.go", c2, "//")
53+
54+
if out1 != out2 {
55+
t.Errorf("renderCallsSection output differs based on relationship order:\ngot1:\n%s\ngot2:\n%s", out1, out2)
56+
}
57+
}
58+
59+
// TestRenderCalleesSection_Deterministic mirrors TestRenderCallsSection_Deterministic
60+
// but targets the callee path: a single caller with multiple callees whose relationships
61+
// appear in reversed order must produce identical output.
62+
func TestRenderCalleesSection_Deterministic(t *testing.T) {
63+
nodes := []api.Node{
64+
{ID: "fn_caller", Labels: []string{"Function"}, Properties: map[string]any{"name": "dispatch", "filePath": "src/a.go"}},
65+
{ID: "fn_c1", Labels: []string{"Function"}, Properties: map[string]any{"name": "alpha", "filePath": "src/b.go"}},
66+
{ID: "fn_c2", Labels: []string{"Function"}, Properties: map[string]any{"name": "beta", "filePath": "src/c.go"}},
67+
}
68+
69+
ir1 := shardIR(nodes, []api.Relationship{
70+
{ID: "r1", Type: "calls", StartNode: "fn_caller", EndNode: "fn_c1"},
71+
{ID: "r2", Type: "calls", StartNode: "fn_caller", EndNode: "fn_c2"},
72+
})
73+
ir2 := shardIR(nodes, []api.Relationship{
74+
{ID: "r2", Type: "calls", StartNode: "fn_caller", EndNode: "fn_c2"},
75+
{ID: "r1", Type: "calls", StartNode: "fn_caller", EndNode: "fn_c1"},
76+
})
77+
78+
c1 := makeRenderCache(ir1)
79+
c2 := makeRenderCache(ir2)
80+
81+
out1 := renderCallsSection("src/a.go", c1, "//")
82+
out2 := renderCallsSection("src/a.go", c2, "//")
83+
84+
if out1 != out2 {
85+
t.Errorf("callee output differs based on relationship order:\ngot1:\n%s\ngot2:\n%s", out1, out2)
86+
}
87+
}
88+
89+
// TestRenderCallsSection_SameNameFunctions ensures that two functions with the same
90+
// name (but different IDs, e.g. methods on different types) are ordered by ID when
91+
// they share a name, preventing non-determinism from the unstable sort.
92+
func TestRenderCallsSection_SameNameFunctions(t *testing.T) {
93+
ir := shardIR(
94+
[]api.Node{
95+
{ID: "fn_a", Labels: []string{"Function"}, Properties: map[string]any{"name": "String", "filePath": "src/types.go"}},
96+
{ID: "fn_b", Labels: []string{"Function"}, Properties: map[string]any{"name": "String", "filePath": "src/types.go"}},
97+
{ID: "caller1", Labels: []string{"Function"}, Properties: map[string]any{"name": "callA", "filePath": "src/other.go"}},
98+
{ID: "caller2", Labels: []string{"Function"}, Properties: map[string]any{"name": "callB", "filePath": "src/other.go"}},
99+
},
100+
[]api.Relationship{
101+
{ID: "r1", Type: "calls", StartNode: "caller1", EndNode: "fn_a"},
102+
{ID: "r2", Type: "calls", StartNode: "caller2", EndNode: "fn_b"},
103+
},
104+
)
105+
106+
c := makeRenderCache(ir)
107+
108+
// Run renderCallsSection multiple times to detect non-determinism
109+
first := renderCallsSection("src/types.go", c, "//")
110+
for i := 0; i < 10; i++ {
111+
out := renderCallsSection("src/types.go", c, "//")
112+
if out != first {
113+
t.Errorf("renderCallsSection is non-deterministic (run %d differs from run 0):\nfirst:\n%s\nlater:\n%s", i+1, first, out)
114+
}
115+
}
116+
}
117+
118+
// TestRenderCallsSection_ContainsCallerAndCallee verifies basic content correctness.
119+
func TestRenderCallsSection_ContainsCallerAndCallee(t *testing.T) {
120+
ir := shardIR(
121+
[]api.Node{
122+
{ID: "fn_target", Labels: []string{"Function"}, Properties: map[string]any{"name": "processRequest", "filePath": "src/handler.go"}},
123+
{ID: "fn_caller", Labels: []string{"Function"}, Properties: map[string]any{"name": "main", "filePath": "src/main.go"}},
124+
{ID: "fn_callee", Labels: []string{"Function"}, Properties: map[string]any{"name": "validate", "filePath": "src/util.go"}},
125+
},
126+
[]api.Relationship{
127+
{ID: "r1", Type: "calls", StartNode: "fn_caller", EndNode: "fn_target"},
128+
{ID: "r2", Type: "calls", StartNode: "fn_target", EndNode: "fn_callee"},
129+
},
130+
)
131+
132+
c := makeRenderCache(ir)
133+
out := renderCallsSection("src/handler.go", c, "//")
134+
135+
if out == "" {
136+
t.Fatal("expected non-empty output for function with caller and callee")
137+
}
138+
if !strings.Contains(out, "[calls]") {
139+
t.Errorf("should contain [calls] header:\n%s", out)
140+
}
141+
if !strings.Contains(out, "processRequest ← main") {
142+
t.Errorf("should show caller relationship:\n%s", out)
143+
}
144+
if !strings.Contains(out, "processRequest → validate") {
145+
t.Errorf("should show callee relationship:\n%s", out)
146+
}
147+
}
148+
149+
// TestRenderCallsSection_EmptyWhenNoCallRelationships returns empty for a file
150+
// with functions that have no callers or callees.
151+
func TestRenderCallsSection_EmptyWhenNoCallRelationships(t *testing.T) {
152+
ir := shardIR(
153+
[]api.Node{
154+
{ID: "fn1", Labels: []string{"Function"}, Properties: map[string]any{"name": "isolated", "filePath": "src/a.go"}},
155+
},
156+
nil,
157+
)
158+
c := makeRenderCache(ir)
159+
out := renderCallsSection("src/a.go", c, "//")
160+
if out != "" {
161+
t.Errorf("expected empty output for function with no call relationships, got:\n%s", out)
162+
}
163+
}

0 commit comments

Comments
 (0)