Compare commits

...

10 Commits

Author SHA1 Message Date
M 64a0d29496 Say why an empty name is rejected, and finish what that removed
Tests / vet + fmt + tests (push) Failing after 42s
The reason given was wrong. A dot path did reach an empty-named field:
Fake("a.") returned it, Fake("a.b.") returned a nested one, and {..a.} bound
it. What was true is narrower — List never offered it, because an empty name
is no path segment — so the engine accepted spellings it would not advertise.
The message, the test comment and the README say that instead.

Two things the rejection finished off:

A {} token still reported "no field \"\"", pointing at a fix the loader now
rejects — two errors for one rule. It is told the name can never exist, like
an option token already is.

addressable is dead: both halves of "not empty and no dot" are now rejected
where a name is authored. Mutating it to panic leaves the suite green here
and panics on main, so it was live and is not. Keeping it would preserve the
silent-hiding this change removes — List quietly omitting a name rather than
New refusing it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 07:31:18 +02:00
M 138bd21d83 Reject an empty field name
A field named "" was reachable by exactly one spelling, a bare {} token,
and by no dot path: List hid it and Fake could not ask for it. checkName
rejects it now, for the reason it rejects a dot — a name no path can reach.

That also makes the empty-segment rule unconditional. {a.} was rejected
because a field really named "" would otherwise have made it resolve; no
data can carry one, so the rule now guards a shape that cannot exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 07:31:18 +02:00
M e31e77b8a3 Tests for an empty field name
A field named "" loads, and a bare {} token renders it, but no dot path can
reach it — List hides it and Fake cannot ask for it. {a.} is already rejected
for the same reason, so the two spellings disagree about the same mistake.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 07:31:18 +02:00
M 4b3dd011fc Pin the per-head walk, and say where the hold stops
Sharing renders' seen set across heads left the suite green while silently
under-rejecting: the first head's walk marks the only route to the second
head's draw. This PR widens heads from the bound levels to every held name,
so two heads in one template is now the ordinary case; a rejected case whose
violation sits on the later head pins the scoping.

An operand rendering one field twice covers the revisit guard, taking
coverage to 97.8% against main's 97.4%.

The README said the two spellings are rejected alike without saying what the
hold follows, which is not true where the operand reaches its own field
through a reference. It now names the plain {field} tokens the walk follows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:58:37 +02:00
M 21e7af6c1f Hold what a calc operand's render settled, up to a reference
The fence pinned only the operand node, so a reference reaching into it drew
that value again: {net.v} was a load error while {..cat.net.v} loaded and
disagreed in 20 of 40 renders. The hold now covers what rendering the operand
settles inside itself, so both spellings of that shape are rejected.

The walk stops at a {..path} edge, where the operand's own value ends and a
shared source begins. cover stops there too, through named, so both halves of
the fence end at the same boundary — and two names drawing from one referenced
category stay two draws.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:58:37 +02:00
M 8d03ce34bb Tests for a reference reaching into a calc operand
{net.v} is a load error because the operand is also a path head, but
{..cat.net.v} names the same node by another spelling and loads — and
disagrees in 20 of 40 renders. Same shape, opposite verdicts, which is the
thing this rule exists to remove.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:58:37 +02:00
M cf3229cd4d Pin the route an operand fence names
The operand label had no test: every rejected case reported through a token
edge, so flipping the flag that distinguishes the two left the suite green
while the error went back to inventing a {b} the format never wrote. Each
case now asserts the route it names, and one reports through an operand edge,
which is the branch that was uncovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:58:37 +02:00
M 39d59aec23 Pin the prep-time guards, and say why an operand's set is the narrow one
The guards report an invariant break instead of computing around it, and no
data reaches them, so they are called directly: coverage 97.4% -> 97.7%,
which also covers the empty-cover skip through a literal operand.

operandDraw's doc keeps why neither wider set works, since both were tried
here — containment reaches a sibling the operand never renders, the render
closure reaches a source two operands share — and drops the rest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:58:37 +02:00
M f949550515 One spelling for an unvalidated builtin arg, and say what the hold buys
float's prep still swallowed ParseFloat's error three lines from the atoi that
had just been changed to report one — two spellings of "parse an arg the check
already validated". atof reports it the same way.

The README paragraph said a reference reaching through an operand is caught,
which the narrowed fence no longer does and should not: it says what the hold
pins instead. Restored the why the trimmed comment in expand had carried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:58:37 +02:00
M 027d5c33fc Name a calc operand as one when the fence reports it
A renderEdge label can be an operand name, and both messages printed it as
{name} — so an author was told a token renders the level when no such token
existed in the format. An edge now carries whether its label is an operand and
names itself the way boundReaders does, so both fences speak one language.

