Skip to content

Commit c643851

Browse files
donislawdevclaude
andcommitted
cli: say the right thing about the caller's own request
Six refusals from the audit list. None of them lost data. All of them told somebody something untrue about what they had asked for, which is rule 3 of this project one level down: the value and the text beside it have to agree. recipe fmt is about the shape of a file, and now says so. Measured: --check ends with 0 on a recipe with no version, an unknown top level key, a format nobody registered, a typo in a target key, and a size and a boundary stated together - all five of which validate refuses. That is the owner's decision of 2026-08-03 rather than an oversight, because a formatter that refused an invalid recipe could not lay out one somebody is still writing. What changed is the text: the help and the flag description now say a check before a commit wants tfg validate beside it. The comment in the code claimed the opposite - that the formatter turns away the same files the rest of the tool turns away - and it was written after fixing one case of exactly that. True of the case, false in general. This project has no guard for that kind of prose, so the entry stays in OBSERVATIONS after being closed. A PNG asking for more pixels than it can hold ended with 1, which tells CI this build is broken, for a pair of numbers the caller chose. It is 4 now, the same as every other value a format cannot deliver. This is the class closed for declared ranges on 2026-08-03 surviving one layer deeper: the declaration bounds each side on its own and cannot express a limit on the two multiplied. tfg formats png now states that limit beside both settings rather than one - mutation caught the weaker version, because removing the sentence from width alone left the guard green while somebody setting height still learned nothing. What stays open is written down: the limit lives in a sentence rather than in the declaration, so a window built from the declaration will still offer the pair. Giving the registry a shape for a constraint binding two settings is a change to AR9. See O45. --count 0 or below came back as "asks for 0 files" whatever was typed, because anything below one produced an empty list and the message described the list rather than the request. The recipe reader had this right and the flag path did not. --out pointing at a file produced two messages for one mistake, the first saying there was nothing at a path that had something at it - the system reports a kind of error our mapping had no sentence for. --expected-reason did not exist. A recipe could say why an outcome was expected and the command line could not, so a run driven by flags could never fill the category the closed list exists to make countable. It uses the same list rather than a second copy, exported for the purpose, because two copies is how two surfaces drift. zip's minimum size answered 1<<62 on any failure, on the reasoning that refusing every size is safer than declaring a wrong minimum. It is not safer, it is quieter: every ZIP request ever made would be refused with a message about a minimum of 4611686018427387904 B and nothing would say why. The condition needs the default entry format to be unregistered, which today depends on "txt" sorting before "zip" in an import list - true, and true by accident. It panics now, like format.Register already does for the same class. Seven guards, each seen red first, and seven mutations. Two were caught wrong: one did not compile once the struct literal could not be closed by a one line swap, and one stayed green because it only removed half of what the guard should have been asking for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 64b363b commit c643851

10 files changed

Lines changed: 351 additions & 45 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,33 @@ because it turns other people's test suites red.
7878
declared contents did not finish at all. It is now 57 ms, and the bytes of
7979
every archive are unchanged.
8080

81+
### Added
82+
83+
- **`--expected-reason` on the command line.** A recipe could say why an
84+
outcome was expected and the flags could not, so a run driven by flags could
85+
never fill the category the closed list exists to make countable. The list is
86+
the same one the recipe uses, and a value that is not on it is refused with
87+
the list to pick from.
88+
8189
### Changed
8290

