Skip to content

Commit 5f16e1f

Browse files
donislawdevclaude
andcommitted
damage: files can be broken on purpose, and something has to refuse them
The tool answered questions about size and about name. It did not answer the third question an upload validator asks - "does it open" - because every file it wrote was well formed by definition. A target now takes damage, as a list from the first day because composition is a requirement rather than an extension: damage: [zero-head] damage: [{type: zero-head, bytes: 16}] and on the command line --damage zero-head:bytes=16, repeatable and applied in the order given. The file still comes out exactly the size asked for. The manifest records what was done to it and says the file is expected to be rejected. zero-head is the one damage in this build. It was chosen because all twenty four formats have a judge that refuses the result and because it does not change the length, so it does not touch the size arithmetic. Three things the architecture note said turned out to be wrong, and each changed the design rather than only the prose. A witness is not a property of the pair (format, damage). It is a function of the format, the damage, the SIZE and the parameter VALUES. Measured: truncate-half has witnesses at 20 kB and none at 4 B, and zeroing one byte has 20 witnesses of 24 where four bytes have all of them. That is why the bytes parameter starts at 4 - the bound belongs to somebody else's reader, the same rule the column ceiling followed. "The comparison is free because we compute the checksum anyway" was false. The write path computes ONE checksum, so before-and-after would cost a second sha256 per file. The damage knows for free instead: zero-head has to read the bytes before it overwrites them. So the damage answers whether it moved anything, not the engine - and it answers per step, because two damages can cancel out. Measured that this is reachable rather than theoretical: ico, avif and jxl all begin with zero bytes. The collision between an automatic expectation and one written in a recipe was settled nowhere. It is now a refusal, and only for accept: reject is what damage means, while sanitize and unspecified are both sensible questions about a broken file. Damage sits between the generator and the counter, which gives three things without building them: the checksum describes the bytes on disk, the existing size check counts the FINAL bytes, and progress counts what will really be there. Nineteen guards, nineteen mutations, all caught. The one worth naming is the reverse of every other oracle guard here - it asks whether a judge REFUSES what we wrote, and its control runs first, because without it the test passes for a build whose judge refuses everything. Three types moved state out rather than growing past the crowding band, and one of them turned out to be a better shape anyway: SizeIsRange, SizeMin and SizeMax were always one statement, so engine.Target and recipe.Target now carry a SizeRange. That refactor invalidated six mutation entries and only staleness.py said so - a pattern that no longer matches reports SKIP, which reads as proven. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent dde2cd9 commit 5f16e1f

61 files changed

Lines changed: 2814 additions & 130 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -600,7 +600,7 @@ jobs:
600600
# in somebody else's file.
601601
run: |
602602
set -euo pipefail
603-
watched='internal/format/registry.go cmd/tfg/main.go internal/gui/window/run.go internal/audit/parallel.go internal/engine/parallel.go go.mod'
603+
watched='internal/format/registry.go internal/damage/damage.go cmd/tfg/main.go internal/gui/window/run.go internal/audit/parallel.go internal/engine/parallel.go go.mod'
604604
# On a pull request there is no "before" - the field belongs to a push
605605
# - so this asked for something empty and every pull request answered
606606
# "touched". That quietly undid the decision of 2026-08-20, because

‎CHANGELOG.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,44 @@ because it turns other people's test suites red.
5858

5959
### Added
6060

61+
- **Files can be broken on purpose.** A target takes `damage`, and the files it
62+
produces are ones a reader refuses:
63+
64+
```yaml
65+
targets:
66+
- id: broken-uploads
67+
format: png
68+
size: 20kb
69+
count: 10
70+
damage: [zero-head]
71+
```
72+
73+
or from the command line, repeatable and applied in the order given:
74+
75+
```
76+
tfg generate --format png --size 20kb --damage zero-head:bytes=16
77+
```
78+
79+
Until now every file this tool wrote was well formed, so the third question
80+
an upload validator asks - "does it open" - was one nothing here could put to
81+
it.
82+
83+
The file still comes out **exactly** the size you asked for. What changes is
84+
the content, and the manifest records what was done to it and says the file
85+
is expected to be rejected.
86+
87+
There is one damage in this release, `zero-head`, which overwrites the
88+
opening bytes with zeros. It was chosen because all twenty four formats have
89+
something that refuses the result - measured, not assumed - and because it
90+
does not change the length. `tfg formats` is unchanged and no existing file
91+
moves a byte: a run that does not ask for damage goes down the path it always
92+
did.
93+
94+
Two things it refuses rather than doing quietly. A file smaller than the
95+
damage is refused before anything is written, naming a size that would work.
96+
And a damage that would leave the bytes untouched stops the run, because a
97+
whole file described as broken is worse than no file at all.
98+
6199
- **HTML files can be a fragment instead of a whole page.** A new setting on
62100
`html`: `structure`, which takes `document` or `fragment`. It defaults to the
63101
whole page these files have always been, so a recipe that says nothing gets

