Skip to content

Commit cc54cf9

Browse files
donislawdevclaude
andauthored
damage: the command line says what a damage takes, not only that it exists (#96)
The window drew the settings of a chosen damage from the declaration on the day damage arrived. The command line named the damages and stopped there, so learning that zero-head takes bytes, and that bytes runs from 4 to 4096, meant typing a wrong value and reading the refusal. The parity guard cannot see that: it counts recipe keys, formats and presets, and a damage parameter is none of the three. tfg damage lists what this build can break, with the smallest file each one takes and the settings it accepts, and tfg damage <id> describes one. Both have --json, carrying the parameters under the same key and in the same shape tfg preset show already uses, because a damage parameter is a format property. Three things it refuses to do quietly. The smallest file follows the settings, so the column is measured with the defaults and says so - the same column got this wrong once for formats, announcing a floor that runs refused. An unknown name ends with 2 rather than 4, which is the code the same mistake already got from --damage. And two names at once are refused rather than answered about the first. Four guards, four mutations, all caught. The last of them holds the list that watches --help against the dispatch itself, because adding this command meant remembering two hand kept lists in two files and nothing asked. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 18a9487 commit cc54cf9

8 files changed

Lines changed: 638 additions & 2 deletions

File tree

‎CHANGELOG.md‎

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

5959
### Added
6060

61+
- **A new command says what this build can break: `tfg damage`.**
62+
63+
```
64+
tfg damage every damage, and what each one does to the bytes
65+
tfg damage zero-head what one of them takes
66+
tfg damage --json the same, for a script
67+
```
68+
69+
The window already drew the settings of a chosen damage. The command line named
70+
them and stopped there, so the only way to learn that `zero-head` takes `bytes`,
71+
and that `bytes` runs from 4 to 4096, was to type a wrong value and read the
72+
refusal.
73+
74+
Each one is listed with the smallest file it can be given. That number follows
75+
the settings - for `zero-head` it is the number of bytes you asked it to zero - so
76+
the column is measured with the defaults and says so.
77+
78+
Asking about a damage that does not exist ends with 2, the code that means the
79+
invocation was mistyped, and it is the same code `--damage` already gave for the
80+
same mistake. Asking about two at once is refused rather than answered about the
81+
first.
82+
6183
- **Files can be broken on purpose.** A target takes `damage`, and the files it
6284
produces are ones a reader refuses:
6385

‎README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ tfg cleanup remove the files a manifest lists
238238
tfg recipe fmt print a recipe in its settled shape
239239
tfg preset build a set of files from a named test question
240240
tfg formats list the formats this build supports
241+
tfg damage list the ways this build can break a file on purpose
241242
tfg version print the tool version
242243
tfg license print the licence and what it means for generated files
243244
```
@@ -326,6 +327,29 @@ tfg formats [--json] every format, with fidelity, determinism and smallest s
326327
tfg formats <id> what a single format accepts
327328
```
328329

330+
### `tfg damage`
331+
332+
```
333+
tfg damage [--json] every damage, with the smallest file it takes and its settings
334+
tfg damage <id> what one damage does to the bytes, and what it takes
335+
```
336+
337+
A damaged file comes out **exactly** the size you asked for and is one a reader
338+
refuses, so the manifest records it as expected to be rejected. That is the third
339+
question an upload validator asks - does it open - and until damage arrived every
340+
file this tool wrote was well formed by definition.
341+
342+
Use them with `tfg generate --damage <id>`, repeatable and applied in the order
343+
given, or with the `damage` key of a target in a recipe:
344+
345+
```
346+
tfg generate --format png --size 20kb --count 10 --damage zero-head:bytes=16
347+
```
348+
349+
The smallest file a damage can be given follows its settings, so the column is
350+
measured with the defaults. Ask for less and the run is refused before anything is
351+
written, naming a size that would work.
352+
329353
## 📜 Recipes
330354

331355
A recipe is a YAML file describing a whole run. Commit it beside your tests and

‎internal/cli/cli.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ func Run(ctx context.Context, args []string, out, errOut io.Writer) int {
8686
return presetCmd(ctx, args[1:], out, errOut)
8787
case "formats":
8888
return formats(args[1:], out, errOut)
89+
case "damage":
90+
return damageCmd(args[1:], out, errOut)
8991
case "--version", "version":
9092
fmt.Fprintln(out, version.Version)
9193
return ExitOK
@@ -151,6 +153,7 @@ Commands:
151153
recipe fmt print a recipe in its settled shape
152154
preset build a set of files from a named test question
153155
formats list the formats this build supports
156+
damage list the ways this build can break a file on purpose
154157
version print the tool version
155158
license print the licence and what it means for generated files
156159

‎internal/cli/damagecmd.go‎

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
// Part of package cli. See cli.go.
2+
package cli
3+
4+
import (
5+
"flag"
6+
"fmt"
7+
"io"
8+
"strings"
9+
10+
"github.com/donislawdev/TestingFilesGenerator/internal/core"
11+
"github.com/donislawdev/TestingFilesGenerator/internal/damage"
12+
)
13+
14+
// damageEntry is what "tfg damage --json" returns.
15+
//
16+
// The parameters ride in the same propertyEntry the format and the preset lists
17+
// use, under the same key the preset list gives them. That is not tidiness: a
18+
// damage parameter IS a format.Property, so a script that already knows how to
19+
// draw a field from "tfg preset show --json" draws this one with no new code.
20+
type damageEntry struct {
21+
ID string `json:"id"`
22+
Detail string `json:"detail"`
23+
// SmallestFileBytes is the smallest file this damage can be given, with
24+
// its settings left at their defaults.
25+
//
26+
// At the defaults on purpose, because that is the number somebody reading
27+
// this column is about to ask a run for. The floor is a function of the
28+
// settings - zero-head's floor IS its bytes value - so a person who raises
29+
// the setting raises this, and the refusal names the new number rather
30+
// than this one. tfg formats made the other choice once and printed a
31+
// floor that runs refused: measured 2026-08-03, pdf announced 3265 and
32+
// took 3286.
33+
SmallestFileBytes int64 `json:"smallest_file_bytes"`
34+
Parameters []propertyEntry `json:"parameters,omitempty"`
35+
}
36+
37+
// damageEntryFor is one damage as a script sees it.
38+
func damageEntryFor(d damage.Descriptor) damageEntry {
39+
params := make([]propertyEntry, 0, len(d.Parameters))
40+
for _, p := range d.Parameters {
41+
params = append(params, propertyEntry{
42+
Name: p.Name, Kind: string(p.Kind), Min: p.Min, Max: p.Max,
43+
Unit: p.Unit, Choices: p.Choices, Default: p.Default, Detail: p.Detail,
44+
})
45+
}
46+
return damageEntry{
47+
ID: d.ID, Detail: d.Detail,
48+
SmallestFileBytes: defaultFloor(d), Parameters: params,
49+
}
50+
}
51+
52+
// defaultFloor is the smallest file this damage takes when nobody states a
53+
// setting.
54+
//
55+
// Asked of the declaration rather than worked out here, so a damage whose floor
56+
// is arithmetic on two settings answers for itself.
57+
func defaultFloor(d damage.Descriptor) int64 {
58+
return d.Floor(d.Defaults())
59+
}
60+
61+
// damageExample is the flag a person would type to use this damage, with its
62+
// first setting at the declared default.
63+
//
64+
// Built from the declaration rather than written out, because the colon and
65+
// comma syntax is the one thing about this flag nobody guesses, and an example
66+
// naming a setting that no longer exists teaches the wrong thing twice.
67+
func damageExample(d damage.Descriptor) string {
68+
out := "--damage " + d.ID
69+
if len(d.Parameters) > 0 {
70+
p := d.Parameters[0]
71+
value := p.Default
72+
if value == "" {
73+
value = "<value>"
74+
}
75+
out += ":" + p.Name + "=" + value
76+
}
77+
return out
78+
}
79+
80+
// describeOneDamage prints everything one damage declares.
81+
//
82+
// The sentence comes first and the settings after it, which is the other way
83+
// round from a format: a format id says what the file will be and a damage id
84+
// does not, so the sentence is the answer rather than the footnote.
85+
func describeOneDamage(d damage.Descriptor, out io.Writer) {
86+
fmt.Fprintf(out, "%s - smallest file %s with the settings below\n",
87+
d.ID, core.ExactBytes(defaultFloor(d)))
88+
fmt.Fprintf(out, " %s\n", d.Detail)
89+
90+
if len(d.Parameters) == 0 {
91+
fmt.Fprint(out, "\nThis damage takes no settings.\n")
92+
return
93+
}
94+
fmt.Fprintf(out, "\nsettings, written after a colon: %s\n", damageExample(d))
95+
for _, p := range d.Parameters {
96+
fmt.Fprintf(out, " %-14s %s\n", p.Name, p.Allowed())
97+
if p.Detail != "" {
98+
fmt.Fprintf(out, " %-14s %s\n", "", p.Detail)
99+
}
100+
}
101+
}
102+
103+
// settingsColumn is the names of what a damage takes, for the list.
104+
//
105+
// A word rather than an empty cell when there are none, because a blank column
106+
// reads as "not filled in yet" and this is an answer.
107+
func settingsColumn(d damage.Descriptor) string {
108+
names := d.ParameterNames()
109+
if len(names) == 0 {
110+
return "none"
111+
}
112+
return strings.Join(names, ", ")
113+
}
114+
115+
func listDamages(out io.Writer) {
116+
fmt.Fprintf(out, "%-14s %-14s %s\n", "DAMAGE", "SMALLEST FILE", "SETTINGS")
117+
for _, d := range damage.All() {
118+
fmt.Fprintf(out, "%-14s %-14s %s\n",
119+
d.ID, core.ExactBytes(defaultFloor(d)), settingsColumn(d))
120+
// The sentence under the row rather than in a column of its own. A
121+
// damage id is not self describing the way a format id is - nobody
122+
// reads "zero-head" and knows what comes out - so a list of names
123+
// would say no more than the --damage flag help already says.
124+
fmt.Fprintf(out, " %s\n", d.Detail)
125+
}
126+
fmt.Fprint(out, "\nRun \"tfg damage <id>\" for what one damage takes.\n")
127+
fmt.Fprint(out, "The smallest file is what the default settings need.\n")
128+
}
129+
130+
func damageUsage(w io.Writer, fs *flag.FlagSet, errOut io.Writer) {
131+
fmt.Fprint(w, `tfg damage - list the ways this build can break a file on purpose.
132+
133+
A damaged file comes out exactly the size you asked for and is one a reader
134+
refuses, so the manifest records it as expected to be rejected. Use them with
135+
"tfg generate --damage <id>" or with the damage key of a recipe.
136+
137+
Usage:
138+
tfg damage
139+
tfg damage --json
140+
tfg damage <id>
141+
142+
Flags:
143+
`)
144+
fs.SetOutput(w)
145+
fs.PrintDefaults()
146+
fs.SetOutput(errOut)
147+
}
148+
149+
// everyDamageEntry is the whole registry as a script sees it.
150+
func everyDamageEntry() []damageEntry {
151+
all := damage.All()
152+
list := make([]damageEntry, 0, len(all))
153+
for _, d := range all {
154+
list = append(list, damageEntryFor(d))
155+
}
156+
return list
157+
}
158+
159+
// damageCmd answers what this build can break and what each one takes.
160+
//
161+
// It exists because the window drew the settings of a chosen damage from the
162+
// declaration and the command line named the damages and stopped there: to
163+
// learn that zero-head takes bytes, and that bytes runs from 4 to 4096, the
164+
// only way was to type a wrong value and read the refusal. The parity guard
165+
// does not see that gap, because it counts recipe keys, formats and presets and
166+
// a damage parameter is none of the three. Written up in
167+
// docs/CORRUPTION-ARCHITECTURE-2026-09-08.md section 14.5.
168+
func damageCmd(args []string, out, errOut io.Writer) int {
169+
fs := flag.NewFlagSet("damage", flag.ContinueOnError)
170+
fs.SetOutput(errOut)
171+
asJSON := fs.Bool("json", false, "write the list as JSON to standard output")
172+
fs.Usage = func() { damageUsage(errOut, fs, errOut) }
173+
if helpRequested(args) {
174+
damageUsage(out, fs, errOut)
175+
return ExitOK
176+
}
177+
leading, rest := splitLeadingPath(args)
178+
if err := fs.Parse(rest); err != nil {
179+
return ExitUsage
180+
}
181+
182+
wanted, ok := atMostOneName(leading, fs, errOut)
183+
if !ok {
184+
return ExitUsage
185+
}
186+
187+
if wanted == "" {
188+
if *asJSON {
189+
return renderJSON(everyDamageEntry(), out, errOut)
190+
}
191+
listDamages(out)
192+
return ExitOK
193+
}
194+
195+
d, err := damage.Get(wanted)
196+
if err != nil {
197+
// The registry already names the damages it knows, so the message is
198+
// written once rather than here as well.
199+
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
200+
return classify(err)
201+
}
202+
if *asJSON {
203+
return renderJSON([]damageEntry{damageEntryFor(d)}, out, errOut)
204+
}
205+
describeOneDamage(d, out)
206+
return ExitOK
207+
}
208+
209+
// atMostOneName takes the single id a listing command was asked about.
210+
//
211+
// Two names are refused rather than answered about the first. Silence about an
212+
// argument somebody typed is the shape untouchable rule 6 forbids: measured on
213+
// 2026-09-09, "tfg formats png svg" describes png, ignores svg and ends with
214+
// zero, so a script asking about the wrong thing gets a confident answer about
215+
// something else. This command does not repeat that.
216+
func atMostOneName(leading string, fs *flag.FlagSet, errOut io.Writer) (string, bool) {
217+
extra := fs.Args()
218+
switch {
219+
case leading == "" && len(extra) == 0:
220+
return "", true
221+
case leading == "" && len(extra) == 1:
222+
return extra[0], true
223+
case leading != "" && len(extra) == 0:
224+
return leading, true
225+
}
226+
names := append([]string{}, extra...)
227+
if leading != "" {
228+
names = append([]string{leading}, names...)
229+
}
230+
fmt.Fprintf(errOut, "tfg: asked about %s at once, and this describes one at a time. "+
231+
"Run it again with a single name, or with none for the whole list.\n", strings.Join(names, ", "))
232+
return "", false
233+
}

‎internal/cli/generate.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,18 @@ func generateFlagSet(errOut io.Writer, g *generateOpts) (*flag.FlagSet, func(io.
7777
// map: the order damages are applied in is part of what they mean.
7878
// The names come from the registry rather than being typed here. The first
7979
// 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
80+
// there was no such command - a sentence in shipped help promising
8181
// something that does not exist, which is the class this project calls
8282
// prose with an expiry date. Built from Names() it cannot say that again.
83+
//
84+
// The pointer is back on 2026-09-09 because the command now exists, and it
85+
// carries what this line cannot: what each damage does to the bytes, what
86+
// it takes and how small a file it can be given. The names stay beside it
87+
// rather than being replaced by it, so the common case is answered without
88+
// a second command.
8389
fs.Var(&g.repeated.damage, "damage", "break the files on purpose, repeatable and applied in order: "+
8490
"--damage zero-head, or --damage zero-head:bytes=16. This build has: "+
85-
strings.Join(damage.Names(), ", "))
91+
strings.Join(damage.Names(), ", ")+". Run \"tfg damage\" for what each one does")
8692

8793
usage := func(w io.Writer) {
8894
fmt.Fprint(w, `tfg generate - produce files.

0 commit comments

Comments
 (0)