Skip to content

Commit ba7677e

Browse files
donislawdevclaude
andcommitted
fix: a refusal about a size names the key the recipe actually carries
The format is handed a count of bytes and answers about bytes, so every size refusal it produces says "size" - the only size key it knows. A target that draws its sizes from a range has no "size" key at all. Measured on 2026-09-06: a recipe carrying "size-range: 143-200" was refused at "targets[1].size", which sent a script reading validate --json, and a person reading the window, to a box that was neither on their screen nor in their file. It now reads "targets[1].size-range", and ordinary targets are unchanged. The substitution happens in atTarget, where the target is in hand. The format cannot make it, because it never learns which key held the number. Nothing else moved, and that was measured rather than reasoned about: refusalcorpus reports 96 identical and 0 differing across 48 recipes and two commands, and bytesweep reports all 23 formats byte for byte identical. The comment on drawSizes is corrected in the same commit, because it promised an invariant the code does not provide. It said a range "either works for every file or for none". That holds only if a format's reachable sizes are one unbroken interval from its minimum, and for four of them they are not - PNG has an unreachable band of eleven byte counts above every picture's encoded size, and the OPC three declare the same shape. So a drawn size can land in a band and be refused later, and whether that happens depends on the count: one 64x64 PNG recipe at one seed with size-range 143-200 is accepted at counts 1 and 2 and refused at 3, 5, 8, 12, 20 and 40. Closing that needs the format to declare its bands so the whole interval can be judged before anything is drawn, which changes format.Descriptor and is the owner's call. This commit does not do it, and the comment no longer claims otherwise. Two mutations in opposite directions, both caught. The pair matters more than either half: a build answering "size-range" for every size refusal would satisfy the first case and misaddress every ordinary target in the tree. Checked rather than assumed that this was not already guarded - the table in TestEveryRecipeRefusalSaysWhichSettingItIsAbout has a size-range case, but that refusal comes from the recipe reader, which knows which key it read. Both cases were tried there first and came back valid, because nothing on that path plans a file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b176bd4 commit ba7677e

7 files changed

Lines changed: 154 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,15 @@ because it turns other people's test suites red.
299299

300300
### Fixed
301301

302+
- **A refusal about a size now names the setting you actually wrote.** A target
303+
using `size-range` was refused at `targets[1].size`, a key that recipe does
304+
not have, so `validate --json` sent a script - and the window sent a person -
305+
to a box that was not there. It now reads `targets[1].size-range`. Targets
306+
using `size` are unchanged.
307+
308+
The wording of every refusal is byte for byte what it was. Only the address
309+
moved.
310+
302311
- **`verify` no longer calls a half-written manifest a file it knows nothing
303312
about.** A run killed outright can leave `<manifest>.tfg-writing` behind.
304313
`verify` reported it as `extra`, the word it uses for a file somebody else put

