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>
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>
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>
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>
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>
{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>
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>
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>
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>
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>
A calc renders its operand whole, so the draw it holds is that one node's
value. The render closure was too wide: it pulled in shared descendants, so
two operands drawing from one source were rejected — two dice over one
{..die}, or {w} x {h} over one {..dim} — though each is drawn once, shown
once, and cannot disagree. main accepts all of them.
So the fence is another route naming the operand's own node, and these pin
both sides: a reference to the operand, one level down, or wrapped in a
choice is rejected; two names drawing from one source, a sibling the operand
never renders, and a literal operand are accepted. That reverses the case
added earlier this round, which read a shared source as a second spelling.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The one-spelling section described a calc operand only as a route that
renders a level, so a reader could not predict that {..cat.net} beside
{calc(net * 2, 2)} is now a load error with no path token in sight. It says
so, and says what an operand's draw covers and what it leaves free.
Dropped the paragraph in checkBoundLevelsHeld that restated its own first
line and the loop below it, and the escape-analysis note in expand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
calcPrep discarded parseCalc's error, so a divergence from checkCalc would
have produced a nil AST and then a nil dereference per render, with no
message. atoi did the same quietly, returning zero for a length, range or
decimal count. Both now panic naming the argument, so the invariant they rest
on fails where it breaks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The fence now asks the right question for each kind of hold. A path head keeps
containment, since a path may read into anything the level carries. An operand
head takes the render closure, because a calc renders its field and that draw
fixes only the value the render produces.
That stops rejecting a reference to a sibling the operand never renders, which
had loaded fine before the fence widened, and starts rejecting a reference
that reaches what the operand renders through. A head whose cover is empty —
a fixed string — skips the walk.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Widening the fence used containment for both kinds of hold, which is right
for a level a path reads and wrong for a sibling a calc reads. A path may
read into anything the level contains; an operand's draw fixes only the value
it renders. So containment rejects a reference to a sibling the operand never
renders, which loads fine today and cannot disagree, and misses a reference
reaching what the operand renders through, which disagreed in 20 of 40 draws.
Also pins the repeated reference arm, the one kind that reaches the repeat
check by passing the per-arm checks rather than falling through them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The operand order was written out in three places; it now lives at calcVars,
which fixes it, and the other two point there. Dropped the note explaining
why ')' is not reserved, which said what the set is not rather than what it
is, and shortened reservedList's doc to less than its body. The note on held
now states where it reaches instead of arguing against a design this repo no
longer contains.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
calcVar.eval returned NaN on a path indexVars makes unreachable, and a name
missing from the index map read operand 0. Both would have printed a wrong
number rather than reporting anything. They panic now, like child() does for
the same class of slip, so drift between calcVars and indexVars surfaces as a
stack trace naming the operand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The calc case drew from single-element lists, so its pinned bytes were
constant wherever the rng sat — it could not have caught a change to how many
draws a calc consumes, which is exactly what the operand hold changed.
Multi-element lists make that stream a pinned fact, and every pinned pair
multiplies correctly, so the hold is pinned with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A name starting with ".." also starts with ".", so loadDir's hidden-entry
skip reaches it first and neither isRef branch could ever fire. Removed, and
the comment in named now names the skip that actually holds the invariant it
depends on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two cases claimed to pin that a category or folder named with the reference
prefix is rejected. They passed for the wrong reason: the entry is skipped as
hidden, which left the directory empty, so New failed with "no .json data
found" instead. Beside a valid sibling both load fine and are simply absent.
Replaced with a test of the real rule — a leading dot means hidden, which is
what lets a data directory be a checkout — and that covers a ".." name too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dedup now runs after the per-arm loop, so a missing reference path or a
missing field is reported as itself. A repeated reference arm is still
caught: the loop lets a well-formed reference through without erroring, and
the dedup reads the arms as written.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
{..|..} reported a repeated arm rather than a reference with no path, and
{|} reported one rather than a missing field. Both are load errors either
way, but the repeat is a consequence of the real mistake, so the real one
should be what the author reads.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
checkBoundLevelsHeld already performed the right walk; it iterated the wrong
set. Walking t.held covers both kinds of hold — the level a dotted token
reads and the sibling a calc reads — so the guarantee the README states now
holds for every spelling of the operand, not just the bare one.
The path message is unchanged. A calc-only head gets its own, naming the
sibling spelling that reads the held draw.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A {calc()} operand is drawn once and held, but only a dotted token's level was
fenced against a second route to it. A {..path} naming the same field renders
it afresh, so the value shown is not the value computed — 47 of 60 renders
disagreed on a five-value operand.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Computation section carried the caveat this branch removes — that a field
shown and used in a calc is drawn twice — so it now describes the hold
instead. The name rules name all four reserved characters rather than the
dot alone, in both the Data and Data format sections, and the alternation
entry says a repeated arm is a load error and points at weight.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A field the format rendered and a calc read was drawn twice, so the operand
shown could differ from the operand computed — the same disagreement the
dotted-path rule already fences, in its plainest spelling. The README
carried it as a caveat, which is what a rule like this exists to remove.
A calc operand now joins the names an expansion holds, so it is drawn once
and every later read of it — the calc, and any bare token spelling it —
sees that draw. The hold stays per expansion: each repeat iteration and each
nested template draws its own.
expand reads a calc's operands before the call and hands over their values,
so a builtin takes (emitted, operands) rather than the sibling fields, and
the evaluator indexes that slice instead of walking the node tree. That is
what keeps the draws a local. Handing them to a builtin instead lets them
escape through an indirect call, which put two maps on the heap for every
held format, calc or not: BenchmarkBound went 56 B/5 allocs -> 744 B/10.
Measured against main: Bound 359 -> 349 ns at 56 B/5 allocs, unchanged;
Calc 416 -> 577 ns and one more alloc, which is what the correlation costs.
Validation is unchanged: t.bound still carries only the levels a dotted
token reads, so the overlap fences fence exactly what they did.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A field the format renders and a calc reads is drawn twice today, so
"{net} x {qty} = {calc(net * qty, 2)}" can print a net that is not the net
it multiplied. The dotted-path rule already holds one draw for a level;
these pin the same hold for a plain sibling a calc reads.
Also pinned: the hold reaches a bare token of the same name, belongs to one
expansion (each repeat iteration and each nested template draws its own),
and leaves a name no calc reads drawing every time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An alternation picks its arms evenly, so {a|a|b} was a covert 3:1 skew — a
third spelling of what a choice's weight is for, reachable only by
experiment. The check runs over the arms as written, before a reference arm
is let through, so {..a|..a} is caught too.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
{a|a|b} skews an alternation three to one, an undocumented third way to
weight beside a choice's weight and a repeated choice entry. These pin its
rejection, and one accepted case fixes the boundary: {x}{x} is two tokens,
so it stays two independent draws.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The dot rule was spelled twice — once for a category or folder, once for a
field — and covered only the dot. Both now go through checkName, which
rejects the four characters no format can name: . | ( and }.
')' stays legal: on its own it is spellable, and rejecting it would refuse a
name that works.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>