‎internal/cli/cli.go‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"strings"
1717
"syscall"
1818

19+
"github.com/donislawdev/TestingFilesGenerator/internal/damage"
1920
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
2021
"github.com/donislawdev/TestingFilesGenerator/internal/format"
2122
_ "github.com/donislawdev/TestingFilesGenerator/internal/format/all"
@@ -268,6 +269,70 @@ func (p propertyFlag) Set(v string) error {
268269
return nil
269270
}
270271

272+
// repeatedFlags are the flags a person may write more than once.
273+
//
274+
// One piece rather than two fields on the options struct, because the type was
275+
// at the crowding band and the answer to that is to move state out. They belong
276+
// together anyway: both map onto a block of a recipe rather than onto a single
277+
// line of one.
278+
type repeatedFlags struct {
279+
props propertyFlag
280+
damage damageFlag
281+
}
282+
283+
// damageFlag collects repeated --damage entries, in the order they were given.
284+
//
285+
// A list rather than a map, which is the difference from --set beside it:
286+
// order is part of what a chain of damages means, and the same damage twice
287+
// with different settings is a legitimate thing to ask for. --set refuses a
288+
// repeat because one of two values would be lost silently, and here neither is.
289+
//
290+
// The settings ride after a colon so one entry stays one argument:
291+
//
292+
// --damage zero-head
293+
// --damage zero-head:bytes=16
294+
// --damage zero-head:bytes=16,other=2
295+
type damageFlag struct{ chain damage.Chain }
296+
297+
func (d *damageFlag) String() string { return d.chain.String() }
298+
299+
func (d *damageFlag) Set(v string) error {
300+
id, settings, hasSettings := strings.Cut(v, ":")
301+
if id == "" {
302+
return fmt.Errorf("expected the name of a damage, got %q", v)
303+
}
304+
values, err := damageSettings(id, settings, hasSettings)
305+
if err != nil {
306+
return err
307+
}
308+
d.chain = append(d.chain, damage.Spec{ID: id, Values: values})
309+
return nil
310+
}
311+
312+
// damageSettings reads the name=value pairs after the colon.
313+
//
314+
// Its own function rather than a block inside Set, because the shape gates
315+
// count how deep a reader has to follow and this was the third level.
316+
func damageSettings(id, settings string, stated bool) (damage.Values, error) {
317+
values := damage.Values{}
318+
if !stated {
319+
return values, nil
320+
}
321+
for _, pair := range strings.Split(settings, ",") {
322+
key, value, found := strings.Cut(pair, "=")
323+
if !found || key == "" {
324+
return nil, fmt.Errorf("expected name=value after the colon, got %q", pair)
325+
}
326+
if _, exists := values[key]; exists {
327+
// The same reason --set gives: one of the two would be lost and
328+
// nobody would know which.
329+
return nil, fmt.Errorf("%s is set more than once on %s", key, id)
330+
}
331+
values[key] = value
332+
}
333+
return values, nil
334+
}
335+
271336
// args2 rebuilds the command as it would have to be typed to run again.
272337
//
273338
// It goes into the manifest, where its whole job is to be re-runnable, and it

‎internal/cli/errors.go‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"syscall"
1313

