From b8da6fdbc0f232e37274f9ed4e375d0d37ba7a93 Mon Sep 17 00:00:00 2001 From: M Date: Sat, 29 Aug 2026 13:12:23 +0200 Subject: [PATCH] See a path token's head when walking for cycles --- bound_test.go | 12 ++++++++++++ reference.go | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/bound_test.go b/bound_test.go index 1d53851..98af42c 100644 --- a/bound_test.go +++ b/bound_test.go @@ -202,6 +202,18 @@ func TestDifferentTailsUnderOneHeadShareTheRow(t *testing.T) { } } +func TestCycleThroughAPathTokenIsRejected(t *testing.T) { + // A path token is a render edge like any other, so a cycle routed through one + // must be caught at New. Reaching render would be fatal: the recursion never + // terminates, and a stack overflow cannot be recovered. + _, err := New([]string{writeData(t, map[string]string{ + "a": `{"format":"{p.x}","p":{"format":"{x}","x":{"format":"{..a}"}}}`, + })}) + if err == nil || !strings.Contains(err.Error(), "reference cycle") { + t.Fatalf("New = %v, want the cycle through {p.x} rejected", err) + } +} + func TestBoundPathIsReachableByFake(t *testing.T) { // Binding changes how a format reads a sibling, not what List and Fake offer: // the sub-fields stay addressable on their own. diff --git a/reference.go b/reference.go index 179ac40..f0ec3cf 100644 --- a/reference.go +++ b/reference.go @@ -190,7 +190,10 @@ func renderEdges(n node) []renderEdge { case *template: var es []renderEdge for _, name := range append(fieldTokens(n.format), calcOperands(n.format)...) { - if c, ok := n.fields[name]; ok { + // A path token renders its head, so the edge is to the head — over- + // approximating the sub-field it descends to, which is what keeps the + // cycle walk conservative rather than blind. + if c, ok := n.fields[splitArm(name).key]; ok { es = append(es, renderEdge{c, name}) } }