Skip to content

Commit 085a7b4

Browse files
aksOpsclaude
andcommitted
feat(analyzer/linker): defensive sort at the linker boundary
Plan Phase 1.4 — even if a future Linker change accidentally re-introduces map-iteration order drift, the boundary call site sorts the result before appending into the working node/edge slices. Result.Sorted() helper added to linker.go; enrich.go applies it after every Link() call. Test: TestLinkerDeterminism_ShuffledInput shuffles the same input set with two different seeds and asserts the sorted output is byte-identical. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 32f8180 commit 085a7b4

3 files changed

Lines changed: 93 additions & 2 deletions

File tree

go/internal/analyzer/enrich.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,14 @@ func Enrich(root string, c *cache.Cache, opts EnrichOptions) (EnrichSummary, err
7070
edges := snap.Edges
7171

7272
// 1. Linkers — order matches Analyzer.java.
73+
// Plan §1.4 — Sorted() at the boundary makes the output independent of
74+
// any linker's internal iteration order.
7375
for _, l := range []linker.Linker{
7476
linker.NewTopicLinker(),
7577
linker.NewEntityLinker(),
7678
linker.NewModuleContainmentLinker(),
7779
} {
78-
r := l.Link(nodes, edges)
80+
r := l.Link(nodes, edges).Sorted()
7981
nodes = append(nodes, r.Nodes...)
8082
edges = append(edges, r.Edges...)
8183
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package linker
2+
3+
import (
4+
"math/rand"
5+
"reflect"
6+
"testing"
7+
8+
"github.com/randomcodespace/codeiq/go/internal/model"
9+
)
10+
11+
// TestLinkerDeterminism_ShuffledInput — Plan §1.6.
12+
// Same set of nodes/edges in two different orders must produce identical
13+
// sorted output through Sorted().
14+
func TestLinkerDeterminism_ShuffledInput(t *testing.T) {
15+
build := func(seed int64) Result {
16+
nodes := []*model.CodeNode{
17+
model.NewCodeNode("c", model.NodeClass, "c"),
18+
model.NewCodeNode("a", model.NodeClass, "a"),
19+
model.NewCodeNode("b", model.NodeClass, "b"),
20+
}
21+
edges := []*model.CodeEdge{
22+
model.NewCodeEdge("e3", model.EdgeCalls, "c", "a"),
23+
model.NewCodeEdge("e1", model.EdgeCalls, "a", "b"),
24+
model.NewCodeEdge("e2", model.EdgeCalls, "b", "c"),
25+
}
26+
r := rand.New(rand.NewSource(seed))
27+
r.Shuffle(len(nodes), func(i, j int) { nodes[i], nodes[j] = nodes[j], nodes[i] })
28+
r.Shuffle(len(edges), func(i, j int) { edges[i], edges[j] = edges[j], edges[i] })
29+
return Result{Nodes: nodes, Edges: edges}.Sorted()
30+
}
31+
32+
r1 := build(1)
33+
r2 := build(2)
34+
if !sameNodeIDs(r1.Nodes, r2.Nodes) {
35+
t.Errorf("node order non-deterministic: %v vs %v", nodeIDs(r1.Nodes), nodeIDs(r2.Nodes))
36+
}
37+
if !sameEdgeIDs(r1.Edges, r2.Edges) {
38+
t.Errorf("edge order non-deterministic")
39+
}
40+
if !reflect.DeepEqual(nodeIDs(r1.Nodes), []string{"a", "b", "c"}) {
41+
t.Errorf("sort order wrong: %v", nodeIDs(r1.Nodes))
42+
}
43+
}
44+
45+
func nodeIDs(ns []*model.CodeNode) []string {
46+
out := make([]string, len(ns))
47+
for i, n := range ns {
48+
out[i] = n.ID
49+
}
50+
return out
51+
}
52+
53+
func sameNodeIDs(a, b []*model.CodeNode) bool {
54+
if len(a) != len(b) {
55+
return false
56+
}
57+
for i := range a {
58+
if a[i].ID != b[i].ID {
59+
return false
60+
}
61+
}
62+
return true
63+
}
64+
65+
func sameEdgeIDs(a, b []*model.CodeEdge) bool {
66+
if len(a) != len(b) {
67+
return false
68+
}
69+
for i := range a {
70+
if a[i].ID != b[i].ID {
71+
return false
72+
}
73+
}
74+
return true
75+
}

go/internal/analyzer/linker/linker.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,28 @@
66
// Mirrors src/main/java/io/github/randomcodespace/iq/analyzer/linker/.
77
package linker
88

9-
import "github.com/randomcodespace/codeiq/go/internal/model"
9+
import (
10+
"sort"
11+
12+
"github.com/randomcodespace/codeiq/go/internal/model"
13+
)
1014

1115
// Result is the bag of new nodes + edges a linker contributes.
1216
type Result struct {
1317
Nodes []*model.CodeNode
1418
Edges []*model.CodeEdge
1519
}
1620

21+
// Sorted returns r with Nodes and Edges sorted by ID. Plan §1.4 — a
22+
// defensive wrapper applied at the linker boundary so a future linker
23+
// change can't re-introduce drift even if its internal map-iteration
24+
// order shifts.
25+
func (r Result) Sorted() Result {
26+
sort.SliceStable(r.Nodes, func(i, j int) bool { return r.Nodes[i].ID < r.Nodes[j].ID })
27+
sort.SliceStable(r.Edges, func(i, j int) bool { return r.Edges[i].ID < r.Edges[j].ID })
28+
return r
29+
}
30+
1731
// Linker mirrors the Java Linker interface. Implementations MUST be
1832
// deterministic — same input slices in must produce identical output every
1933
// time (sort any map iteration before emitting).

0 commit comments

Comments
 (0)