91+
- **`tfg recipe fmt` says what it does not check.** It settles the layout of a
92+
file and never claimed otherwise in `--help`, but the text implied more than
93+
it did. A recipe with a key nobody recognises still has a settled shape and
94+
is still formatted, so a check before a commit wants `tfg validate` beside
95+
it. Both the help and the flag description now say so.
96+
- **A PNG asking for more pixels than it can hold is the caller's request, not
97+
a fault in the tool.** It ended with 1, which tells CI this build is broken,
98+
for a pair of numbers somebody chose. It now ends with 4, the same as every
99+
other value a format cannot deliver. `tfg formats png` also states the limit
100+
on the two dimensions multiplied - it offered each side up to 20000 without
101+
saying that both cannot be at their largest at once.
102+
- **`--count 0` or below is reported with the number that was written.** It
103+
used to come back as "asks for 0 files" whatever was typed, because anything
104+
below one produced an empty list and the message described the list.
105+
- **`--out` pointing at a file says so, once.** It produced two messages for
106+
one mistake, the first of them saying there was nothing at a path that had
107+
something at it.
83108
- **A file name ending in a dot or a space is refused.** Windows stores such a
84109
name without the last character, so the file on disk was not the file the
85110
manifest described - `tfg generate --name "report."` ended with 0 and `tfg

‎internal/cli/cli.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ func flagsGiven(fs *flag.FlagSet) map[string]bool {
193193
// meaningless next to a recipe that may hold many.
194194
func describingFlagsGiven(given map[string]bool) []string {
195195
var bad []string
196-
for _, name := range []string{"format", "size", "size-range", "boundary", "count", "name", "id", "set", "expected"} {
196+
for _, name := range []string{"format", "size", "size-range", "boundary", "count", "name", "id", "set", "expected", "expected-reason"} {
197197
if given[name] {
198198
bad = append(bad, "--"+name)
199199
}

‎internal/cli/generate.go‎

Lines changed: 54 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,20 +18,21 @@ import (
1818
)
1919

2020
type generateOpts struct {
21-
formatID string
22-
sizeStr string
23-
sizeRange string
24-
boundary string
25-
count int
26-
outDir string
27-
name string
28-
id string
29-
seed int64
30-
expected string
31-
clean bool
32-
dryRun bool
33-
asJSON bool
34-
props propertyFlag
21+
formatID string
22+
sizeStr string
23+
sizeRange string
24+
boundary string
25+
count int
26+
outDir string
27+
name string
28+
id string
29+
seed int64
30+
expected string
31+
expectedReason string
32+
clean bool
33+
dryRun bool
34+
asJSON bool
35+
props propertyFlag
3536
}
3637

3738
func generateFlagSet(errOut io.Writer, g *generateOpts) (*flag.FlagSet, func(io.Writer)) {
@@ -48,6 +49,8 @@ func generateFlagSet(errOut io.Writer, g *generateOpts) (*flag.FlagSet, func(io.
4849
fs.StringVar(&g.id, "id", "files", "target id, the anchor the seeds are derived from")
4950
fs.Int64Var(&g.seed, "seed", 0, "run seed, the same seed gives the same bytes")
5051
fs.StringVar(&g.expected, "expected", "", "declared expectation: accept, reject, sanitize or unspecified")
52+
fs.StringVar(&g.expectedReason, "expected-reason", "",
53+
"why that outcome is expected, from the closed list. Run with an unknown value to see it")
5154
fs.BoolVar(&g.clean, "clean", false, "turn off the self describing label")
5255
fs.BoolVar(&g.dryRun, "dry-run", false, "count and show, write nothing at all")
5356
fs.BoolVar(&g.asJSON, "json", false, "write the manifest to standard output")
@@ -209,6 +212,16 @@ func targetsFromFlags(g *generateOpts, given map[string]bool, errOut io.Writer)
209212
return nil, ExitUsage
210213
}
211214

215+
// Reported with the number the caller wrote. It used to fall through to the
216+
// planner, which builds an empty list from anything below one and then says
217+
// "asks for 0 files" - a sentence about a number nobody typed.
218+
if given["count"] && g.count < 1 {
219+
fmt.Fprintf(errOut,
220+
"tfg: --count %d asks for fewer than one file. A target that produces nothing is almost always a mistake rather than an intention. Ask for at least one, or leave the flag out to get a single file.\n",
221+
g.count)
222+
return nil, ExitRecipe
223+
}
224+
212225
// Asked before the list is built, because building it is the failure. A
213226
// count past the ceiling used to reach make([]int64) and panic with a stack
214227
// trace under the exit code that means a mistyped flag.
@@ -242,18 +255,34 @@ func targetsFromFlags(g *generateOpts, given map[string]bool, errOut io.Writer)
242255
return nil, ExitUsage
243256
}
244257

258+
// The list is closed so that a report can group by reason, and a typo would
259+
// make a category of one. Same list the recipe uses rather than a second
260+
// copy, because two copies is how the two surfaces drift apart.
261+
if g.expectedReason != "" && !recipe.KnownReason(g.expectedReason) {
262+
fmt.Fprintf(errOut,
263+
"tfg: --expected-reason %q is not on the list. The list is closed so that a report can group by reason. Use one of: %s.\n",
264+
g.expectedReason, strings.Join(recipe.Reasons(), ", "))
265+
return nil, ExitUsage
266+
}
267+
if g.expectedReason != "" && g.expected == "" {
268+
fmt.Fprintln(errOut,
269+
"tfg: --expected-reason says why an outcome is expected and no outcome was given. Add --expected accept, reject, sanitize or unspecified, or drop the reason.")
270+
return nil, ExitUsage
271+
}
272+
245273
return []engine.Target{{
246-
ID: g.id,
247-
Format: g.formatID,
248-
Sizes: sizes,
249-
SizeIsRange: g.sizeRange != "",
250-
SizeMin: rangeLow,
251-
SizeMax: rangeHigh,
252-
BoundaryLimit: boundaryLimit,
253-
NameTmpl: g.name,
254-
Label: !g.clean,
255-
Expected: g.expected,
256-
Properties: g.props,
274+
ID: g.id,
275+
Format: g.formatID,
276+
Sizes: sizes,
277+
SizeIsRange: g.sizeRange != "",
278+
SizeMin: rangeLow,
279+
SizeMax: rangeHigh,
280+
BoundaryLimit: boundaryLimit,
281+
NameTmpl: g.name,
282+
Label: !g.clean,
283+
Expected: g.expected,
284+
ExpectedReason: g.expectedReason,
285+
Properties: g.props,
257286
}}, ExitOK
258287
}
259288

‎internal/cli/recipecmd.go‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,10 +202,15 @@ func recipeCmd(args []string, out, errOut io.Writer) int {
202202
fs := flag.NewFlagSet("recipe fmt", flag.ContinueOnError)
203203
fs.SetOutput(errOut)
204204
write := fs.Bool("w", false, "write the result back to the file instead of printing it")
205-
check := fs.Bool("check", false, "print nothing and end with code 3 when the file is not in its settled shape")
205+
check := fs.Bool("check", false, "print nothing and end with code 3 when the layout is not settled. It says nothing about whether the recipe is valid - use tfg validate for that")
206206
usage := func(w io.Writer) {
207207
fmt.Fprint(w, `tfg recipe fmt - print a recipe in its settled shape, comments kept.
208208
209+
This settles the layout of a file. It does not check that the recipe makes
210+
sense - a file with a key nobody recognises or a format that does not exist
211+
still has a settled shape, and this will print it. Run "tfg validate" for
212+
that, and run both if you are checking recipes before a commit.
213+
209214
Usage:
210215
tfg recipe fmt <recipe.yaml>
211216