1414
"github.com/donislawdev/TestingFilesGenerator/internal/audit"
15+
"github.com/donislawdev/TestingFilesGenerator/internal/damage"
1516
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
1617
"github.com/donislawdev/TestingFilesGenerator/internal/format"
1718
"github.com/donislawdev/TestingFilesGenerator/internal/manifest"
@@ -201,6 +202,21 @@ func classifyRequest(err error) (int, bool) {
201202
if errors.As(err, &unknownPreset) {
202203
return ExitUsage, true
203204
}
205+
// A damage this build does not know is a typo in the invocation, the same
206+
// class as an unknown preset - a recipe naming one is refused while the
207+
// recipe is read, so anything reaching here came off the command line.
208+
var unknownDamage *damage.UnknownError
209+
if errors.As(err, &unknownDamage) {
210+
return ExitUsage, true
211+
}
212+
// A file smaller than the damage it was given is the same class as a size
213+
// below a format's minimum: the request is well formed and nothing here can
214+
// deliver it. It gets the same code for that reason rather than by
215+
// resemblance.
216+
var tooSmall *damage.TooSmallError
217+
if errors.As(err, &tooSmall) {
218+
return ExitFormat, true
219+
}
204220
if code, ok := classifyFormat(err); ok {
205221
return code, true
206222
}

‎internal/cli/generate.go‎

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"strings"
1212

1313
"github.com/donislawdev/TestingFilesGenerator/internal/core"
14+
"github.com/donislawdev/TestingFilesGenerator/internal/damage"
1415
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
1516
"github.com/donislawdev/TestingFilesGenerator/internal/format"
1617
"github.com/donislawdev/TestingFilesGenerator/internal/manifest"
@@ -37,7 +38,11 @@ type generateOpts struct {
3738
clean bool
3839
dryRun bool
3940
asJSON bool
40-
props propertyFlag
41+
// The flags a person may write more than once, in one piece rather than
42+
// two fields. They are one thing on the screen and one thing in a recipe -
43+
// what was stated repeatedly - and grouping them is what moving state out
44+
// means when a type is at the crowding band.
45+
repeated repeatedFlags
4146
}
4247

4348
func generateFlagSet(errOut io.Writer, g *generateOpts) (*flag.FlagSet, func(io.Writer)) {
@@ -65,8 +70,19 @@ func generateFlagSet(errOut io.Writer, g *generateOpts) (*flag.FlagSet, func(io.
6570
// Twenty five formats with a dozen properties each would give a surface
6671
// nobody reads in --help, and this maps one to one onto the properties
6772
// block of a recipe, so both surfaces speak the same words.
68-
g.props = propertyFlag{}
69-
fs.Var(&g.props, "set", "format property, repeatable: --set width=1920 --set height=1080")
73+
g.repeated.props = propertyFlag{}
74+
fs.Var(&g.repeated.props, "set", "format property, repeatable: --set width=1920 --set height=1080")
75+
76+
// Repeatable like --set, and for the same reason, but a list rather than a
77+
// map: the order damages are applied in is part of what they mean.
78+
// The names come from the registry rather than being typed here. The first
79+
// version of this line ended "run tfg damage to see what there is" and
80+
// there is no such command - a sentence in shipped help promising
81+
// something that does not exist, which is the class this project calls
82+
// prose with an expiry date. Built from Names() it cannot say that again.
83+
fs.Var(&g.repeated.damage, "damage", "break the files on purpose, repeatable and applied in order: "+
84+
"--damage zero-head, or --damage zero-head:bytes=16. This build has: "+
85+
strings.Join(damage.Names(), ", "))
7086

7187
usage := func(w io.Writer) {
7288
fmt.Fprint(w, `tfg generate - produce files.
@@ -312,15 +328,14 @@ func targetsFromFlags(g *generateOpts, given map[string]bool, errOut io.Writer)
312328
ID: g.id,
313329
Format: g.formatID,
314330
Sizes: sizes,
315-
SizeIsRange: g.sizeRange != "",
316-
SizeMin: rangeLow,
317-
SizeMax: rangeHigh,
331+
Range: engine.SizeRange{Used: g.sizeRange != "", Min: rangeLow, Max: rangeHigh},
318332
BoundaryLimit: boundaryLimit,
319333
NameTmpl: g.name,
320334
Label: !g.clean,
321335
Expected: g.expected,
322336
ExpectedReason: g.expectedReason,
323-
Properties: g.props,
337+
Properties: g.repeated.props,
338+
Damage: g.repeated.damage.chain,
324339
}}, ExitOK
325340
}
326341

@@ -599,16 +614,15 @@ func engineTarget(t recipe.Target, label bool) engine.Target {
599614
Sizes: t.Sizes,
600615
Contains: contentsOf(t),
601616
SizeFromContents: t.SizeFromContents,
602-
SizeIsRange: t.SizeIsRange,
603-
SizeMin: t.SizeMin,
604-
SizeMax: t.SizeMax,
617+
Range: engine.SizeRange(t.Range),
605618
BoundaryLimit: t.BoundaryLimit,
606619
NameTmpl: t.Name,
607620
Label: label,
608621
Expected: t.Expected,
609622
ExpectedReason: t.ExpectedReason,
610623
Group: t.Group,
611624
Properties: t.Properties,
625+
Damage: t.Damage,
612626
}
613627
}
614628

0 commit comments

Comments
 (0)