From 5c6af77d0549b97588d42051eea9bd5cd99b9a61 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 9 Sep 2026 13:23:43 +0200 Subject: [PATCH] cli: a listing command refuses a name it cannot answer about Measured on the built binary: "tfg formats png svg" described png, said nothing at all about svg and ended with zero, and "tfg preset list some-name" printed the whole list and ignored the name though that operation takes no name at all. Both are silence about an argument somebody typed, and in a script a confident answer about the wrong thing is worse than a refusal. The second one is not in the observation that asked for this. It came out of measuring the first, and fixing only the named half would have left that observation's own sentence true about a different pair of commands. Both now end with 2 and name the word they could not use. The guard asks four commands at once, including the two that already refused, and requires the refusal to name the rejected word - an exit code alone is satisfied by a build that refuses everything, so the other half checks these commands still answer. The flag spelling guard stops naming its directories by hand and derives them from the layer map. internal/damage arrived on layer 2, under both surfaces, without joining that list, and the hole would have taken every later package too. The layer map is the right source because the layering guard already refuses a package that is neither on the ladder nor declared test only, and because it leaves out internal/oracle on purpose - that one carries real command lines for inkscape and ffprobe in raw strings. 113 files scanned became 128, with zero findings. Refusing the extra name pushed internal/cli/preset.go past the file ceiling, so it is split by what the parts do: presetcmd.go answers somebody who typed tfg preset, preset.go turns a named question into targets. The ratchet then took the ceiling down, 433 to 408 and crowdedFiles 1 to 0. Three new mutations, and four existing ones retargeted after the split - only staleness.py said they had gone stale. All eight run and caught. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 19 ++ internal/cli/formats.go | 10 +- internal/cli/preset.go | 249 --------------------- internal/cli/presetcmd.go | 279 ++++++++++++++++++++++++ internal/guard/codeshape_test.go | 5 +- internal/guard/crowding_test.go | 4 +- internal/guard/flagnames_test.go | 60 ++++- internal/guard/listingarguments_test.go | 81 +++++++ 8 files changed, 445 insertions(+), 262 deletions(-) create mode 100644 internal/cli/presetcmd.go create mode 100644 internal/guard/listingarguments_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 46a5823..653cf27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -306,6 +306,25 @@ because it turns other people's test suites red. ### Changed +- **Two commands now refuse a name they cannot answer about, instead of + quietly answering about a different one.** + + ``` + tfg formats png svg described png and said nothing about svg, ending with 0 + tfg preset list some-name printed the whole list and ignored the name, ending with 0 + ``` + + Both now end with 2 and name the word they could not use. A script that + asked about the wrong thing was getting a confident answer about something + else, which is worse than being told no. + + `tfg preset list` takes no name at all, so anything after it was always a + mistake - most likely somebody reaching for `tfg preset show`. + The refusal says so. + + Nothing changes when you pass the right number of names, and no generated + file moves a byte. + - **A spreadsheet can now be built wider than a spreadsheet can open.** The `columns` setting of `xlsx` used to stop at 64. It now reaches 32768, which is the ceiling `csv` already had. diff --git a/internal/cli/formats.go b/internal/cli/formats.go index 466cb9c..ca0be41 100644 --- a/internal/cli/formats.go +++ b/internal/cli/formats.go @@ -172,9 +172,13 @@ Flags: if err := fs.Parse(rest); err != nil { return ExitUsage } - wanted := leading - if wanted == "" && fs.NArg() == 1 { - wanted = fs.Arg(0) + // Two names used to describe the first and say nothing about the second, + // ending with zero: measured 2026-09-09, "tfg formats png svg" printed the + // card for png and ignored svg, so a script asking about the wrong thing + // got a confident answer about something else. O196. + wanted, ok := atMostOneName(leading, fs, errOut) + if !ok { + return ExitUsage } if wanted != "" { diff --git a/internal/cli/preset.go b/internal/cli/preset.go index 09de047..d2a9b36 100644 --- a/internal/cli/preset.go +++ b/internal/cli/preset.go @@ -9,7 +9,6 @@ import ( "sort" "strings" - "github.com/donislawdev/TestingFilesGenerator/internal/core" "github.com/donislawdev/TestingFilesGenerator/internal/engine" "github.com/donislawdev/TestingFilesGenerator/internal/format" "github.com/donislawdev/TestingFilesGenerator/internal/manifest" @@ -338,251 +337,3 @@ func targetsFromPreset(fs *flag.FlagSet, g *generateOpts, given map[string]bool, opt.Preset = record(expanded) return targetsFromParsedRecipe(rec, hash, g, given, opt), ExitOK } - -func presetCmd(ctx context.Context, args []string, out, errOut io.Writer) int { - if len(args) > 0 { - switch args[0] { - case "list": - return presetList(args[1:], out, errOut) - case "show": - return presetShow(ctx, args[1:], out, errOut) - case "eject": - return presetEject(args[1:], out, errOut) - } - } - - // Asking about the command itself, before any operation is named. - if helpRequested(args) { - presetUsage(out) - return ExitOK - } - if len(args) == 0 { - fmt.Fprintln(errOut, "tfg: preset takes one operation: list, show or eject. Example: tfg preset list") - } else { - fmt.Fprintf(errOut, "tfg: preset has no operation called %q. It takes list, show or eject.\n", args[0]) - } - presetUsage(errOut) - return ExitUsage -} - -func presetUsage(w io.Writer) { - fmt.Fprint(w, `tfg preset - build a set of files from a named test question. - -Usage: - tfg preset list what this build offers - tfg preset show what it takes and what it would produce - tfg preset eject > my.yaml the recipe it stands for, to edit - -A preset is a recipe with a name. Ejecting one gives back an ordinary recipe -file, so nothing here is a closed box. - -Run "tfg generate --preset " to produce the files. -`) -} - -// presetFlagSet builds the flag set of an operation taking one preset id. -// -// The id has to be read before parsing, because the parameters of the preset -// are flags and there is no way to register them until it is known which they -// are. -// asJSON is filled in for the operations that have a machine readable form and -// nil for the one that does not - a recipe is already machine readable, and a -// second encoding of it would be a second thing to keep in step. -func presetFlagSet(name string, args []string, out, errOut io.Writer, usage func(io.Writer), asJSON *bool) ( - *preset.Expansion, int) { - - fs := flag.NewFlagSet("preset "+name, flag.ContinueOnError) - fs.SetOutput(errOut) - fs.Usage = func() { usage(errOut) } - if helpRequested(args) { - usage(out) - return nil, ExitOK - } - // Registered before the parameters, so a preset declaring one called json - // is caught by the collision check rather than by the flag package panicking. - if asJSON != nil { - fs.BoolVar(asJSON, "json", false, "write the answer as JSON to standard output") - } - - id, rest := splitLeadingPath(args) - if id == "" { - fmt.Fprintf(errOut, "tfg: preset %s takes the id of one preset. Run \"tfg preset list\" to see them.\n", name) - return nil, ExitUsage - } - p, err := preset.Get(id) - if err != nil { - fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) - return nil, classify(err) - } - if clash := clashingParameter(fs, p); clash != "" { - fmt.Fprintf(errOut, "tfg: the preset %s declares a parameter called %q and that is already a flag of this command. This is a fault in the build rather than in what you typed, and there is nothing you can do about it from here.\n", p.ID, clash) - return nil, ExitRuntime - } - registerPresetFlags(fs, p) - - if err := fs.Parse(rest); err != nil { - return nil, ExitUsage - } - if fs.NArg() > 0 { - fmt.Fprintf(errOut, "tfg: preset %s takes one preset id and %q came after it. Give the parameters as flags, for example --limit 10mb.\n", name, fs.Arg(0)) - return nil, ExitUsage - } - - expanded, err := preset.Expand(id, givenPresetArgs(fs, p)) - if err != nil { - fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) - return nil, classify(err) - } - return expanded, ExitOK -} - -func presetList(args []string, out, errOut io.Writer) int { - fs := flag.NewFlagSet("preset list", flag.ContinueOnError) - fs.SetOutput(errOut) - asJSON := fs.Bool("json", false, "write the list as JSON to standard output") - usage := func(w io.Writer) { - fmt.Fprint(w, `tfg preset list - the test questions this build can answer. - -Usage: - tfg preset list - tfg preset list --json - -Flags: -`) - fs.SetOutput(w) - fs.PrintDefaults() - fs.SetOutput(errOut) - } - fs.Usage = func() { usage(errOut) } - if helpRequested(args) { - usage(out) - return ExitOK - } - if err := fs.Parse(args); err != nil { - return ExitUsage - } - - all := preset.All() - if *asJSON { - list := make([]presetEntry, 0, len(all)) - for _, p := range all { - list = append(list, presetEntryFor(p)) - } - return renderJSON(list, out, errOut) - } - - // An empty build says so rather than printing a heading over nothing. - if len(all) == 0 { - fmt.Fprint(out, "This build registers no presets.\n") - return ExitOK - } - fmt.Fprintf(out, "%-18s %s\n", "PRESET", "QUESTION IT ANSWERS") - for _, p := range all { - fmt.Fprintf(out, "%-18s %s\n", p.ID, p.Question) - } - fmt.Fprint(out, "\nRun \"tfg preset show \" for what one takes and what it would produce.\n") - return ExitOK -} - -func presetShow(ctx context.Context, args []string, out, errOut io.Writer) int { - usage := func(w io.Writer) { - fmt.Fprint(w, `tfg preset show - what a preset takes and what it would produce. - -The budget is counted from the plan, at the parameters you gave, so it is the -number of files and bytes this run would really write. - -Usage: - tfg preset show size-boundaries - tfg preset show size-boundaries --limit 20mb --format png - tfg preset show size-boundaries --json -`) - } - var asJSON bool - expanded, code := presetFlagSet("show", args, out, errOut, usage, &asJSON) - if expanded == nil { - return code - } - - b, err := budgetOf(ctx, expanded) - if err != nil { - fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) - return classify(err) - } - - if asJSON { - entry := presetEntryFor(expanded.Preset) - entry.Budget = &b - entry.Defaulted = expanded.Defaulted - entry.Notes = expanded.Notes() - return renderJSON(entry, out, errOut) - } - describePreset(expanded, b, out) - return ExitOK -} - -func describePreset(e *preset.Expansion, b budget, out io.Writer) { - p := e.Preset - fmt.Fprintf(out, "%s - %s\n%s\n", p.ID, p.Title, p.Question) - - if len(p.Parameters) > 0 { - fmt.Fprint(out, "\nparameters:\n") - for _, param := range p.Parameters { - fmt.Fprintf(out, " --%-12s %s\n", param.Name, param.Allowed()) - if param.Detail != "" { - fmt.Fprintf(out, " %-14s %s\n", "", param.Detail) - } - } - } - for _, name := range p.Reads { - fmt.Fprintf(out, " --%-12s the global flag, this preset gives it a default\n", name) - } - - fmt.Fprintf(out, "\nbudget at these values:\n %s, %s, %s total, format %s\n", - core.Count(b.Targets, "target", "targets"), core.Count(b.Files, "file", "files"), - core.ExactBytes(b.Bytes), strings.Join(b.Formats, ", ")) - for _, note := range e.Notes() { - fmt.Fprintf(out, "\nnote: %s\n", note) - } - - if len(p.Catches) > 0 { - fmt.Fprint(out, "\nwhat it typically catches:\n") - for _, c := range p.Catches { - fmt.Fprintf(out, " - %s\n", c) - } - } - fmt.Fprintf(out, "\nRun \"tfg preset eject %s\" for the recipe, or \"tfg generate --preset %s\" to produce the files.\n", - p.ID, p.ID) -} - -func presetEject(args []string, out, errOut io.Writer) int { - usage := func(w io.Writer) { - fmt.Fprint(w, `tfg preset eject - the recipe a preset stands for. - -Prints an ordinary recipe file. Edit it, commit it, run it with tfg generate - -from here on it is yours and nothing about it is special. - -The recipe goes to standard output and everything else to standard error, so -"tfg preset eject size-boundaries > my.yaml" gives a clean file. - -Usage: - tfg preset eject size-boundaries > my.yaml - tfg preset eject size-boundaries --limit 20mb --format png > my.yaml -`) - } - expanded, code := presetFlagSet("eject", args, out, errOut, usage, nil) - if expanded == nil { - return code - } - - // The note goes to the error channel. The recipe is the data here, and a - // sentence about a number we chose has no business inside a file somebody - // is about to commit. - for _, note := range expanded.Notes() { - fmt.Fprintf(errOut, "note: %s\n", note) - } - if _, err := out.Write(expanded.Source); err != nil { - fmt.Fprintf(errOut, "tfg: cannot write the recipe: %s\n", describeError(err)) - return ExitIO - } - return ExitOK -} diff --git a/internal/cli/presetcmd.go b/internal/cli/presetcmd.go new file mode 100644 index 0000000..7bea1bd --- /dev/null +++ b/internal/cli/presetcmd.go @@ -0,0 +1,279 @@ +// Part of package cli. See cli.go. +// +// The tfg preset command lives here and the machinery behind +// generate --preset lives in preset.go beside it. One file held both and +// grew past the file ceiling on 2026-09-09, and the split follows what the +// parts DO rather than where the line count happened to fall: everything +// here answers somebody who typed tfg preset, and everything there turns a +// named question into targets for a run. +package cli + +import ( + "context" + "flag" + "fmt" + "io" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/core" + "github.com/donislawdev/TestingFilesGenerator/internal/preset" +) + +func presetCmd(ctx context.Context, args []string, out, errOut io.Writer) int { + if len(args) > 0 { + switch args[0] { + case "list": + return presetList(args[1:], out, errOut) + case "show": + return presetShow(ctx, args[1:], out, errOut) + case "eject": + return presetEject(args[1:], out, errOut) + } + } + + // Asking about the command itself, before any operation is named. + if helpRequested(args) { + presetUsage(out) + return ExitOK + } + if len(args) == 0 { + fmt.Fprintln(errOut, "tfg: preset takes one operation: list, show or eject. Example: tfg preset list") + } else { + fmt.Fprintf(errOut, "tfg: preset has no operation called %q. It takes list, show or eject.\n", args[0]) + } + presetUsage(errOut) + return ExitUsage +} + +func presetUsage(w io.Writer) { + fmt.Fprint(w, `tfg preset - build a set of files from a named test question. + +Usage: + tfg preset list what this build offers + tfg preset show what it takes and what it would produce + tfg preset eject > my.yaml the recipe it stands for, to edit + +A preset is a recipe with a name. Ejecting one gives back an ordinary recipe +file, so nothing here is a closed box. + +Run "tfg generate --preset " to produce the files. +`) +} + +// presetFlagSet builds the flag set of an operation taking one preset id. +// +// The id has to be read before parsing, because the parameters of the preset +// are flags and there is no way to register them until it is known which they +// are. +// asJSON is filled in for the operations that have a machine readable form and +// nil for the one that does not - a recipe is already machine readable, and a +// second encoding of it would be a second thing to keep in step. +func presetFlagSet(name string, args []string, out, errOut io.Writer, usage func(io.Writer), asJSON *bool) ( + *preset.Expansion, int) { + + fs := flag.NewFlagSet("preset "+name, flag.ContinueOnError) + fs.SetOutput(errOut) + fs.Usage = func() { usage(errOut) } + if helpRequested(args) { + usage(out) + return nil, ExitOK + } + // Registered before the parameters, so a preset declaring one called json + // is caught by the collision check rather than by the flag package panicking. + if asJSON != nil { + fs.BoolVar(asJSON, "json", false, "write the answer as JSON to standard output") + } + + id, rest := splitLeadingPath(args) + if id == "" { + fmt.Fprintf(errOut, "tfg: preset %s takes the id of one preset. Run \"tfg preset list\" to see them.\n", name) + return nil, ExitUsage + } + p, err := preset.Get(id) + if err != nil { + fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) + return nil, classify(err) + } + if clash := clashingParameter(fs, p); clash != "" { + fmt.Fprintf(errOut, "tfg: the preset %s declares a parameter called %q and that is already a flag of this command. This is a fault in the build rather than in what you typed, and there is nothing you can do about it from here.\n", p.ID, clash) + return nil, ExitRuntime + } + registerPresetFlags(fs, p) + + if err := fs.Parse(rest); err != nil { + return nil, ExitUsage + } + if fs.NArg() > 0 { + fmt.Fprintf(errOut, "tfg: preset %s takes one preset id and %q came after it. Give the parameters as flags, for example --limit 10mb.\n", name, fs.Arg(0)) + return nil, ExitUsage + } + + expanded, err := preset.Expand(id, givenPresetArgs(fs, p)) + if err != nil { + fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) + return nil, classify(err) + } + return expanded, ExitOK +} + +func presetList(args []string, out, errOut io.Writer) int { + fs := flag.NewFlagSet("preset list", flag.ContinueOnError) + fs.SetOutput(errOut) + asJSON := fs.Bool("json", false, "write the list as JSON to standard output") + usage := func(w io.Writer) { + fmt.Fprint(w, `tfg preset list - the test questions this build can answer. + +Usage: + tfg preset list + tfg preset list --json + +Flags: +`) + fs.SetOutput(w) + fs.PrintDefaults() + fs.SetOutput(errOut) + } + fs.Usage = func() { usage(errOut) } + if helpRequested(args) { + usage(out) + return ExitOK + } + if err := fs.Parse(args); err != nil { + return ExitUsage + } + // This operation takes no name, and until 2026-09-09 anything written + // after it was dropped without a word: tfg preset list size-boundaries + // printed the whole list and ended with zero, so somebody reaching for + // show got list and no sign of it. Found while measuring the same + // silence in tfg formats, O196. + if extra := fs.Args(); len(extra) > 0 { + fmt.Fprintf(errOut, "tfg: preset list takes no name and %q came after it. "+ + "Run \"tfg preset list\" for all of them, or \"tfg preset show %s\" for one.\n", + extra[0], extra[0]) + return ExitUsage + } + + all := preset.All() + if *asJSON { + list := make([]presetEntry, 0, len(all)) + for _, p := range all { + list = append(list, presetEntryFor(p)) + } + return renderJSON(list, out, errOut) + } + + // An empty build says so rather than printing a heading over nothing. + if len(all) == 0 { + fmt.Fprint(out, "This build registers no presets.\n") + return ExitOK + } + fmt.Fprintf(out, "%-18s %s\n", "PRESET", "QUESTION IT ANSWERS") + for _, p := range all { + fmt.Fprintf(out, "%-18s %s\n", p.ID, p.Question) + } + fmt.Fprint(out, "\nRun \"tfg preset show \" for what one takes and what it would produce.\n") + return ExitOK +} + +func presetShow(ctx context.Context, args []string, out, errOut io.Writer) int { + usage := func(w io.Writer) { + fmt.Fprint(w, `tfg preset show - what a preset takes and what it would produce. + +The budget is counted from the plan, at the parameters you gave, so it is the +number of files and bytes this run would really write. + +Usage: + tfg preset show size-boundaries + tfg preset show size-boundaries --limit 20mb --format png + tfg preset show size-boundaries --json +`) + } + var asJSON bool + expanded, code := presetFlagSet("show", args, out, errOut, usage, &asJSON) + if expanded == nil { + return code + } + + b, err := budgetOf(ctx, expanded) + if err != nil { + fmt.Fprintf(errOut, "tfg: %s\n", describeError(err)) + return classify(err) + } + + if asJSON { + entry := presetEntryFor(expanded.Preset) + entry.Budget = &b + entry.Defaulted = expanded.Defaulted + entry.Notes = expanded.Notes() + return renderJSON(entry, out, errOut) + } + describePreset(expanded, b, out) + return ExitOK +} + +func describePreset(e *preset.Expansion, b budget, out io.Writer) { + p := e.Preset + fmt.Fprintf(out, "%s - %s\n%s\n", p.ID, p.Title, p.Question) + + if len(p.Parameters) > 0 { + fmt.Fprint(out, "\nparameters:\n") + for _, param := range p.Parameters { + fmt.Fprintf(out, " --%-12s %s\n", param.Name, param.Allowed()) + if param.Detail != "" { + fmt.Fprintf(out, " %-14s %s\n", "", param.Detail) + } + } + } + for _, name := range p.Reads { + fmt.Fprintf(out, " --%-12s the global flag, this preset gives it a default\n", name) + } + + fmt.Fprintf(out, "\nbudget at these values:\n %s, %s, %s total, format %s\n", + core.Count(b.Targets, "target", "targets"), core.Count(b.Files, "file", "files"), + core.ExactBytes(b.Bytes), strings.Join(b.Formats, ", ")) + for _, note := range e.Notes() { + fmt.Fprintf(out, "\nnote: %s\n", note) + } + + if len(p.Catches) > 0 { + fmt.Fprint(out, "\nwhat it typically catches:\n") + for _, c := range p.Catches { + fmt.Fprintf(out, " - %s\n", c) + } + } + fmt.Fprintf(out, "\nRun \"tfg preset eject %s\" for the recipe, or \"tfg generate --preset %s\" to produce the files.\n", + p.ID, p.ID) +} + +func presetEject(args []string, out, errOut io.Writer) int { + usage := func(w io.Writer) { + fmt.Fprint(w, `tfg preset eject - the recipe a preset stands for. + +Prints an ordinary recipe file. Edit it, commit it, run it with tfg generate - +from here on it is yours and nothing about it is special. + +The recipe goes to standard output and everything else to standard error, so +"tfg preset eject size-boundaries > my.yaml" gives a clean file. + +Usage: + tfg preset eject size-boundaries > my.yaml + tfg preset eject size-boundaries --limit 20mb --format png > my.yaml +`) + } + expanded, code := presetFlagSet("eject", args, out, errOut, usage, nil) + if expanded == nil { + return code + } + + // The note goes to the error channel. The recipe is the data here, and a + // sentence about a number we chose has no business inside a file somebody + // is about to commit. + for _, note := range expanded.Notes() { + fmt.Fprintf(errOut, "note: %s\n", note) + } + if _, err := out.Write(expanded.Source); err != nil { + fmt.Fprintf(errOut, "tfg: cannot write the recipe: %s\n", describeError(err)) + return ExitIO + } + return ExitOK +} diff --git a/internal/guard/codeshape_test.go b/internal/guard/codeshape_test.go index 782b5a6..dcae9a5 100644 --- a/internal/guard/codeshape_test.go +++ b/internal/guard/codeshape_test.go @@ -47,7 +47,10 @@ const ( // Lowered from 457 on 2026-09-06: writing a file moved out of engine.go // into parallel.go, beside the goroutines that do it. The longest file in // the tree is somewhere else now. - longestFile = 433 + // Lowered from 433 on 2026-09-09: the tfg preset command moved out of + // preset.go into presetcmd.go, leaving the machinery behind generate + // --preset on its own. The longest file is engine.go again. + longestFile = 408 // Depth answers a different question than length, and it is the better // question of the two. A hundred line function that is flat reads top to diff --git a/internal/guard/crowding_test.go b/internal/guard/crowding_test.go index 13d4f9d..96cd726 100644 --- a/internal/guard/crowding_test.go +++ b/internal/guard/crowding_test.go @@ -60,8 +60,10 @@ const ( // when the per file work moved into compare. The ratchet only tightens. // Lowered from 2 on 2026-09-06: engine.go dropped out of the crowded band // when writing a file moved into parallel.go. + // Lowered from 1 on 2026-09-09: preset.go split in two, so no file sits + // in the band under the file ceiling any more. crowdedFunctions = 9 - crowdedFiles = 1 + crowdedFiles = 0 ) func TestNothingIsQuietlyCreepingTowardsTheCeiling(t *testing.T) { diff --git a/internal/guard/flagnames_test.go b/internal/guard/flagnames_test.go index e38fa19..f0e9503 100644 --- a/internal/guard/flagnames_test.go +++ b/internal/guard/flagnames_test.go @@ -6,6 +6,7 @@ import ( "go/token" "path/filepath" "regexp" + "sort" "strconv" "strings" "testing" @@ -29,27 +30,70 @@ import ( // flag names. A list would need a line adding every time a flag is, which is // the kind of guard somebody forgets to extend - and the defect is the dashes, // not which word follows them. +// packagesBelowTheSurfaces is every package the layer map puts under the +// command line and the window, as a path from this directory. +// +// Derived rather than written out, and that is the whole of O197. The list +// here used to name seven directories by hand, and internal/damage arrived on +// 2026-09-08 without joining it - a package on layer 2, under BOTH surfaces, +// whose refusals the window and the command line both show. Measured when the +// gap was found: zero flag spellings in it, so the hole was empty. It was still +// a hole, and the next package below the surfaces would have fallen in it too. +// +// The layer map is the right source rather than a walk of internal, because +// TestLayeringHoldsForEveryPackage already refuses a package that is neither on +// the ladder nor declared test only. So this covers a package added tomorrow +// with nothing to remember, and it leaves out the two test only packages on +// purpose: internal/oracle carries the flags of other people's programs in raw +// strings - inkscape and ffprobe are called with real command lines - and +// measured on 2026-09-09 it holds three of them. A walk of internal would +// redden on those, which is how a guard gets switched off inside a week. +func packagesBelowTheSurfaces(t *testing.T) []string { + t.Helper() + + // The surfaces are layer 4. Anything above them is a binary, anything + // below is what both of them show. + const surfaces = 4 + + var out []string + for pkg, n := range layer { + if n >= surfaces { + continue + } + rest, inside := strings.CutPrefix(pkg, "internal/") + if !inside { + // Nothing below the surfaces lives outside internal today. + // Saying so rather than silently skipping it, because a + // package that did would go unscanned and look scanned. + t.Errorf("%s sits below the surfaces and outside internal, so this guard does not know where to read it", pkg) + continue + } + out = append(out, filepath.Join("..", rest)) + } + if len(out) == 0 { + t.Fatal("the layer map put no package below the surfaces, so this guard would read nothing") + } + sort.Strings(out) + return out +} + func TestNoMessageBelowTheSurfacesIsWrittenInFlagSpelling(t *testing.T) { // A dash pair followed by a letter. "|---|---|" in generated markdown is // three dashes and does not match, and neither does a range or an em dash // written as two. flagLike := regexp.MustCompile(`--[a-z]`) - dirs := []string{ - "../engine", "../core", "../preset", "../recipe", "../manifest", - "../audit", "../format", - } + dirs := packagesBelowTheSurfaces(t) scanned, found := 0, 0 for _, dir := range dirs { matches, err := filepath.Glob(filepath.Join(dir, "*.go")) if err != nil { t.Fatal(err) } - nested, err := filepath.Glob(filepath.Join(dir, "*", "*.go")) - if err != nil { - t.Fatal(err) + if len(matches) == 0 { + t.Errorf("%s is on the ladder and holds no Go file, so the layer map names a package that is not there", dir) } - for _, path := range append(matches, nested...) { + for _, path := range matches { if strings.HasSuffix(path, "_test.go") { continue } diff --git a/internal/guard/listingarguments_test.go b/internal/guard/listingarguments_test.go new file mode 100644 index 0000000..3e8e477 --- /dev/null +++ b/internal/guard/listingarguments_test.go @@ -0,0 +1,81 @@ +package guard + +import ( + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/cli" +) + +// A listing command refuses a name it cannot answer about, rather than +// answering about a different one. +// +// Measured on 2026-09-09, on the built binary: "tfg formats png svg" printed +// the card for png, said nothing at all about svg and ended with zero, and +// "tfg preset list size-boundaries" printed the whole list and ended with zero +// though that operation takes no name. Both are silence about an argument +// somebody typed - untouchable rule 6 read from the other end - and both are +// worse in a script than a refusal, because a confident answer about the wrong +// thing is indistinguishable from a right one. O196. +// +// The refusal has to NAME the word it could not use. An exit code alone is a +// weak thing to assert on: a command that refused everything with the same code +// would satisfy it, and so would one that refused for an unrelated reason. +// +// Two commands answered this correctly already and are here as the other half +// of the comparison, because the defect is not that a check is missing +// somewhere - it is that four commands answered one question two ways. +func TestAListingCommandRefusesANameItCannotAnswerAbout(t *testing.T) { + refusals := []struct { + what string + args []string + extra string + }{ + {"formats asked about two", []string{"formats", "png", "svg"}, "svg"}, + {"damage asked about two", []string{"damage", "zero-head", "other"}, "other"}, + {"preset list given a name", []string{"preset", "list", "size-boundaries"}, "size-boundaries"}, + {"preset show given two", []string{"preset", "show", "size-boundaries", "extra"}, "extra"}, + {"verify given two", []string{"verify", "a.json", "b.json"}, ""}, + } + + for _, c := range refusals { + t.Run(c.what, func(t *testing.T) { + code, stdout, errOut := run(t, c.args...) + if code != cli.ExitUsage { + t.Errorf("ended with %d, expected %d - a name it cannot use is a mistake "+ + "in what was typed\nstdout: %s\nstderr: %s", code, cli.ExitUsage, stdout, errOut) + } + if stdout != "" { + t.Errorf("a refused run wrote to stdout, so a pipe receives half an answer: %q", stdout) + } + // Named rather than merely refused. Without this the guard passes + // for a command that says "usage" and leaves the reader to work out + // which of the words they typed was the problem. + if c.extra != "" && !strings.Contains(errOut, c.extra) { + t.Errorf("the refusal does not name %q, the word it could not use: %s", c.extra, errOut) + } + }) + } + + // The control, and it is not decoration: every assertion above is satisfied + // by a build that refuses these commands outright. This half says the + // refusal is about the extra name and nothing else. + answers := [][]string{ + {"formats"}, + {"formats", "png"}, + {"damage"}, + {"damage", "zero-head"}, + {"preset", "list"}, + } + for _, args := range answers { + t.Run("still answers "+strings.Join(args, " "), func(t *testing.T) { + code, stdout, errOut := run(t, args...) + if code != cli.ExitOK { + t.Errorf("ended with %d, expected %d: %s", code, cli.ExitOK, errOut) + } + if stdout == "" { + t.Error("answered with nothing on stdout") + } + }) + } +}