Skip to content

Commit dd5ffca

Browse files
donislawdevclaude
andcommitted
recipe: a recipe can build on a preset, on the command line and on the batch screen
extends: preset:<id> names a preset and with: fills its parameters, written the way the flags take them. The preset's targets come first and the file's own after them, so the file is the same run as "tfg preset eject" with the extra targets typed under it, byte for byte - PR5 in a second form, and a guard runs both roads and compares the files. A target whose id the preset already uses is refused at the file's own line, with without extends is refused, and extends names a preset and nothing else yet. A file with extends and no targets of its own is legal: it is a preset run committed to a repository. The owner's three decisions, in docs/EXTENDS-WITH-2026-09-22.md: the window in the same change, the recipe hash of the file as written, the clash addressed at the file's target. The recipe package cannot expand a preset - the layer rule lets preset import recipe and not the other way - so reading such a file is two steps with the expansion between them. recipe.ExtensionOf says what the file builds on, recipe.ParseExtending reads it with the expansion in front of its targets, and preset.ReadRecipe joins the two; a guard holds cli and gui to reading files through that door. The merge is at the raw level, before validation, so every rule about targets runs once where it lives, and a refusal about the file's target carries its position in the file, not in the merged list. recipe.CheckBase holds every preset to contributing a version and targets and nothing else, asked of each registered preset by a guard before any recipe builds on one. Refusals from the preset side become recipe problems addressed to extends or with.<name>, in four parts - which found a refused value arriving with no remedy, now carried from the declaration. The manifest records the preset under run.preset as a --preset run does, validate --json carries the same block, and the batch screen gains a section: a switch, the preset, and its parameters drawn from the declaration the preset screen already draws. Parity 122 of 130. Five guards, nine mutations, one more stored screen. Noticed on the way and recorded as O231, not changed: the batch screen's label switch starts off while the other two surfaces have the label on. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 350dd52 commit dd5ffca

34 files changed

Lines changed: 2174 additions & 108 deletions

‎CHANGELOG.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,27 @@ because it turns other people's test suites red.
1414

1515
## [Unreleased]
1616

17+
### Added
18+
19+
- **A recipe can build on a preset.** Two keys the recipe reader used to
20+
refuse as not built yet now work: `extends: preset:<id>` names the preset
21+
and `with:` fills its parameters, written the way the flags take them
22+
(`limit: 5mb`, `spread: 1B,1kb,1mb`, `format: png`). The preset's files
23+
come first and the recipe's own `targets` are added after them, so the
24+
file is the same run as `tfg preset eject` with the extra targets typed
25+
under it, byte for byte, and shorter. A target whose `id` the preset
26+
already uses is refused rather than replaced, `with` without `extends` is
27+
refused, and `extends` names a preset and nothing else yet - not another
28+
file. A recipe with `extends` and no `targets` of its own is legal, which
29+
is how a preset run is committed to a repository. The manifest records the
30+
preset under `run.preset` with the parameters left out listed as
31+
`defaulted`, as a `--preset` run does, and `run.recipe_hash` is the hash
32+
of the file as written. `tfg validate --json` carries the same `preset`
33+
block. On the desktop window, the batch screen has a section "Build on a
34+
preset": a switch, the preset, and its parameters drawn under it. A preset
35+
flag beside a recipe file (`tfg generate r.yaml --limit 5mb`) is refused
36+
with a sentence saying the value goes under `with`.
37+
1738
### Changed
1839

1940
- **The line before the second start says what was checked, not what was