‎internal/core/setting.go‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,20 @@ func articleFor(name string) string {
8484
// than either surface's own word for the same thing.
8585
const SettingSize = "size"
8686

87+
// SettingSizeRange is the recipe key a refusal about a RANGE of sizes is about.
88+
//
89+
// A target that draws its sizes from a range has no "size" key at all, so an
90+
// address naming one sends somebody to a box that is not on their screen and
91+
// not in their file. Measured on 2026-09-06: a recipe carrying "size-range:
92+
// 143-200" was refused with "at": "targets[1].size", naming a setting the
93+
// person had not written.
94+
//
95+
// The refusal itself comes from the format, which knows only that a number of
96+
// bytes is out of reach and cannot know which key carried that number. So the
97+
// substitution happens where the target is in hand - see atTarget in the
98+
// engine.
99+
const SettingSizeRange = "size-range"
100+
87101
// SettingErrorf is a refusal that names the setting it is about through a slot,
88102
// so each surface reads it in its own words.
89103
//

‎internal/engine/engine.go‎

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,31 @@ type Target struct {
7878
// container was told to hold - would otherwise fail on some runs and not
7979
// others, depending on what came out of the seed. A tool whose whole promise
8080
// is that the same seed gives the same run cannot have an error that appears
81-
// and disappears. So the low end is planned first and the range either works
82-
// for every file or for none.
81+
// and disappears.
82+
//
83+
// THE LOW END IS NOT THE WHOLE ANSWER, and this comment claimed it was
84+
// until 2026-09-06. It said the range "either works for every file or for
85+
// none", and the code does not provide that. The check here is sufficient only
86+
// if a format's reachable sizes are one unbroken interval starting at its
87+
// minimum, and for four of them they are not: PNG has an unreachable band of
88+
// eleven byte counts immediately above every picture's encoded size, because
89+
// the smallest padding chunk costs twelve bytes, and the OPC three declare the
90+
// same shape between the comment capacity and the smallest extra part.
91+
//
92+
// So a size DRAWN into such a band is refused later, by the per file plan, and
93+
// whether that happens depends on the count. Measured on 2026-09-06, one 64x64
94+
// PNG recipe at one seed with size-range 143-200: counts 1 and 2 are accepted,
95+
// counts 3, 5, 8, 12, 20 and 40 are refused. The low end moves with the seed
96+
// too - 144, 143, 144 B at seeds 1, 2 and 3 - so judging file 0's band says
97+
// nothing about file 2's.
98+
//
99+
// The bytes are stable under a raised count and that was verified, so rule 2
100+
// holds for CONTENT. What is not stable is whether the run happens at all.
101+
// Closing that needs the format to declare its unreachable bands so the whole
102+
// interval can be judged before anything is drawn, which is a change to
103+
// format.Descriptor and the owner's call. Until then the refusal at least
104+
// names the key the recipe carries - see atTarget - rather than pointing at a
105+
// "size" setting a range target does not have.
83106
//
84107
// The judge is the generator itself rather than a second copy of its rules
85108
// here. A copy would be a place for the two to disagree, and the disagreement
@@ -392,7 +415,7 @@ func PlanContext(ctx context.Context, targets []Target, opt Options) ([]PlannedF
392415
// deliberately leaves alone.
393416
desc, err := settleTarget(t, opt, seen)
394417
if err != nil {
395-
return nil, atTarget(i+1, err)
418+
return nil, atTarget(i+1, t, err)
396419
}
397420
targetSeed := core.TargetSeed(opt.Seed, t.ID)
398421

‎internal/engine/errors.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ const (
152152
// Inventing a position for it would put a message about the whole run under
153153
// one batch of twenty, which is worse than leaving it at the foot of the form
154154
// where a message about the run belongs.
155-
func atTarget(position int, err error) error {
155+
func atTarget(position int, t *Target, err error) error {
156156
var about interface{ AboutSetting() string }
157157
if !errors.As(err, &about) || about.AboutSetting() == "" {
158158
return err
@@ -163,6 +163,15 @@ func atTarget(position int, err error) error {
163163
if core.AddressNamesATarget(setting) {
164164
return err
165165
}
166+
// A refusal about a size belongs to the key that carried the number. The
167+
// format cannot know which one that was - it is handed a count of bytes and
168+
// answers about bytes - so the substitution happens here, where the target
169+
// is in hand. Without it a recipe written with "size-range" is refused at
170+
// "targets[1].size", which is a box that is not on the screen and not in
171+
// the file. Measured on 2026-09-06.
172+
if setting == core.SettingSize && t != nil && t.SizeIsRange {
173+
setting = core.SettingSizeRange
174+
}
166175
return &addressedError{err: err, at: core.TargetAddress(position, setting)}
167176
}
168177

‎internal/engine/plantarget.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,12 +44,12 @@ func (pl *planning) files(ctx context.Context, t *Target, desc format.Descriptor
4444
Properties: t.Properties,
4545
})
4646
if err != nil {
47-
return atTarget(position, err)
47+
return atTarget(position, t, err)
4848
}
4949

5050
name, err := renderName(t, desc, idx)
5151
if err != nil {
52-
return atTarget(position, err)
52+
return atTarget(position, t, err)
5353
}
5454
// Two files heading for one name means one of them would be
5555
// destroyed by the other, and the manifest would still describe

‎internal/guard/sizerange_test.go‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package guard
22

33
import (
4+
"bytes"
5+
"context"
46
"crypto/sha256"
57
"encoding/hex"
8+
"encoding/json"
69
"os"
710
"path/filepath"
811
"strings"
@@ -321,3 +324,91 @@ targets:
321324
t.Errorf("all six archives came out the same size, so nothing is being drawn")
322325
}
323326
}
327+
328+
// A refusal about a size names the key the recipe actually carries.
329+
//
330+
// The format is handed a count of bytes and answers about bytes, so every size
331+
// refusal it produces says "size" - the only size key it knows. A target that
332+
// draws from a range has no "size" key at all. Measured on 2026-09-06: a recipe
333+
// carrying "size-range: 143-200" was refused at "targets[1].size", sending
334+
// somebody to a box that was neither on their screen nor in their file.
335+
//
336+
// NOT COVERED BY TestEveryRecipeRefusalSaysWhichSettingItIsAbout, and that was
337+
// checked rather than assumed. Its table has a "size-range" case already, but
338+
// that refusal comes from the recipe READER, which knows which key it was
339+
// reading. This one comes from underneath, and the table's runner never reaches
340+
// it - the two cases were tried there first and the recipe came back valid,
341+
// because nothing in that path plans a file.
342+
//
343+
// The pair matters more than either half. A build that answered "size-range"
344+
// for every size refusal would pass the first case and misaddress every
345+
// ordinary target in the tree.
346+
func TestASizeRefusalIsAddressedToTheKeyTheRecipeCarries(t *testing.T) {
347+
cases := []struct {
348+
name string
349+
src string
350+
want string
351+
}{
352+
{
353+
name: "a range the format cannot deliver",
354+
src: `version: 1
355+
targets:
356+
- id: a
357+
format: pdf
358+
count: 40
359+
size-range: 10-8kb
360+
`,
361+
want: "targets[1].size-range",
362+
},
363+
{
364+
name: "a plain size the format cannot deliver",
365+
src: `version: 1
366+
targets:
367+
- id: a
368+
format: pdf
369+
count: 1
370+
size: 10
371+
`,
372+
want: "targets[1].size",
373+
},
374+
}
375+
376+
for _, c := range cases {
377+
t.Run(c.name, func(t *testing.T) {
378+
dir := t.TempDir()
379+
path := writeRecipe(t, dir, c.src)
380+
381+
var out, errOut bytes.Buffer
382+
if code := cli.Run(context.Background(),
383+
[]string{"validate", path, "--json"}, &out, &errOut); code == cli.ExitOK {
384+
t.Fatalf("this recipe was meant to be refused and validate was happy with it:\n%s",
385+
out.String())
386+
}
387+
388+
var report struct {
389+
Problems []struct {
390+
What string `json:"what"`
391+
At string `json:"at"`
392+
} `json:"problems"`
393+
}
394+
if err := json.Unmarshal(errOut.Bytes(), &report); err != nil {
395+
t.Fatalf("the report is not readable as JSON: %v\n%s", err, errOut.String())
396+
}
397+
// Asserted rather than assumed. A report with no problems in it
398+
// would pass the loop below by never entering it.
399+
if len(report.Problems) == 0 {
400+
t.Fatalf("the report carries no problems at all:\n%s", errOut.String())
401+
}
402+
403+
for _, pr := range report.Problems {
404+
if pr.At == c.want {
405+
continue
406+
}
407+
t.Errorf("the refusal %q is addressed to %q and belongs at %q.\n"+
408+
"What to do: a refusal about a size carries the key that held the number, "+
409+
"so a window can mark the box somebody can actually change.",
410+
pr.What, pr.At, c.want)
411+
}
412+
})
413+
}
414+
}

‎internal/recipe/compose.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,8 +322,8 @@ const (
322322
KeyID = "id"
323323
KeyFormat = "format"
324324
KeyCount = "count"
325-
KeySize = "size"
326-
KeySizeRange = "size-range"
325+
KeySize = core.SettingSize
326+
KeySizeRange = core.SettingSizeRange
327327
KeyBoundary = "boundary"
328328
KeyName = "name"
329329
KeyGroup = "group"

0 commit comments

Comments
 (0)