The narrowed operand cover fixed the other half: the render closure pulled the
other operand into the cover, which could report the two sides the wrong way
round ({n} renders "m" where it is m that reaches n).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 21:58:37 +02:00
11 changed files with 195 additions and 57 deletions
+9 -5
View File
@@ -238,8 +238,9 @@ option stays unset. `New` rejects an option that cannot take effect — a
`separator` without a `repeat` above 1, a `weight` outside a choice — and a name
using a character the grammars reserve: `.` separates the segments of a path, `|`
the arms of a token, `(` opens a function call and `}` ends the token, so a name
carrying one is a name no format could ever spell. That holds for a category, a
folder and a field alike.
carrying one is a name no format could ever spell. An empty name goes the same
way — it is no path segment at all, so `List` never offers it. That holds for a
category, a folder and a field alike.
**Functions.** A `{name()}` token calls a built-in function instead of rendering
a field. `{luhn()}` appends a Luhn check digit over the digits emitted **so far**
@@ -365,9 +366,12 @@ the reference sits, including in a field the format renders.
A `{calc()}` operand is held on its own terms too, so the same fence guards it:
`{..cat.net} x 2 = {calc(net * 2, 2)}` names one field two ways and is a load
error, with no path token anywhere. What an operand's draw fixes is the value it
renders, so a reference reaching *through* it is caught as well, while a sibling
its format never renders is free — that sibling is no part of the value.
error, with no path token anywhere. A calc renders its operand whole, so the hold
pins what that render settled, following the operand's plain `{field}` tokens —
`{net.v}` and `{..cat.net.v}` are rejected alike. It stops at a `{..path}`, where
the operand's own value ends and a shared source begins: two names drawing from
one referenced category are two draws, as `{word} {word}` is, so two dice over
one `{..die}` are fine.
```text
token {p} renders a level that {p.first} reads a path into; name the fields you want instead
+66 -8
View File
@@ -148,25 +148,77 @@ func TestALevelRenderedOnlyByAPathTokenIsHeld(t *testing.T) {
// shown is not the value computed. Two names that merely draw from one source are
// two draws, as {word} {word} is — the rule everywhere else in the engine.
func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
rejected := map[string]map[string]string{
rejected := map[string]struct {
files map[string]string
want string // the route the error names, spelled as the author wrote it
}{
"a reference beside the operand": {
map[string]string{
"cat": `{"format":"{..cat.net} x 2 = {calc(net * 2, 2)}","net":["10.00","20.00"]}`,
},
`{..cat.net} renders "net"`,
},
"a reference one level down": {
map[string]string{
"cat": `{"format":"{calc(net * 2, 2)} {q}","net":["10.00","20.00"],` +
`"q":{"format":"{..cat.net}"}}`,
},
`{q} renders "net"`,
},
// A reference ending at the operand binds the choice itself, not a variant,
// so wrapping the operand in one changes nothing.
"a reference to an operand wrapped in a choice": {
map[string]string{
"cat": `{"format":"{calc(n * 2, 2)} {q}","n":[{"format":"{v}","v":["1","2"]}],` +
`"q":{"format":"{..cat.n}"}}`,
},
`{q} renders "n"`,
},
// The reaching route can be another operand, which is not a token — so the
// error must name it as one rather than invent a {b} the format never wrote.
"an operand reaching another operand": {
map[string]string{
"cat": `{"format":"{calc(a + b, 0)}","a":{"format":"{x}","x":["1","2"]},` +
`"b":{"format":"{..cat.a}"}}`,
},
`calc operand "b" renders "a"`,
},
// Reaching *into* the operand is the same shape as naming it: the draw the
// calc holds settled that value too. The token spelling {net.v} is already
// rejected, so the reference spelling has to be.
"a reference into the operand": {
map[string]string{
"cat": `{"format":"{calc(net * 2, 2)}|{..cat.net.v}",` +
`"net":{"format":"{v}","v":["10.00","20.00"]}}`,
},
`{..cat.net.v} renders "net"`,
},
"a reference into the operand one level down": {
map[string]string{
"cat": `{"format":"{calc(net * 2, 2)}|{q}","net":{"format":"{v}","v":["10.00","20.00"]},` +
`"q":{"format":"{..cat.net.v}"}}`,
},
`{q} renders "net"`,
},
// Each head is walked against its own seen set. Sharing one across heads
// would let the first head's walk mark the only route to the second's draw,
// and this violation sits on the later head in sorted order.
"a violation on the second of two held heads": {
map[string]string{
"cat": `{"format":"{calc(a + b, 0)} {w}","a":{"format":"{x}","x":["1","2"]},` +
`"b":{"format":"{y}","y":["3","4"]},"w":{"format":"{..cat.b}"}}`,
},
`{w} renders "b"`,
},
}
for name, files := range rejected {
_, err := New([]string{writeData(t, files)})
for name, c := range rejected {
_, err := New([]string{writeData(t, c.files)})
if err == nil || !strings.Contains(err.Error(), "a {calc()} also reads") {
t.Errorf("%s: New = %v, want the second route to the operand rejected", name, err)
continue
}
if !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: New = %v, want it to name %q", name, err, c.want)
}
}
@@ -200,6 +252,11 @@ func TestACalcOperandIsHeldAgainstEveryRoute(t *testing.T) {
"a literal operand named twice": {
"cat": `{"format":"{calc(n * 2, 0)} {..cat.n}","n":"5"}`,
},
// One node reached twice while walking the operand: the walk must not
// revisit it, and the repeat is not a second route to anything.
"an operand that renders one field twice": {
"cat": `{"format":"{calc(v * 2, 0)}","v":{"format":"{a}{a}","a":["1","2"]}}`,
},
}
for name, files := range accepted {
if _, err := New([]string{writeData(t, files)}); err != nil {
@@ -599,12 +656,13 @@ func TestDeepPathsUnderOneHeadStayIndependentWhereTheyDiverge(t *testing.T) {
}
func TestEmptyPathSegmentIsRejected(t *testing.T) {
// "{a.}", "{.b}" and "{a..b}" are unfinished paths. A field really named ""
// would otherwise make them resolve, which reads as a typo that worked.
// "{a.}", "{.b}" and "{a..b}" are unfinished paths, and the segment naming
// nothing is reported as that rather than as a missing field. An empty name is
// rejected where it is authored, so no data can make these resolve.
rejected := map[string]string{
"trailing dot": `{"format":"[{a.}]","a":{"format":"x","":["V"]}}`,
"leading dot": `{"format":"[{.b}]","":{"format":"{b}","b":["V"]}}`,
"double dot": `{"format":"[{a..b}]","a":{"format":"x","":{"format":"{b}","b":["V"]}}}`,
"trailing dot": `{"format":"[{a.}]","a":{"format":"x"}}`,
"leading dot": `{"format":"[{.b}]","a":{"format":"{b}","b":["V"]}}`,
"double dot": `{"format":"[{a..b}]","a":{"format":"x"}}`,
}
for name, file := range rejected {
_, err := New([]string{writeData(t, map[string]string{"cat": file})})
+10 -3
View File
@@ -48,9 +48,7 @@ var builtins = map[string]builtin{
return func(s *session, _ string, _ []string) string { return strconv.Itoa(lo + s.IntN(span)) }
}},
"float": {arity: 3, check: floatArgs, prep: func(a []string) callFn {
lo, _ := strconv.ParseFloat(a[0], 64)
hi, _ := strconv.ParseFloat(a[1], 64)
dp := atoi(a[2])
lo, hi, dp := atof(a[0]), atof(a[1]), atoi(a[2])
return func(s *session, _ string, _ []string) string {
return strconv.FormatFloat(lo+s.Float64()*(hi-lo), 'f', dp, 64)
}
@@ -104,6 +102,15 @@ func atoi(s string) int {
return n
}
// atof is atoi for a float arg, and reports an unvalidated one the same way.
func atof(s string) float64 {
f, err := strconv.ParseFloat(s, 64)
if err != nil {
panic(fmt.Sprintf("fakes: builtin arg %q reached prep unvalidated: %v", s, err))
}
return f
}
func randBytes(r rng, n int) []byte {
b := make([]byte, n)
for i := range b {
+24
View File
@@ -193,3 +193,27 @@ func TestRegistryShapes(t *testing.T) {
}
}
}
// TestArgGuardsPanic pins the guards that report a builtin arg its check should
// have rejected. No data reaches them — checkFunc runs a builtin's check before
// compileOps ever calls prep — so they are exercised directly.
func TestArgGuardsPanic(t *testing.T) {
for name, call := range map[string]func(){
"atoi on an unvalidated arg": func() { atoi("nope") },
"atof on an unvalidated arg": func() { atof("nope") },
} {
mustPanic(t, name, call)
}
}
// mustPanic fails unless call panics, which is what separates a reported
// invariant break from a silently wrong value.
func mustPanic(t *testing.T, name string, call func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Errorf("%s: no panic, want the invariant reported", name)
}
}()
call()
}
+13
View File
@@ -103,6 +103,19 @@ func TestCalcTokenOperandsReadsOnlyACalc(t *testing.T) {
}
}
// TestCalcGuardsPanic pins the guards on calc's own invariants: checkCalc parsed
// the expression before prep sees it, and indexVars places every name calcVars
// read, so a break in either is reported rather than computed around.
func TestCalcGuardsPanic(t *testing.T) {
for name, call := range map[string]func(){
"prep on an expression that does not parse": func() { calcPrep([]string{"1 +"}) },
"an operand name never placed": func() { calcVar("n").eval(nil) },
"indexVars on a name it did not read": func() { indexVars(calcVar("n"), map[string]int{}) },
} {
mustPanic(t, name, call)
}
}
// --- one draw, one value: a calc operand reads the expansion's draw ---
// TestCalcOperandReadsTheExpansionsDraw pins the one-draw rule for a calc operand.
+14 -1
View File
@@ -120,6 +120,13 @@ func TestNewErrors(t *testing.T) {
map[string]string{"a|b": `["1"]`},
`category "a|b" contains "|"`,
},
// An empty name is not a path segment, so List never offered it — while a
// bare {}, a trailing dot in Fake("a.") and a {..a.} reference all reached
// it. The engine accepted spellings it would never advertise.
"empty field name": {
map[string]string{"a": `{"format":"[{}]","":"VALUE"}`},
`field "" is empty`,
},
"folder name with a paren": {
map[string]string{"a(b/cat": `["1"]`},
`folder "a(b" contains "("`,
@@ -149,9 +156,15 @@ func TestNewErrors(t *testing.T) {
map[string]string{"a": `{"format":"{..|..}"}`},
"reference has no path",
},
// No field can be named "", so the token is told that rather than sent to
// name one — the fix "no field" points at is itself a load error.
"repeated empty arm": {
map[string]string{"a": `{"format":"{|}"}`},
`no field ""`,
"a name is never empty",
},
"bare empty token": {
map[string]string{"a": `{"format":"[{}]","x":["1"]}`},
"a name is never empty",
},
}
for name, c := range rejected {
+1 -13
View File
@@ -22,7 +22,6 @@ import (
"fmt"
"math/rand/v2"
"sort"
"strings"
)
// Fakes generates fake data from a loaded namespace tree. Create one with [New].
@@ -93,14 +92,6 @@ func (f *Fakes) List() []string {
return out
}
// addressable reports whether a name can be one segment of a dot path. A dot would
// split it into two segments and an empty name into none, so a path through such a
// name cannot be spelled — List must not offer one, and it must not count towards
// what a choice's variants share.
func addressable(name string) bool {
return name != "" && !strings.Contains(name, ".")
}
// paths lists the dot paths addressable from n, relative to it, where "" is n
// itself. A group has no value of its own, so it contributes only its children's.
func paths(n node) []string {
@@ -108,9 +99,6 @@ func paths(n node) []string {
case *group:
var out []string
for _, name := range sortedNames(n.children) {
if !addressable(name) {
continue
}
for _, p := range paths(n.children[name]) {
out = append(out, join(name, p))
}
@@ -119,7 +107,7 @@ func paths(n node) []string {
case *template:
out := []string{""}
for _, name := range sortedNames(n.fields) {
if isRef(name) || !addressable(name) { // a binding, or unreachable by path
if isRef(name) { // a binding, not a path segment
continue
}
for _, p := range paths(n.fields[name]) {
+3
View File
@@ -266,6 +266,9 @@ var reservedList = strings.Join(strings.Split(reservedInName, ""), " ")
// category or folder and a field go through it, so there is one answer to what a
// name may contain.
func checkName(name string) error {
if name == "" {
return fmt.Errorf("%q is empty, which is not a path segment, so List never offers it", name)
}
if i := strings.IndexAny(name, reservedInName); i >= 0 {
return fmt.Errorf("%q contains %q; a name may not use %s, which the dot path and {token} grammars reserve",
name, name[i:i+1], reservedList)
+43 -19
View File
@@ -65,10 +65,10 @@ func checkBoundLevelsHeld(root map[string]node) error {
if isPath {
cover(t.fields[head], held)
} else {
coverRendered(t.fields[head], held)
operandDraw(t.fields[head], held)
}
if len(held) == 0 {
continue // a fixed string, which cannot disagree with itself
continue // an early out: a literal head holds nothing to reach
}
// One seen set across the edges: a node that cannot reach the level
// cannot reach it by another route either, so it is walked once here.
@@ -79,9 +79,9 @@ func checkBoundLevelsHeld(root map[string]node) error {
}
if renders(e.to, held, seen) {
if isPath {
return fmt.Errorf("%s: {%s} renders %q, which {%s} reads a path into; name the fields you want instead", path, e.label, head, reader)
return fmt.Errorf("%s: %s renders %q, which {%s} reads a path into; name the fields you want instead", path, e.reached(), head, reader)
}
return fmt.Errorf("%s: {%s} renders %q, which a {calc()} also reads; reach it one way so it is drawn once", path, e.label, head)
return fmt.Errorf("%s: %s renders %q, which a {calc()} also reads; reach it one way so it is drawn once", path, e.reached(), head)
}
}
}
@@ -103,14 +103,17 @@ func cover(n node, into map[node]bool) {
}
}
// coverRendered collects what one held draw of a calc operand answers for: what
// rendering it reaches, following the same edges expand does. An operand is only
// ever rendered — checkNoOverlap rejects a path into one — so its draw fixes that
// value and nothing else. That is narrower than containment where a sibling the
// operand's format never renders cannot disagree with it, and wider where the
// operand renders through a reference, which is part of the value it produces.
// Literals are left out for the reason cover leaves them out.
func coverRendered(n node, into map[node]bool) {
// operandDraw collects what one held draw of a {calc()} operand answers for: the
// operand and what rendering it settles inside itself. A calc renders its operand
// whole, so that draw fixes every value the render produced, and a second route to
// any of them disagrees with it.
//
// The walk stops at a {..path} edge, which is where the operand's own value ends
// and a shared source begins: two names referencing one category are two draws, the
// same rule {word} {word} follows. cover stops there too, by way of named, so both
// halves of the fence end at the same boundary. A literal is left out for the
// reason cover leaves one out.
func operandDraw(n node, into map[node]bool) {
if _, fixed := n.(literal); fixed {
return
}
@@ -119,7 +122,10 @@ func coverRendered(n node, into map[node]bool) {
}
into[n] = true
for _, e := range renderEdges(n) {
coverRendered(e.to, into)
if isRef(e.label) {
continue
}
operandDraw(e.to, into)
}
}
@@ -267,11 +273,23 @@ func refTokens(format string) []string {
return refs
}
// renderEdge is a child a node renders into, labelled by the token that reaches it
// (a field name, reference, or choice index) for a readable cycle report.
// renderEdge is a child a node renders into, labelled by what reaches it (a field
// name, reference, or choice index) for a readable cycle report. operand marks a
// label that is a {calc()} operand name rather than a token, so an error can name
// it the way the author wrote it.
type renderEdge struct {
to node
label string
operand bool
}
// reached names an edge as the author spelled it, the vocabulary boundReaders uses
// for the sibling fence.
func (e renderEdge) reached() string {
if e.operand {
return fmt.Sprintf("calc operand %q", e.label)
}
return "{" + e.label + "}"
}
// renderEdges lists the children rendering n recurses into, mirroring expand: a
@@ -282,21 +300,27 @@ func renderEdges(n node) []renderEdge {
case *choice:
es := make([]renderEdge, len(n.items))
for i, it := range n.items {
es[i] = renderEdge{it, fmt.Sprintf("[%d]", i)}
es[i] = renderEdge{to: it, label: fmt.Sprintf("[%d]", i)}
}
return es
case *template:
var es []renderEdge
for _, name := range append(fieldTokens(n.format), calcOperands(n.format)...) {
add := func(name string, operand bool) {
a := splitArm(name)
c, ok := n.fields[a.key]
if !ok {
continue
return
}
for _, leaf := range pathLeaves(c, a.tail) {
es = append(es, renderEdge{leaf, name})
es = append(es, renderEdge{leaf, name, operand})
}
}
for _, name := range fieldTokens(n.format) {
add(name, false)
}
for _, name := range calcOperands(n.format) {
add(name, true)
}
return es
default:
return nil
+2 -1
View File
@@ -156,7 +156,8 @@ func expand(s *session, t *template) string {
case 'f':
b.WriteString(readField(s, t, held, o.arms[s.IntN(len(o.arms))]))
case 'b':
// Read in the order calcVars fixed, which is what op.operands holds.
// Read before the call, so the value a calc computes is the value the
// format showed. calcVars fixed the order op.operands holds.
var operands []string
if len(o.operands) > 0 {
operands = make([]string, len(o.operands))
+3
View File
@@ -147,6 +147,9 @@ func checkTokens(format string, fields map[string]node) error {
}
head, ok := fields[a.key]
if !ok {
if a.key == "" {
return fmt.Errorf("token {%s}: a name is never empty, so this token can name no field", t.body)
}
if isOption(a.key) {
return fmt.Errorf("token {%s}: %q is an option and can never be a field", t.body, a.key)
}