‎internal/engine/engine.go‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,17 @@ func preflight(files []PlannedFile, opt Options) error {
396396
// A failure to read the free space is not a reason to refuse. A disk we
397397
// cannot measure is not the same as a disk that is full.
398398

399+
// Pointing --out at a file rather than a directory is a mistake somebody
400+
// makes, and it used to arrive as two messages about one fault, the first
401+
// of them saying "there is nothing at that path" about a path that has
402+
// something at it. The system reports ENOTDIR and our mapping only knew
403+
// "missing", "no permission" and "already there".
404+
if info, err := os.Stat(opt.OutDir); err == nil && !info.IsDir() {
405+
return &RecipeError{Detail: fmt.Sprintf(
406+
"the output directory %s is a file, not a directory. Point --out at a directory, or at one that does not exist yet and it will be created",
407+
opt.OutDir)}
408+
}
409+
399410
// The manifest is checked with the files it would describe, and leaving it
400411
// out cost exactly what it protects. A second run into the same directory
401412
// wrote a fresh manifest over the old one, so every file the old one listed

‎internal/format/png/png.go‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -91,15 +91,22 @@ func init() {
9191
Label: format.LabelVisible,
9292
Oracle: "pillow",
9393
Properties: []format.Property{
94+
// The declaration bounds each side on its own and cannot say that
95+
// the two multiplied have a limit as well, so the sentence carries
96+
// it. Without that, "tfg formats png" offered 20000 by 20000 and
97+
// the run then refused it - the tool advertising a pair it does not
98+
// accept. Naming the whole rule in the registry needs a shape for a
99+
// limit on two settings at once, which is a change to AR9 rather
100+
// than a line here. Recorded in docs/OBSERVATIONS.md, O45.
94101
{
95102
Name: "width", Kind: format.PropertyInt,
96103
Min: minDimension, Max: maxDimension, Unit: "pixels",
97-
Detail: "How wide the picture is. Left out, a size is chosen that fits the bytes you asked for.",
104+
Detail: "How wide the picture is. Left out, a size is chosen that fits the bytes you asked for. Width times height cannot pass 40 megapixels, so both sides cannot be at their largest at once.",
98105
},
99106
{
100107
Name: "height", Kind: format.PropertyInt,
101108
Min: minDimension, Max: maxDimension, Unit: "pixels",
102-
Detail: "How tall the picture is. Left out, a size is chosen that fits the bytes you asked for.",
109+
Detail: "How tall the picture is. Left out, a size is chosen that fits the bytes you asked for. Width times height cannot pass 40 megapixels, so both sides cannot be at their largest at once.",
103110
},
104111
},
105112
GeneratorVersion: generatorVersion,
@@ -275,10 +282,20 @@ func chooseSize(r format.Request, label string) (memo, bool, error) {
275282
return memo{}, true, err
276283
}
277284
_, _ = wRaw, hRaw
285+
// A PropertyValueError rather than a plain one, and the difference is
286+
// the exit code somebody's CI reads. This used to end with 1, which
287+
// means the tool itself broke, for a pair of numbers the caller chose.
288+
// That is the same defect closed for declared ranges on 2026-08-03,
289+
// surviving one layer deeper: the declaration bounds each dimension on
290+
// its own and cannot express a limit on the two multiplied.
278291
if int64(w)*int64(h) > maxPixels {
279-
return memo{}, true, fmt.Errorf(
280-
"png: %dx%d is %d megapixels and the limit is %d - the picture is held in memory while it is encoded. Ask for smaller dimensions",
281-
w, h, int64(w)*int64(h)/1_000_000, maxPixels/1_000_000)
292+
return memo{}, true, &format.PropertyValueError{
293+
Format: "png", Key: "width and height",
294+
Value: fmt.Sprintf("%dx%d", w, h),
295+
Reason: fmt.Sprintf(
296+
"together they come to %d megapixels and the limit is %d, because the picture is held in memory while it is encoded. Each side may go up to %d, but not both at once - ask for a smaller pair",
297+
int64(w)*int64(h)/1_000_000, maxPixels/1_000_000, maxDimension),
298+
}
282299
}
283300
m := memo{width: w, height: h, seed: r.Seed, label: label}
284301
body, err := encodedBodySize(m)

‎internal/format/zip/zip.go‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -557,23 +557,34 @@ func intProperty(props map[string]string, key string, fallback, min, max int) (i
557557

558558
// minimumBytes is the smallest archive this generator can produce: one empty
559559
// entry, no label, no padding.
560+
// It panics rather than returning a number nobody measured.
561+
//
562+
// This used to answer 1<<62 on any failure, on the reasoning that refusing
563+
// every size is safer than declaring a wrong minimum. It is not safer, it is
564+
// quieter: ZIP would refuse every request ever made with a message about a
565+
// minimum of 4611686018427387904 B, and nothing would say why. That is rule 6
566+
// broken in the way this project keeps finding.
567+
//
568+
// The condition is a programming mistake rather than a runtime one. It needs
569+
// the default entry format to be unregistered when this runs, which today
570+
// depends on "txt" sorting before "zip" in the import list of format/all -
571+
// true, and true by accident. format.Register already panics on the same class
572+
// of mistake, so this matches it: a build that cannot state its own minimum
573+
// fails at start rather than at every use.
560574
func minimumBytes() int64 {
561575
desc, err := format.Get(defaultEntryFmt)
562576
if err != nil {
563-
// The default entry format is not registered yet, which happens only
564-
// if registration order changes. Refusing every size is safer than
565-
// declaring a minimum that was never measured.
566-
return 1 << 62
577+
panic(fmt.Sprintf("zip: the default entry format %q is not registered yet, so the minimum size of an archive cannot be worked out. Check the import order in internal/format/all", defaultEntryFmt))
567578
}
568579
cp, err := desc.Generator.Plan(format.Request{Bytes: 0, Seed: 0, Label: false})
569580
if err != nil {
570-
return 1 << 62
581+
panic(fmt.Sprintf("zip: the default entry format %q cannot produce an empty file, so the minimum size of an archive cannot be worked out: %v", defaultEntryFmt, err))
571582
}
572583
n, err := archiveSize(memo{children: []child{{
573584
name: "txt_0001.txt", desc: desc, plan: cp,
574585
}}})
575586
if err != nil {
576-
return 1 << 62
587+
panic(fmt.Sprintf("zip: the smallest archive cannot be measured: %v", err))
577588
}
578589
return n
579590
}

0 commit comments

Comments
 (0)