‎README.md‎

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,8 @@ tfg generate fixtures.yaml
428428
| `seed` | the number that makes a run repeatable. Same seed, same bytes |
429429
| `defaults.label` | write the self describing label inside each file. Default `true` |
430430
| `targets` | the list of things to produce. See below |
431+
| `extends` | a preset to build on, as `preset:size-boundaries`. Its files come first and your `targets` are added after them. See [Building on a preset](#building-on-a-preset) |
432+
| `with` | the preset's parameters, written the way the flags take them: `limit: 5mb`, `spread: 1B,1kb,1mb`, `format: png`. One left out stands in from its default, and the manifest says so |
431433
| `output.dir` | where the files and the manifest go. A relative path is read from the directory you run in, not from the one the recipe sits in |
432434
| `output.manifest` | manifest file name. Default `manifest.json` |
433435

@@ -483,11 +485,39 @@ A reason names **the rule in play**, not the verdict. That is why the same
483485
reason can sit under either outcome - a file one byte under a limit is
484486
`accept`, and the rule it is about is still `size_limit`.
485487

488+
### Building on a preset
489+
490+
A recipe can start from a preset's set and add its own files:
491+
492+
```yaml
493+
version: 1
494+
seed: 7
495+
extends: preset:size-boundaries
496+
with:
497+
limit: 5mb
498+
format: png
499+
targets:
500+
- id: our-legacy-format
501+
format: tiff
502+
count: 1
503+
size: 3mb
504+
```
505+
506+
This is the same run as `tfg preset eject size-boundaries --limit 5mb --format png`
507+
with the extra target typed under it, byte for byte. The file is shorter, it
508+
says which question the set answers, and the manifest records the preset under
509+
`run.preset` with the parameters you left out listed as `defaulted`. A target
510+
whose `id` the preset already uses is refused, never silently replaced. A
511+
recipe with `extends` and no `targets` of its own is legal, and it is how a
512+
preset run is committed to a repository. The `--limit` and other preset flags
513+
do not apply beside a recipe file, and the recipe's `with` is where they go.
514+
486515
### Not built yet
487516

488517
These keys are recognised and **refused with a message saying so**, never
489-
ignored quietly: `extends`, `with`, `policy`, `engine`, `defaults.fill`,
490-
`fill` on a target, `mutations`, `output.split_threshold`.
518+
ignored quietly: `policy`, `engine`, `defaults.fill`, `fill` on a target,
519+
`output.split_threshold`. `extends` names a preset and nothing else yet, so a
520+
recipe cannot build on another file.
491521

492522
## 📁 Formats in detail
493523

‎internal/cli/generate.go‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ func generate(ctx context.Context, args []string, out, errOut io.Writer) int {
186186

187187
// targetsFromRecipe reads the recipe and settles what the flags override.
188188
func targetsFromRecipe(path string, g *generateOpts, given map[string]bool, opt *engine.Options, errOut io.Writer) ([]engine.Target, int) {
189-
rec, hash, code := loadRecipe(path, errOut)
189+
read, hash, code := loadRecipe(path, errOut)
190190
if code != ExitOK {
191191
return nil, code
192192
}
@@ -200,7 +200,15 @@ func targetsFromRecipe(path string, g *generateOpts, given map[string]bool, opt
200200
return nil, ExitUsage
201201
}
202202

203-
return targetsFromParsedRecipe(rec, hash, g, given, opt), ExitOK
203+
// A file that builds on a preset is recorded the way a --preset run is,
204+
// and says the same things out loud - which of the preset's numbers were
205+
// invented because the file did not give them. Nil for a file that stands
206+
// alone, and the manifest field stays absent.
207+
for _, note := range read.Notes() {
208+
fmt.Fprintf(errOut, "note: %s\n", note)
209+
}
210+
opt.Preset = record(read.Expansion)
211+
return targetsFromParsedRecipe(read.Recipe, hash, g, given, opt), ExitOK
204212
}
205213

206214
// targetsFromParsedRecipe settles what the flags take away from a recipe that

‎internal/cli/preset.go‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,13 @@ import (
2828
// which is the output contract and no business of an input concept. The drift
2929
// this invites is watched behaviourally instead - a guard runs the same preset
3030
// from both surfaces and compares the records they produce.
31+
//
32+
// Nil in, nil out: a recipe file that stands alone has no preset to record,
33+
// and the manifest field is absent rather than empty for it.
3134
func record(e *preset.Expansion) *manifest.Preset {
35+
if e == nil {
36+
return nil
37+
}
3238
return &manifest.Preset{
3339
ID: e.Preset.ID,
3440
Parameters: map[string]string(e.Settled),
@@ -270,9 +276,12 @@ func explainUndefinedFlag(fs *flag.FlagSet, args []string, errOut io.Writer) boo
270276
if name == "" {
271277
return false
272278
}
279+
// The second sentence names both roads, because since 2026-09-22 a
280+
// recipe file can build on the preset too - and beside a file the flag
281+
// does not exist either, the file's with section is where the value goes.
273282
fmt.Fprintf(errOut,
274-
"tfg: --%s is a parameter of the preset %s, so it only exists beside it. Add --preset %s, or drop --%s.\n",
275-
name, owner, owner, name)
283+
"tfg: --%s is a parameter of the preset %s, so it only exists beside it. Add --preset %s, put %s under with: in a recipe that extends it, or drop --%s.\n",
284+
name, owner, owner, name, name)
276285
return true
277286
}
278287

‎internal/cli/presetcmd.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,11 @@ func describePreset(e *preset.Expansion, b budget, out io.Writer) {
241241
fmt.Fprintf(out, " - %s\n", c)
242242
}
243243
}
244-
fmt.Fprintf(out, "\nRun \"tfg preset eject %s\" for the recipe, or \"tfg generate --preset %s\" to produce the files.\n",
245-
p.ID, p.ID)
244+
// Three roads to the same set, and all three are named: the third
245+
// arrived on 2026-09-22 and a setting nobody knows they can reach is a
246+
// setting that is not there.
247+
fmt.Fprintf(out, "\nRun \"tfg preset eject %s\" for the recipe, \"tfg generate --preset %s\" to produce the files, or write \"extends: preset:%s\" in a recipe of your own.\n",
248+
p.ID, p.ID, p.ID)
246249
}
247250

248251
func presetEject(args []string, out, errOut io.Writer) int {

‎internal/cli/recipecmd.go‎

Lines changed: 43 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,28 @@ import (
1212

1313
"github.com/donislawdev/TestingFilesGenerator/internal/core"
1414
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
15+
"github.com/donislawdev/TestingFilesGenerator/internal/manifest"
16+
"github.com/donislawdev/TestingFilesGenerator/internal/preset"
1517
"github.com/donislawdev/TestingFilesGenerator/internal/recipe"
1618
)
1719

18-
func loadRecipe(path string, errOut io.Writer) (*recipe.Recipe, string, int) {
20+
// loadRecipe reads a recipe file for a run, through the door that knows
21+
// presets: a file that builds on one comes back with the preset it built on,
22+
// so the run can record which numbers were the preset's own.
23+
//
24+
// The hash is of the file as written, whether or not it builds on a preset.
25+
// It answers the question a pipeline asks - was this manifest made from the
26+
// recipe committed here - and the manifest's preset record, with the tool's
27+
// version beside it, says the rest. The owner's decision of 2026-09-22, in
28+
// docs/EXTENDS-WITH-2026-09-22.md section 2.4.
29+
func loadRecipe(path string, errOut io.Writer) (*preset.Read, string, int) {
1930
src, err := readRecipe(path)
2031
if err != nil {
2132
said, code := recipeReadFailure(path, err)
2233
fmt.Fprintf(errOut, "tfg: %s\n", said)
2334
return nil, "", code
2435
}
25-
rec, err := recipe.Parse(src, path)
36+
read, err := preset.ReadRecipe(src, path)
2637
if err != nil {
2738
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
2839
return nil, "", classify(err)
@@ -32,7 +43,7 @@ func loadRecipe(path string, errOut io.Writer) (*recipe.Recipe, string, int) {
3243
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
3344
return nil, "", classify(err)
3445
}
35-
return rec, hash, ExitOK
46+
return read, hash, ExitOK
3647
}
3748

3849
// validate runs the checks a run would run and writes nothing at all, so it
@@ -66,10 +77,11 @@ func validate(ctx context.Context, args []string, out, errOut io.Writer) int {
6677
return ExitUsage
6778
}
6879

69-
rec, hash, code := loadRecipeReporting(path, *asJSON, errOut)
80+
read, hash, code := loadRecipeReporting(path, *asJSON, errOut)
7081
if code != ExitOK {
7182
return code
7283
}
84+
rec := read.Recipe
7385

7486
// The schema and the semantics both passed. Planning is what proves the
7587
// rest: a size below the minimum of its format, a format nobody
@@ -85,18 +97,31 @@ func validate(ctx context.Context, args []string, out, errOut io.Writer) int {
8597
return planningRefusal(err, path, *asJSON, errOut)
8698
}
8799

100+
// A file that builds on a preset says so here the way the manifest will
101+
// say it, so a pipeline reading this report and one reading the manifest
102+
// see one record. The notes go where they go on a run: to a person, on
103+
// standard error, because a number the preset invented is something to
104+
// know before the files exist as well as after.
105+
for _, note := range read.Notes() {
106+
fmt.Fprintf(errOut, "note: %s\n", note)
107+
}
108+
88109
if *asJSON {
89110
return writeJSON(out, errOut, validateReport{
90111
Recipe: path, Valid: true, RecipeHash: hash,
91112
Targets: len(rec.Targets), Files: len(planned),
92113
TotalBytes: engine.TotalBytes(planned),
114+
Preset: record(read.Expansion),
93115
Problems: []validateProblem{},
94116
}, ExitOK)
95117
}
96118

97119
fmt.Fprintf(out, "%s is valid: %s, %s, %s total\n%s\n",
98120
path, core.Count(len(rec.Targets), "target", "targets"), core.Count(len(planned), "file", "files"),
99121
core.ExactBytes(engine.TotalBytes(planned)), hash)
122+
if read.Expansion != nil {
123+
fmt.Fprintf(out, "built on preset %s\n", read.Expansion.Preset.ID)
124+
}
100125
return ExitOK
101126
}
102127

@@ -138,13 +163,17 @@ func planningOptions(rec *recipe.Recipe) engine.Options {
138163
// rather than as one blob of prose, because RC7 already reports them all at
139164
// once and a script should not have to split the message back apart.
140165
type validateReport struct {
141-
Recipe string `json:"recipe"`
142-
Valid bool `json:"valid"`
143-
RecipeHash string `json:"recipe_hash,omitempty"`
144-
Targets int `json:"targets,omitempty"`
145-
Files int `json:"files,omitempty"`
146-
TotalBytes int64 `json:"total_bytes,omitempty"`
147-
Problems []validateProblem `json:"problems"`
166+
Recipe string `json:"recipe"`
167+
Valid bool `json:"valid"`
168+
RecipeHash string `json:"recipe_hash,omitempty"`
169+
Targets int `json:"targets,omitempty"`
170+
Files int `json:"files,omitempty"`
171+
TotalBytes int64 `json:"total_bytes,omitempty"`
172+
// Preset is the preset the recipe builds on, in the shape the manifest
173+
// records it - id, settled parameters, and which of them stood in from
174+
// their defaults. Absent when the recipe stands alone.
175+
Preset *manifest.Preset `json:"preset,omitempty"`
176+
Problems []validateProblem `json:"problems"`
148177
}
149178

150179
// validateProblem carries the three parts every refusal in this tool has: what
@@ -212,7 +241,7 @@ func addressOf(err error) string {
212241
// loadRecipeReporting is loadRecipe with the option of a machine readable
213242
// refusal. A recipe with five problems has to arrive as five entries, not as
214243
// one string a script would have to take apart.
215-
func loadRecipeReporting(path string, asJSON bool, errOut io.Writer) (*recipe.Recipe, string, int) {
244+
func loadRecipeReporting(path string, asJSON bool, errOut io.Writer) (*preset.Read, string, int) {
216245
if !asJSON {
217246
return loadRecipe(path, errOut)
218247
}
@@ -222,7 +251,7 @@ func loadRecipeReporting(path string, asJSON bool, errOut io.Writer) (*recipe.Re
222251
return nil, "", writeJSON(errOut, errOut, validateReport{Recipe: path, Valid: false,
223252
Problems: []validateProblem{{What: said}}}, code)
224253
}
225-
rec, err := recipe.Parse(src, path)
254+
read, err := preset.ReadRecipe(src, path)
226255
if err != nil {
227256
report := validateReport{Recipe: path, Valid: false, Problems: []validateProblem{}}
228257
var invalid *recipe.ValidationError
@@ -240,7 +269,7 @@ func loadRecipeReporting(path string, asJSON bool, errOut io.Writer) (*recipe.Re
240269
return nil, "", writeJSON(errOut, errOut, validateReport{Recipe: path, Valid: false,
241270
Problems: []validateProblem{{What: err.Error()}}}, classify(err))
242271
}
243-
return rec, hash, ExitOK
272+
return read, hash, ExitOK
244273
}
245274

246275
// recipeCmd groups the operations that work on a recipe file itself rather

0 commit comments

Comments
 (0)