Skip to content

Commit e40df04

Browse files
ChrisJBurnsclaude
andcommitted
Trim drift walker and its tests
Simplify cycle detection: stop on first revisit instead of allowing one self-referential expansion. The "expand once" gold-plating produced a "next.<field>" entry on linked-list-shaped types, but no real CRD or runtime config has cyclic shape, and stop-on-revisit is the simpler contract for drift detection. The visited tracker becomes a map[reflect.Type]struct{} and maxStructRevisits goes away. Compress the FlattenJSONLeafFields godoc from a 9-bullet semantics list into one paragraph. Most of the bullets are now subsumed by the single "json.Marshaler => leaf, otherwise recurse on Struct/Slice/Array/Map" rule. The detailed encoding/json reference belongs in the standard library, not here. Drop redundant subtests in TestFlattenJSONLeafFields. After the json.Marshaler refactor, the json.RawMessage / thvjson.Map+Any / vmcpconfig.Duration cases all exercise the same short-circuit branch as metav1.Duration, so one Marshaler example is enough. Drop slice-of- pointer-to-struct (covered by combining the pointer-deref and slice-of- struct cases). Fold the dedicated PointerInputDereferenced test into the table. Remove TestFlattenJSONLeafFields_OutputIsSorted: the table- driven test already pins exact sorted-string equality on every case. Update the recursive-type expectation to ["name"] to lock in the new stop-on-revisit semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 376f6d6 commit e40df04

2 files changed

Lines changed: 29 additions & 107 deletions

File tree

cmd/thv-operator/internal/testutil/reflect.go

