From e5f4e7c8d708030e3e2abed912f6908e6d7a365f Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 9 Sep 2026 12:07:41 +0200 Subject: [PATCH] damage: the command line says what a damage takes, not only that it exists 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 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 --- CHANGELOG.md | 22 ++ README.md | 24 ++ internal/cli/cli.go | 3 + internal/cli/damagecmd.go | 233 ++++++++++++++++++ internal/cli/generate.go | 10 +- internal/guard/damagecommand_test.go | 344 +++++++++++++++++++++++++++ internal/guard/exitcodes_test.go | 3 + internal/guard/help_test.go | 1 + 8 files changed, 638 insertions(+), 2 deletions(-) create mode 100644 internal/cli/damagecmd.go create mode 100644 internal/guard/damagecommand_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 13c4d1b..46a5823 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,28 @@ because it turns other people's test suites red. ### Added +- **A new command says what this build can break: `tfg damage`.** + + ``` + tfg damage every damage, and what each one does to the bytes + tfg damage zero-head what one of them takes + tfg damage --json the same, for a script + ``` + + The window already drew the settings of a chosen damage. The command line named + them and stopped there, so the only way to learn that `zero-head` takes `bytes`, + and that `bytes` runs from 4 to 4096, was to type a wrong value and read the + refusal. + + Each one is listed with the smallest file it can be given. That number follows + the settings - for `zero-head` it is the number of bytes you asked it to zero - so + the column is measured with the defaults and says so. + + Asking about a damage that does not exist ends with 2, the code that means the + invocation was mistyped, and it is the same code `--damage` already gave for the + same mistake. Asking about two at once is refused rather than answered about the + first. + - **Files can be broken on purpose.** A target takes `damage`, and the files it produces are ones a reader refuses: diff --git a/README.md b/README.md index 6a8d7ab..b4cd509 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,7 @@ tfg cleanup remove the files a manifest lists tfg recipe fmt print a recipe in its settled shape tfg preset build a set of files from a named test question tfg formats list the formats this build supports +tfg damage list the ways this build can break a file on purpose tfg version print the tool version tfg license print the licence and what it means for generated files ``` @@ -326,6 +327,29 @@ tfg formats [--json] every format, with fidelity, determinism and smallest s tfg formats what a single format accepts ``` +### `tfg damage` + +``` +tfg damage [--json] every damage, with the smallest file it takes and its settings +tfg damage what one damage does to the bytes, and what it takes +``` + +A damaged file comes out **exactly** the size you asked for and is one a reader +refuses, so the manifest records it as expected to be rejected. That is the third +question an upload validator asks - does it open - and until damage arrived every +file this tool wrote was well formed by definition. + +Use them with `tfg generate --damage `, repeatable and applied in the order +given, or with the `damage` key of a target in a recipe: + +``` +tfg generate --format png --size 20kb --count 10 --damage zero-head:bytes=16 +``` + +The smallest file a damage can be given follows its settings, so the column is +measured with the defaults. Ask for less and the run is refused before anything is +written, naming a size that would work. + ## 📜 Recipes A recipe is a YAML file describing a whole run. Commit it beside your tests and diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 16cdceb..fe0e927 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -86,6 +86,8 @@ func Run(ctx context.Context, args []string, out, errOut io.Writer) int { return presetCmd(ctx, args[1:], out, errOut) case "formats": return formats(args[1:], out, errOut) + case "damage": + return damageCmd(args[1:], out, errOut) case "--version", "version": fmt.Fprintln(out, version.Version) return ExitOK @@ -151,6 +153,7 @@ Commands: recipe fmt print a recipe in its settled shape preset build a set of files from a named test question formats list the formats this build supports + damage list the ways this build can break a file on purpose version print the tool version license print the licence and what it means for generated files diff --git a/internal/cli/damagecmd.go b/internal/cli/damagecmd.go new file mode 100644 index 0000000..bf62835 --- /dev/null +++ b/internal/cli/damagecmd.go @@ -0,0 +1,233 @@ +// Part of package cli. See cli.go. +package cli + +import ( + "flag" + "fmt" + "io" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/damage" +) + +// damageEntry is what "tfg damage --json" returns. +// +// The parameters ride in the same propertyEntry the format and the preset lists +// use, under the same key the preset list gives them. That is not tidiness: a +// damage parameter IS a format.Property, so a script that already knows how to +// draw a field from "tfg preset show --json" draws this one with no new code. +type damageEntry struct { + ID string `json:"id"` + Detail string `json:"detail"` + // SmallestFileBytes is the smallest file this damage can be given, with + // its settings left at their defaults. + // + // At the defaults on purpose, because that is the number somebody reading + // this column is about to ask a run for. The floor is a function of the + // settings - zero-head's floor IS its bytes value - so a person who raises + // the setting raises this, and the refusal names the new number rather + // than this one. tfg formats made the other choice once and printed a + // floor that runs refused: measured 2026-08-03, pdf announced 3265 and + // took 3286. + SmallestFileBytes int64 `json:"smallest_file_bytes"` + Parameters []propertyEntry `json:"parameters,omitempty"` +} + +// damageEntryFor is one damage as a script sees it. +func damageEntryFor(d damage.Descriptor) damageEntry { + params := make([]propertyEntry, 0, len(d.Parameters)) + for _, p := range d.Parameters { + params = append(params, propertyEntry{ + Name: p.Name, Kind: string(p.Kind), Min: p.Min, Max: p.Max, + Unit: p.Unit, Choices: p.Choices, Default: p.Default, Detail: p.Detail, + }) + } + return damageEntry{ + ID: d.ID, Detail: d.Detail, + SmallestFileBytes: defaultFloor(d), Parameters: params, + } +} + +// defaultFloor is the smallest file this damage takes when nobody states a +// setting. +// +// Asked of the declaration rather than worked out here, so a damage whose floor +// is arithmetic on two settings answers for itself. +func defaultFloor(d damage.Descriptor) int64 { + return d.Floor(d.Defaults()) +} + +// damageExample is the flag a person would type to use this damage, with its +// first setting at the declared default. +// +// Built from the declaration rather than written out, because the colon and +// comma syntax is the one thing about this flag nobody guesses, and an example +// naming a setting that no longer exists teaches the wrong thing twice. +func damageExample(d damage.Descriptor) string { + out := "--damage " + d.ID + if len(d.Parameters) > 0 { + p := d.Parameters[0] + value := p.Default + if value == "" { + value = "" + } + out += ":" + p.Name + "=" + value + } + return out +} + +// describeOneDamage prints everything one damage declares. +// +// The sentence comes first and the settings after it, which is the other way +// round from a format: a format id says what the file will be and a damage id +// does not, so the sentence is the answer rather than the footnote. +func describeOneDamage(d damage.Descriptor, out io.Writer) { + fmt.Fprintf(out, "%s - smallest file %s with the settings below\n", + d.ID, core.ExactBytes(defaultFloor(d))) + fmt.Fprintf(out, " %s\n", d.Detail) + + if len(d.Parameters) == 0 { + fmt.Fprint(out, "\nThis damage takes no settings.\n") + return + } + fmt.Fprintf(out, "\nsettings, written after a colon: %s\n", damageExample(d)) + for _, p := range d.Parameters { + fmt.Fprintf(out, " %-14s %s\n", p.Name, p.Allowed()) + if p.Detail != "" { + fmt.Fprintf(out, " %-14s %s\n", "", p.Detail) + } + } +} + +// settingsColumn is the names of what a damage takes, for the list. +// +// A word rather than an empty cell when there are none, because a blank column +// reads as "not filled in yet" and this is an answer. +func settingsColumn(d damage.Descriptor) string { + names := d.ParameterNames() + if len(names) == 0 { + return "none" + } + return strings.Join(names, ", ") +} + +func listDamages(out io.Writer) { + fmt.Fprintf(out, "%-14s %-14s %s\n", "DAMAGE", "SMALLEST FILE", "SETTINGS") + for _, d := range damage.All() { + fmt.Fprintf(out, "%-14s %-14s %s\n", + d.ID, core.ExactBytes(defaultFloor(d)), settingsColumn(d)) + // The sentence under the row rather than in a column of its own. A + // damage id is not self describing the way a format id is - nobody + // reads "zero-head" and knows what comes out - so a list of names + // would say no more than the --damage flag help already says. + fmt.Fprintf(out, " %s\n", d.Detail) + } + fmt.Fprint(out, "\nRun \"tfg damage \" for what one damage takes.\n") + fmt.Fprint(out, "The smallest file is what the default settings need.\n") +} + +func damageUsage(w io.Writer, fs *flag.FlagSet, errOut io.Writer) { + fmt.Fprint(w, `tfg damage - list the ways this build can break a file on purpose. + +A damaged file comes out exactly the size you asked for and is one a reader +refuses, so the manifest records it as expected to be rejected. Use them with +"tfg generate --damage " or with the damage key of a recipe. + +Usage: + tfg damage + tfg damage --json + tfg damage + +Flags: +`) + fs.SetOutput(w) + fs.PrintDefaults() + fs.SetOutput(errOut) +} + +// everyDamageEntry is the whole registry as a script sees it. +func everyDamageEntry() []damageEntry { + all := damage.All() + list := make([]damageEntry, 0, len(all)) + for _, d := range all { + list = append(list, damageEntryFor(d)) + } + return list +} + +// damageCmd answers what this build can break and what each one takes. +// +// It exists because the window drew the settings of a chosen damage from the +// declaration and the command line named the damages and stopped there: to +// learn that zero-head takes bytes, and that bytes runs from 4 to 4096, the +// only way was to type a wrong value and read the refusal. The parity guard +// does not see that gap, because it counts recipe keys, formats and presets and +// a damage parameter is none of the three. Written up in +// docs/CORRUPTION-ARCHITECTURE-2026-09-08.md section 14.5. +func damageCmd(args []string, out, errOut io.Writer) int { + fs := flag.NewFlagSet("damage", flag.ContinueOnError) + fs.SetOutput(errOut) + asJSON := fs.Bool("json", false, "write the list as JSON to standard output") + fs.Usage = func() { damageUsage(errOut, fs, errOut) } + if helpRequested(args) { + damageUsage(out, fs, errOut) + return ExitOK + } + leading, rest := splitLeadingPath(args) + if err := fs.Parse(rest); err != nil { + return ExitUsage + } + + wanted, ok := atMostOneName(leading, fs, errOut) + if !ok { + return ExitUsage + } + + if wanted == "" { + if *asJSON { + return renderJSON(everyDamageEntry(), out, errOut) + } + listDamages(out) + return ExitOK + } + + d, err := damage.Get(wanted) + if err != nil { + // The registry already names the damages it knows, so the message is + // written once rather than here as well. + fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) + return classify(err) + } + if *asJSON { + return renderJSON([]damageEntry{damageEntryFor(d)}, out, errOut) + } + describeOneDamage(d, out) + return ExitOK +} + +// atMostOneName takes the single id a listing command was asked about. +// +// Two names are refused rather than answered about the first. Silence about an +// argument somebody typed is the shape untouchable rule 6 forbids: measured on +// 2026-09-09, "tfg formats png svg" describes png, ignores svg and ends with +// zero, so a script asking about the wrong thing gets a confident answer about +// something else. This command does not repeat that. +func atMostOneName(leading string, fs *flag.FlagSet, errOut io.Writer) (string, bool) { + extra := fs.Args() + switch { + case leading == "" && len(extra) == 0: + return "", true + case leading == "" && len(extra) == 1: + return extra[0], true + case leading != "" && len(extra) == 0: + return leading, true + } + names := append([]string{}, extra...) + if leading != "" { + names = append([]string{leading}, names...) + } + fmt.Fprintf(errOut, "tfg: asked about %s at once, and this describes one at a time. "+ + "Run it again with a single name, or with none for the whole list.\n", strings.Join(names, ", ")) + return "", false +} diff --git a/internal/cli/generate.go b/internal/cli/generate.go index cef196f..94fa1d1 100644 --- a/internal/cli/generate.go +++ b/internal/cli/generate.go @@ -77,12 +77,18 @@ func generateFlagSet(errOut io.Writer, g *generateOpts) (*flag.FlagSet, func(io. // map: the order damages are applied in is part of what they mean. // The names come from the registry rather than being typed here. The first // version of this line ended "run tfg damage to see what there is" and - // there is no such command - a sentence in shipped help promising + // there was no such command - a sentence in shipped help promising // something that does not exist, which is the class this project calls // prose with an expiry date. Built from Names() it cannot say that again. + // + // The pointer is back on 2026-09-09 because the command now exists, and it + // carries what this line cannot: what each damage does to the bytes, what + // it takes and how small a file it can be given. The names stay beside it + // rather than being replaced by it, so the common case is answered without + // a second command. fs.Var(&g.repeated.damage, "damage", "break the files on purpose, repeatable and applied in order: "+ "--damage zero-head, or --damage zero-head:bytes=16. This build has: "+ - strings.Join(damage.Names(), ", ")) + strings.Join(damage.Names(), ", ")+". Run \"tfg damage\" for what each one does") usage := func(w io.Writer) { fmt.Fprint(w, `tfg generate - produce files. diff --git a/internal/guard/damagecommand_test.go b/internal/guard/damagecommand_test.go new file mode 100644 index 0000000..4dc6e49 --- /dev/null +++ b/internal/guard/damagecommand_test.go @@ -0,0 +1,344 @@ +package guard + +import ( + "encoding/json" + "go/ast" + "go/parser" + "go/token" + "strconv" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/cli" + "github.com/donislawdev/TestingFilesGenerator/internal/damage" + "github.com/donislawdev/TestingFilesGenerator/internal/format" + _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" +) + +// The command line says what a damage TAKES, not only that it exists. +// +// This is the other half of TestTheWindowDrawsAFieldForEveryDamageParameter. +// The window drew the settings of a chosen damage from the declaration on the +// day damage arrived, and the command line named the damages and stopped +// there: to learn that zero-head takes bytes, and that bytes runs from 4 to +// 4096, the only way was to type a wrong value and read the refusal. +// +// The parity guard cannot see that gap. It counts recipe keys, formats and +// presets, and a damage parameter is none of the three - so this is a parity +// defect in quality rather than in reach, and it needed a guard of its own. +// Written up in docs/CORRUPTION-ARCHITECTURE-2026-09-08.md section 14.5. +// +// The expectation is REBUILT from the declaration rather than written out. A +// list of strings would prove today's damage and go quiet on the next one, +// which is the lesson the imagedim guard cost: a guard that reconstructs what +// the registry declares covers a field nobody has thought of yet. +func TestTheCommandLineSaysWhatEveryDamageTakes(t *testing.T) { + all := damage.All() + if len(all) == 0 { + t.Fatal("no damage is registered, so this guard would pass against any build") + } + + code, listing, errOut := run(t, "damage") + if code != cli.ExitOK { + t.Fatalf("listing the damages ended with %d: %s", code, errOut) + } + + settings := 0 + for _, d := range all { + if !strings.Contains(listing, d.ID) { + t.Errorf("the list does not name %q:\n%s", d.ID, listing) + } + // The sentence, not only the name. A damage id is not self describing + // the way a format id is - nobody reads "zero-head" and knows what + // comes out - so a list of names alone would say no more than the + // --damage flag help already says. + if !strings.Contains(listing, d.Detail) { + t.Errorf("the list names %q and does not say what it does:\n%s", d.ID, listing) + } + + code, described, errOut := run(t, "damage", d.ID) + if code != cli.ExitOK { + t.Fatalf("describing %q ended with %d: %s", d.ID, code, errOut) + } + for _, p := range d.Parameters { + // What a value may be, asked of the declaration itself. Allowed is + // the sentence the window puts under the same field, so the two + // surfaces cannot describe one setting in two ways. + for _, want := range []string{p.Name, p.Allowed()} { + if !strings.Contains(described, want) { + t.Errorf("%s declares %q and the command line does not say %q:\n%s", + d.ID, p.Name, want, described) + } + } + settings++ + } + } + if settings == 0 { + t.Fatal("no damage declares a setting, so the half that matters proved nothing") + } + + // The detail view has to be a different answer from the list, or the + // second command is a longer way of asking the first. What only it carries + // is the range: with one damage registered there is no other way to tell + // "it honoured the argument" from "it printed everything". + ranged := 0 + for _, d := range all { + _, described, _ := run(t, "damage", d.ID) + for _, p := range d.Parameters { + if strings.Contains(listing, p.Allowed()) { + t.Errorf("the list already carries the range of %s.%s, so asking about "+ + "one damage adds nothing", d.ID, p.Name) + } + if strings.Contains(described, p.Allowed()) { + ranged++ + } + } + } + if ranged == 0 { + t.Fatal("no range reached the detail view, so the comparison above proved nothing") + } + t.Logf("%d damage setting(s) described from the registry", settings) +} + +// The number this command announces as the smallest file is a number a run +// accepts. +// +// The same promise TestTheSmallestSizeIsAcceptedForEveryFormat makes about tfg +// formats, and it is here because that column got it wrong once: until +// 2026-08-04 the MINIMUM of tfg formats was the structural floor of the format +// rather than what a run would take, so pdf announced 3265 and refused it. +// +// The floor is DERIVED from the declaration rather than typed, which is what +// separates this from the engine guards beside it - those name 8 because +// zero-head defaults to eight bytes, and would go quiet if the default moved. +// Pressed through the whole command line rather than through Plan, because the +// claim is about what the tool takes and not about what one function believes. +func TestTheSmallestFileTheDamageCommandAnnouncesIsOneARunAccepts(t *testing.T) { + all := damage.All() + if len(all) == 0 { + t.Fatal("no damage is registered, so this guard would pass against any build") + } + + carrier, err := format.Get("txt") + if err != nil { + t.Fatalf("txt is the format small enough to sit on these floors: %v", err) + } + smallestFile := carrier.SmallestAccepted(format.Request{}) + + for _, d := range all { + floor := announcedFloor(t, d.ID) + if floor < smallestFile { + t.Fatalf("%s says it needs %d B and the smallest txt is %d B, so this "+ + "guard can no longer put the question", d.ID, floor, smallestFile) + } + + at := strconv.FormatInt(floor, 10) + code, _, errOut := run(t, "generate", "--format", "txt", "--size", at, + "--damage", d.ID, "--clean", "--out", t.TempDir()) + if code != cli.ExitOK { + t.Errorf("%s announces %s B as the smallest file and a run of exactly that "+ + "ended with %d: %s", d.ID, at, code, errOut) + } + + // The other end, so this cannot be satisfied by a build that accepts + // everything. One byte under has to be refused, and the refusal has to + // name the number that was announced rather than only saying no. + under := strconv.FormatInt(floor-1, 10) + code, _, errOut = run(t, "generate", "--format", "txt", "--size", under, + "--damage", d.ID, "--clean", "--out", t.TempDir()) + if code == cli.ExitOK { + t.Errorf("%s announces %s B as the smallest file and a run of %s B was accepted", + d.ID, at, under) + } + if !strings.Contains(errOut, at) { + t.Errorf("%s refused %s B without naming the %s B it announced: %s", + d.ID, under, at, errOut) + } + } +} + +// announcedFloor is the smallest file the command prints for one damage, read +// out of the machine readable form. +func announcedFloor(t *testing.T, id string) int64 { + t.Helper() + code, stdout, errOut := run(t, "damage", id, "--json") + if code != cli.ExitOK { + t.Fatalf("asking about %q as JSON ended with %d: %s", id, code, errOut) + } + var entries []struct { + SmallestFileBytes int64 `json:"smallest_file_bytes"` + } + if err := json.Unmarshal([]byte(stdout), &entries); err != nil { + t.Fatalf("the JSON for %q does not parse: %v\n%s", id, err, stdout) + } + if len(entries) != 1 { + t.Fatalf("asking about one damage returned %d entries", len(entries)) + } + return entries[0].SmallestFileBytes +} + +// Every declared setting reaches the machine readable form, filled in. +// +// The keys being present is the easy half and was once the only half: tfg +// formats emptied every value and kept all five keys, so a script would have +// drawn a field with no unit, no default and no help text and nothing would +// have said so. This asks for the values. +func TestTheMachineReadableDamageListCarriesTheWholeDeclaration(t *testing.T) { + code, stdout, errOut := run(t, "damage", "--json") + if code != cli.ExitOK { + t.Fatalf("the machine readable list ended with %d: %s", code, errOut) + } + + var entries []struct { + ID string `json:"id"` + Detail string `json:"detail"` + SmallestFileBytes int64 `json:"smallest_file_bytes"` + Parameters []struct { + Name string `json:"name"` + Kind string `json:"kind"` + Min int64 `json:"min"` + Max int64 `json:"max"` + Unit string `json:"unit"` + Default string `json:"default"` + Detail string `json:"detail"` + } `json:"parameters"` + } + if err := json.Unmarshal([]byte(stdout), &entries); err != nil { + t.Fatalf("the list does not parse: %v\n%s", err, stdout) + } + if len(entries) != len(damage.All()) { + t.Fatalf("the registry holds %d damage(s) and the list carries %d", + len(damage.All()), len(entries)) + } + + checked := 0 + for _, e := range entries { + d, err := damage.Get(e.ID) + if err != nil { + t.Errorf("the list carries %q, which the registry does not know", e.ID) + continue + } + if e.Detail != d.Detail { + t.Errorf("%s: the list says %q and the registry says %q", e.ID, e.Detail, d.Detail) + } + if e.SmallestFileBytes != d.Floor(d.Defaults()) { + t.Errorf("%s: the list says %d B and the declaration says %d B", + e.ID, e.SmallestFileBytes, d.Floor(d.Defaults())) + } + if len(e.Parameters) != len(d.Parameters) { + t.Errorf("%s declares %d setting(s) and the list carries %d", + e.ID, len(d.Parameters), len(e.Parameters)) + continue + } + for i, p := range d.Parameters { + got := e.Parameters[i] + if got.Name != p.Name || got.Kind != string(p.Kind) || got.Default != p.Default { + t.Errorf("%s.%s arrives as name %q kind %q default %q", + e.ID, p.Name, got.Name, got.Kind, got.Default) + } + if got.Detail != p.Detail { + t.Errorf("%s.%s arrives with detail %q, and the declaration says %q", + e.ID, p.Name, got.Detail, p.Detail) + } + if p.Kind == format.PropertyInt && (got.Min != p.Min || got.Max != p.Max) { + t.Errorf("%s.%s runs from %d to %d and arrives as %d to %d", + e.ID, p.Name, p.Min, p.Max, got.Min, got.Max) + } + if got.Unit != p.Unit { + t.Errorf("%s.%s has unit %q and arrives with %q", e.ID, p.Name, p.Unit, got.Unit) + } + checked++ + } + } + if checked == 0 { + t.Fatal("no setting was compared, so this proved nothing") + } +} + +// Every command that gets its own arguments is watched for --help. +// +// commandsTakingHelp is written out by hand, and the comment above it says +// that adding a command without adding it there leaves that command unwatched. +// Nothing held that. Adding tfg damage on 2026-09-09 meant remembering two +// hand kept lists in two files, which is the shape this project has been +// bitten by before - the mutation list went stale on twenty eight entries the +// same way. +// +// The rule is mechanical rather than a list of exceptions. A case in the +// dispatch that hands args[1:] to something takes flags of its own and +// therefore takes --help. version, license and help do not: they answer and +// return, so they are not exempted here, they simply do not match. A list of +// exceptions would have had nine entries against ten commands, and a ratchet +// that is mostly excuse teaches its reader to skip it. +func TestEveryCommandTakingItsOwnArgumentsIsWatchedForHelp(t *testing.T) { + dispatched := commandsInTheDispatch(t) + if len(dispatched) == 0 { + t.Fatal("no dispatching case was found, so this guard would pass against any tree") + } + + watched := map[string]bool{} + for _, cmd := range commandsTakingHelp { + watched[cmd[0]] = true + } + for _, name := range dispatched { + if !watched[name] { + t.Errorf("tfg %s takes its own arguments and is not in commandsTakingHelp, "+ + "so nothing asks whether it answers --help on stdout with code 0", name) + } + } + t.Logf("%d command(s) take their own arguments", len(dispatched)) +} + +// commandsInTheDispatch reads the verbs of cli.Run that hand on their +// arguments, by asking the source rather than by running anything. +func commandsInTheDispatch(t *testing.T) []string { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), "../cli/cli.go", nil, 0) + if err != nil { + t.Fatalf("reading the dispatch: %v", err) + } + + var out []string + ast.Inspect(file, func(n ast.Node) bool { + clause, ok := n.(*ast.CaseClause) + if !ok { + return true + } + if !handsOnItsArguments(clause) { + return true + } + for _, expr := range clause.List { + lit, ok := expr.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + name, err := strconv.Unquote(lit.Value) + if err != nil || strings.HasPrefix(name, "-") { + continue + } + out = append(out, name) + } + return true + }) + return out +} + +// handsOnItsArguments reports whether this case passes args[1:] to something, +// which is what having flags of its own looks like from the outside. +func handsOnItsArguments(clause *ast.CaseClause) bool { + found := false + for _, stmt := range clause.Body { + ast.Inspect(stmt, func(n ast.Node) bool { + slice, ok := n.(*ast.SliceExpr) + if !ok { + return true + } + ident, ok := slice.X.(*ast.Ident) + if ok && ident.Name == "args" && slice.High == nil { + found = true + } + return true + }) + } + return found +} diff --git a/internal/guard/exitcodes_test.go b/internal/guard/exitcodes_test.go index bb91115..9f1f7d8 100644 --- a/internal/guard/exitcodes_test.go +++ b/internal/guard/exitcodes_test.go @@ -106,6 +106,9 @@ func TestEveryEndingUsesACodeFromTheTable(t *testing.T) { {"an unknown format", []string{"generate", "--format", "nope", "--size", "1kb", "--out", filepath.Join(dir, "e4")}, cli.ExitFormat}, {"writing over something", []string{"generate", "--format", "txt", "--size", "500", "--out", occupied}, cli.ExitIO}, {"listing the formats", []string{"formats"}, cli.ExitOK}, + {"listing the damages", []string{"damage"}, cli.ExitOK}, + {"a damage nobody registered", []string{"damage", "nope"}, cli.ExitUsage}, + {"two damages at once", []string{"damage", "zero-head", "zero-head"}, cli.ExitUsage}, {"the version", []string{"version"}, cli.ExitOK}, } diff --git a/internal/guard/help_test.go b/internal/guard/help_test.go index 677390d..96d4bc5 100644 --- a/internal/guard/help_test.go +++ b/internal/guard/help_test.go @@ -36,6 +36,7 @@ var commandsTakingHelp = [][]string{ {"verify"}, {"cleanup"}, {"formats"}, + {"damage"}, {"recipe", "fmt"}, {"preset"}, {"preset", "list"},