Lines changed: 16 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -17,35 +17,12 @@ import (
1717
// FlattenJSONLeafFields returns every leaf JSON field path under t as a sorted
1818
// slice of dot-delimited paths (e.g. "openTelemetry.tracing.enabled").
1919
//
20-
// Walking semantics:
21-
// - For struct fields, the JSON tag's name (before any comma) is used as the
22-
// path segment. If the tag is "-", the field is skipped. If the tag is
23-
// missing, the Go field name is used.
24-
// - Unexported fields are skipped.
25-
// - Fields with `,inline` flatten into the parent path with no prefix.
26-
// - Pointer types are dereferenced and walked.
27-
// - Struct types are recursed into.
28-
// - Slice and array types whose ELEMENT type is a struct (or pointer to a
29-
// struct) recurse into that element type, joining with the parent path.
30-
// A path like "tools.workload" therefore means "every element of the
31-
// `tools` slice has a `workload` field".
32-
// - Map types whose VALUE type is a struct (or pointer to a struct) recurse
33-
// into that value type the same way.
34-
// - Types that implement json.Marshaler are treated as leaves regardless of
35-
// their underlying kind. A custom MarshalJSON produces a JSON shape that
36-
// bears no relation to the Go field layout, so walking the fields would
37-
// emit paths that never appear in the serialized form. This handles
38-
// metav1.Time, metav1.Duration, intstr.IntOrString, resource.Quantity,
39-
// json.RawMessage, and any project-local custom-marshaled types
40-
// (e.g. pkg/json.Map, pkg/json.Any, pkg/vmcp/config.Duration) without an
41-
// explicit allow-list.
42-
// - All other primitive types (primitives, []string, map[string]string,
43-
// time.Duration which is just int64) terminate the walk and contribute
44-
// one leaf path entry via the default branch.
45-
// - Self-referential types are expanded one level (the original visit plus
46-
// one self-reference) and then truncated, to keep the walk terminating.
47-
// Subsequent levels (e.g. "next.next.<field>") are intentionally elided.
48-
// See maxStructRevisits for the controlling constant.
20+
// A leaf is any type that does not produce nested JSON keys at runtime: a
21+
// primitive, a slice/map of primitives, or a type implementing json.Marshaler
22+
// (whose MarshalJSON shape is opaque to reflection). Structs, slices/maps of
23+
// structs, and pointers to either are recursed into. Field names follow
24+
// encoding/json rules including `,inline` and anonymous-field promotion.
25+
// Self-referential types stop on revisit so the walk always terminates.
4926
//
5027
// If t is nil or, after dereferencing, is not a struct, an empty slice is
5128
// returned rather than panicking.
@@ -61,7 +38,7 @@ func FlattenJSONLeafFields(t reflect.Type) []string {
6138
}
6239

6340
leafSet := make(map[string]struct{})
64-
visited := map[reflect.Type]int{}
41+
visited := map[reflect.Type]struct{}{}
6542
recurseStruct(t, "", leafSet, visited)
6643

6744
out := make([]string, 0, len(leafSet))
@@ -96,7 +73,7 @@ var skipFieldTypes = map[reflect.Type]struct{}{
9673
// visited is the set of struct types currently on the recursion stack; it is
9774
// used to break cycles on self-referential types. Callers add t to visited
9875
// before invoking this function and remove it afterwards.
99-
func walkStruct(t reflect.Type, prefix string, leafSet map[string]struct{}, visited map[reflect.Type]int) {
76+
func walkStruct(t reflect.Type, prefix string, leafSet map[string]struct{}, visited map[reflect.Type]struct{}) {
10077
for i := 0; i < t.NumField(); i++ {
10178
field := t.Field(i)
10279
// Skip unexported fields. PkgPath is non-empty for unexported fields.
@@ -140,7 +117,7 @@ func walkStruct(t reflect.Type, prefix string, leafSet map[string]struct{}, visi
140117

141118
// walkType walks a single type with the given accumulated prefix. It either
142119
// recurses into nested structs/slices/maps or records a leaf at prefix.
143-
func walkType(t reflect.Type, prefix string, leafSet map[string]struct{}, visited map[reflect.Type]int) {
120+
func walkType(t reflect.Type, prefix string, leafSet map[string]struct{}, visited map[reflect.Type]struct{}) {
144121
// Deref pointers.
145122
for t.Kind() == reflect.Pointer {
146123
t = t.Elem()
@@ -185,25 +162,16 @@ func walkType(t reflect.Type, prefix string, leafSet map[string]struct{}, visite
185162
}
186163
}
187164

188-
// maxStructRevisits caps how many times a single struct type may appear on the
189-
// recursion stack. A value of 2 means the type may be entered at most twice
190-
// on the same path: the original entry plus one self-referential visit. This
191-
// is enough for one level of "next.<field>" expansion in cyclic types like
192-
// linked lists, while still guaranteeing the walk terminates.
193-
const maxStructRevisits = 2
194-
195165
// recurseStruct descends into a nested struct type with cycle protection.
196-
// visited holds the count of how many times each struct type currently appears
197-
// on the recursion stack. When entering t would exceed maxStructRevisits, the
198-
// walk stops silently — no leaf is recorded, on the assumption that the
199-
// useful leaves under t have already been captured by the earlier visit on
200-
// the same path.
201-
func recurseStruct(t reflect.Type, prefix string, leafSet map[string]struct{}, visited map[reflect.Type]int) {
202-
if visited[t] >= maxStructRevisits {
166+
// visited is the set of struct types currently on the recursion stack; if t
167+
// is already present, the walk stops silently to keep self-referential types
168+
// from looping forever.
169+
func recurseStruct(t reflect.Type, prefix string, leafSet map[string]struct{}, visited map[reflect.Type]struct{}) {
170+
if _, seen := visited[t]; seen {
203171
return
204172
}
205-
visited[t]++
206-
defer func() { visited[t]-- }()
173+
visited[t] = struct{}{}
174+
defer delete(visited, t)
207175
walkStruct(t, prefix, leafSet, visited)
208176
}
209177

cmd/thv-operator/internal/testutil/reflect_test.go

Lines changed: 13 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,13 @@
44
package testutil
55

66
import (
7-
"encoding/json"
87
"reflect"
9-
"sort"
108
"testing"
119
"time"
1210

1311
"github.com/stretchr/testify/assert"
1412
"github.com/stretchr/testify/require"
1513
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16-
17-
thvjson "github.com/stacklok/toolhive/pkg/json"
18-
vmcpconfig "github.com/stacklok/toolhive/pkg/vmcp/config"
1914
)
2015

2116
// --- Fixture types ---------------------------------------------------------
@@ -52,10 +47,6 @@ type withSliceOfStruct struct {
5247
Items []leafInner `json:"items"`
5348
}
5449

55-
type withSliceOfPointerStruct struct {
56-
Items []*leafInner `json:"items"`
57-
}
58-
5950
type withSliceOfPrimitive struct {
6051
Tags []string `json:"tags"`
6152
}
@@ -90,24 +81,14 @@ type withUnexportedField struct {
9081
hidden string //nolint:unused // exercised by reflection
9182
}
9283

84+
// withDuration covers two leaf paths in one fixture: a primitive int64
85+
// (time.Duration, default branch) and a json.Marshaler (metav1.Duration,
86+
// short-circuit branch). Other Marshaler types follow the same code path.
9387
type withDuration struct {
9488
Wait time.Duration `json:"wait"`
9589
WaitMeta metav1.Duration `json:"waitMeta"`
9690
}
9791

98-
type withRawJSON struct {
99-
Raw json.RawMessage `json:"raw"`
100-
}
101-
102-
type withThvJSON struct {
103-
M thvjson.Map `json:"m"`
104-
A thvjson.Any `json:"a"`
105-
}
106-
107-
type withVmcpDuration struct {
108-
D vmcpconfig.Duration `json:"d"`
109-
}
110-
11192
type recursive struct {
11293
Name string `json:"name"`
11394
Next *recursive `json:"next,omitempty"`
@@ -153,11 +134,6 @@ func TestFlattenJSONLeafFields(t *testing.T) {
153134
in: withSliceOfStruct{},
154135
want: []string{"items.a", "items.b"},
155136
},
156-
{
157-
name: "slice of pointer to struct recurses into element",
158-
in: withSliceOfPointerStruct{},
159-
want: []string{"items.a", "items.b"},
160-
},
161137
{
162138
name: "slice of primitive terminates at slice",
163139
in: withSliceOfPrimitive{},
@@ -189,29 +165,22 @@ func TestFlattenJSONLeafFields(t *testing.T) {
189165
want: []string{"visible"},
190166
},
191167
{
192-
name: "time.Duration and metav1.Duration are leaves",
168+
// time.Duration is a primitive int64 (default branch);
169+
// metav1.Duration is a json.Marshaler (short-circuit branch).
170+
// Together these exercise both leaf-emission paths.
171+
name: "primitive and json.Marshaler types are leaves",
193172
in: withDuration{},
194173
want: []string{"wait", "waitMeta"},
195174
},
196175
{
197-
name: "json.RawMessage is a leaf",
198-
in: withRawJSON{},
199-
want: []string{"raw"},
200-
},
201-
{
202-
name: "thvjson Map and Any are leaves",
203-
in: withThvJSON{},
204-
want: []string{"a", "m"},
205-
},
206-
{
207-
name: "vmcp config Duration is a leaf",
208-
in: withVmcpDuration{},
209-
want: []string{"d"},
176+
name: "self-referential type stops on revisit",
177+
in: recursive{},
178+
want: []string{"name"},
210179
},
211180
{
212-
name: "self-referential type does not recurse infinitely",
213-
in: recursive{},
214-
want: []string{"name", "next.name"},
181+
name: "pointer input is dereferenced",
182+
in: &flatPrimitives{},
183+
want: []string{"count", "enabled", "name"},
215184
},
216185
}
217186

@@ -224,21 +193,6 @@ func TestFlattenJSONLeafFields(t *testing.T) {
224193
}
225194
}
226195

227-
func TestFlattenJSONLeafFields_PointerInputDereferenced(t *testing.T) {
228-
t.Parallel()
229-
230-
got := FlattenJSONLeafFields(reflect.TypeOf(&flatPrimitives{}))
231-
require.Equal(t, []string{"count", "enabled", "name"}, got)
232-
}
233-
234-
func TestFlattenJSONLeafFields_OutputIsSorted(t *testing.T) {
235-
t.Parallel()
236-
237-
got := FlattenJSONLeafFields(reflect.TypeOf(flatPrimitives{}))
238-
require.NotEmpty(t, got)
239-
require.True(t, sort.StringsAreSorted(got), "expected sorted output, got %v", got)
240-
}
241-
242196
func TestFlattenJSONLeafFields_NilOrNonStructReturnsEmpty(t *testing.T) {
243197
t.Parallel()
244198

0 commit comments

Comments
 (0)