Skip to content

Commit 74b4b37

Browse files
donislawdevclaude
andauthored
cli: a listing command refuses a name it cannot answer about (#97)
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 <noreply@anthropic.com>
1 parent cc54cf9 commit 74b4b37

8 files changed

Lines changed: 445 additions & 262 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,25 @@ because it turns other people's test suites red.
306306

307307
### Changed
308308

309+
- **Two commands now refuse a name they cannot answer about, instead of
310+
quietly answering about a different one.**
311+
312+
```
313+
tfg formats png svg described png and said nothing about svg, ending with 0
314+
tfg preset list some-name printed the whole list and ignored the name, ending with 0
315+
```
316+
317+
Both now end with 2 and name the word they could not use. A script that
318+
asked about the wrong thing was getting a confident answer about something
319+
else, which is worse than being told no.
320+
321+
`tfg preset list` takes no name at all, so anything after it was always a
322+
mistake - most likely somebody reaching for `tfg preset show`.
323+
The refusal says so.
324+
325+
Nothing changes when you pass the right number of names, and no generated
326+
file moves a byte.
327+
309328
- **A spreadsheet can now be built wider than a spreadsheet can open.** The
310329
`columns` setting of `xlsx` used to stop at 64. It now reaches 32768, which is
311330
the ceiling `csv` already had.

‎internal/cli/formats.go‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -172,9 +172,13 @@ Flags:
172172
if err := fs.Parse(rest); err != nil {
173173
return ExitUsage
174174
}
175-
wanted := leading
176-
if wanted == "" && fs.NArg() == 1 {
177-
wanted = fs.Arg(0)
175+
// Two names used to describe the first and say nothing about the second,
176+
// ending with zero: measured 2026-09-09, "tfg formats png svg" printed the
177+
// card for png and ignored svg, so a script asking about the wrong thing
178+
// got a confident answer about something else. O196.
179+
wanted, ok := atMostOneName(leading, fs, errOut)
180+
if !ok {
181+
return ExitUsage
178182
}
179183

180184
if wanted != "" {

‎internal/cli/preset.go‎

Lines changed: 0 additions & 249 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"sort"
1010
"strings"
1111

12-
"github.com/donislawdev/TestingFilesGenerator/internal/core"
1312
"github.com/donislawdev/TestingFilesGenerator/internal/engine"
1413
"github.com/donislawdev/TestingFilesGenerator/internal/format"
1514
"github.com/donislawdev/TestingFilesGenerator/internal/manifest"
@@ -338,251 +337,3 @@ func targetsFromPreset(fs *flag.FlagSet, g *generateOpts, given map[string]bool,
338337
opt.Preset = record(expanded)
339338
return targetsFromParsedRecipe(rec, hash, g, given, opt), ExitOK
340339
}
341-
342-
func presetCmd(ctx context.Context, args []string, out, errOut io.Writer) int {
343-
if len(args) > 0 {
344-
switch args[0] {
345-
case "list":
346-
return presetList(args[1:], out, errOut)
347-
case "show":
348-
return presetShow(ctx, args[1:], out, errOut)
349-
case "eject":
350-
return presetEject(args[1:], out, errOut)
351-
}
352-
}
353-
354-
// Asking about the command itself, before any operation is named.
355-
if helpRequested(args) {
356-
presetUsage(out)
357-
return ExitOK
358-
}
359-
if len(args) == 0 {
360-
fmt.Fprintln(errOut, "tfg: preset takes one operation: list, show or eject. Example: tfg preset list")
361-
} else {
362-
fmt.Fprintf(errOut, "tfg: preset has no operation called %q. It takes list, show or eject.\n", args[0])
363-
}
364-
presetUsage(errOut)
365-
return ExitUsage
366-
}
367-
368-
func presetUsage(w io.Writer) {
369-
fmt.Fprint(w, `tfg preset - build a set of files from a named test question.
370-
371-
Usage:
372-
tfg preset list what this build offers
373-
tfg preset show <id> what it takes and what it would produce
374-
tfg preset eject <id> > my.yaml the recipe it stands for, to edit
375-
376-
A preset is a recipe with a name. Ejecting one gives back an ordinary recipe
377-
file, so nothing here is a closed box.
378-
379-
Run "tfg generate --preset <id>" to produce the files.
380-
`)
381-
}
382-
383-
// presetFlagSet builds the flag set of an operation taking one preset id.
384-
//
385-
// The id has to be read before parsing, because the parameters of the preset
386-
// are flags and there is no way to register them until it is known which they
387-
// are.
388-
// asJSON is filled in for the operations that have a machine readable form and
389-
// nil for the one that does not - a recipe is already machine readable, and a
390-
// second encoding of it would be a second thing to keep in step.
391-
func presetFlagSet(name string, args []string, out, errOut io.Writer, usage func(io.Writer), asJSON *bool) (
392-
*preset.Expansion, int) {
393-
394-
fs := flag.NewFlagSet("preset "+name, flag.ContinueOnError)
395-
fs.SetOutput(errOut)
396-
fs.Usage = func() { usage(errOut) }
397-
if helpRequested(args) {
398-
usage(out)
399-
return nil, ExitOK
400-
}
401-
// Registered before the parameters, so a preset declaring one called json
402-
// is caught by the collision check rather than by the flag package panicking.
403-
if asJSON != nil {
404-
fs.BoolVar(asJSON, "json", false, "write the answer as JSON to standard output")
405-
}
406-
407-
id, rest := splitLeadingPath(args)
408-
if id == "" {
409-
fmt.Fprintf(errOut, "tfg: preset %s takes the id of one preset. Run \"tfg preset list\" to see them.\n", name)
410-
return nil, ExitUsage
411-
}
412-
p, err := preset.Get(id)
413-
if err != nil {
414-
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
415-
return nil, classify(err)
416-
}
417-
if clash := clashingParameter(fs, p); clash != "" {
418-
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)
419-
return nil, ExitRuntime
420-
}
421-
registerPresetFlags(fs, p)
422-
423-
if err := fs.Parse(rest); err != nil {
424-
return nil, ExitUsage
425-
}
426-
if fs.NArg() > 0 {
427-
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))
428-
return nil, ExitUsage
429-
}
430-
431-
expanded, err := preset.Expand(id, givenPresetArgs(fs, p))
432-
if err != nil {
433-
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
434-
return nil, classify(err)
435-
}
436-
return expanded, ExitOK
437-
}
438-
439-
func presetList(args []string, out, errOut io.Writer) int {
440-
fs := flag.NewFlagSet("preset list", flag.ContinueOnError)
441-
fs.SetOutput(errOut)
442-
asJSON := fs.Bool("json", false, "write the list as JSON to standard output")
443-
usage := func(w io.Writer) {
444-
fmt.Fprint(w, `tfg preset list - the test questions this build can answer.
445-
446-
Usage:
447-
tfg preset list
448-
tfg preset list --json
449-
450-
Flags:
451-
`)
452-
fs.SetOutput(w)
453-
fs.PrintDefaults()
454-
fs.SetOutput(errOut)
455-
}
456-
fs.Usage = func() { usage(errOut) }
457-
if helpRequested(args) {
458-
usage(out)
459-
return ExitOK
460-
}
461-
if err := fs.Parse(args); err != nil {
462-
return ExitUsage
463-
}
464-
465-
all := preset.All()
466-
if *asJSON {
467-
list := make([]presetEntry, 0, len(all))
468-
for _, p := range all {
469-
list = append(list, presetEntryFor(p))
470-
}
471-
return renderJSON(list, out, errOut)
472-
}
473-
474-
// An empty build says so rather than printing a heading over nothing.
475-
if len(all) == 0 {
476-
fmt.Fprint(out, "This build registers no presets.\n")
477-
return ExitOK
478-
}
479-
fmt.Fprintf(out, "%-18s %s\n", "PRESET", "QUESTION IT ANSWERS")
480-
for _, p := range all {
481-
fmt.Fprintf(out, "%-18s %s\n", p.ID, p.Question)
482-
}
483-
fmt.Fprint(out, "\nRun \"tfg preset show <id>\" for what one takes and what it would produce.\n")
484-
return ExitOK
485-
}
486-
487-
func presetShow(ctx context.Context, args []string, out, errOut io.Writer) int {
488-
usage := func(w io.Writer) {
489-
fmt.Fprint(w, `tfg preset show - what a preset takes and what it would produce.
490-
491-
The budget is counted from the plan, at the parameters you gave, so it is the
492-
number of files and bytes this run would really write.
493-
494-
Usage:
495-
tfg preset show size-boundaries
496-
tfg preset show size-boundaries --limit 20mb --format png
497-
tfg preset show size-boundaries --json
498-
`)
499-
}
500-
var asJSON bool
501-
expanded, code := presetFlagSet("show", args, out, errOut, usage, &asJSON)
502-
if expanded == nil {
503-
return code
504-
}
505-
506-
b, err := budgetOf(ctx, expanded)
507-
if err != nil {
508-
fmt.Fprintf(errOut, "tfg: %s\n", describeError(err))
509-
return classify(err)
510-
}
511-
512-
if asJSON {
513-
entry := presetEntryFor(expanded.Preset)
514-
entry.Budget = &b
515-
entry.Defaulted = expanded.Defaulted
516-
entry.Notes = expanded.Notes()
517-
return renderJSON(entry, out, errOut)
518-
}
519-
describePreset(expanded, b, out)
520-
return ExitOK
521-
}
522-
523-
func describePreset(e *preset.Expansion, b budget, out io.Writer) {
524-
p := e.Preset
525-
fmt.Fprintf(out, "%s - %s\n%s\n", p.ID, p.Title, p.Question)
526-
527-
if len(p.Parameters) > 0 {
528-
fmt.Fprint(out, "\nparameters:\n")
529-
for _, param := range p.Parameters {
530-
fmt.Fprintf(out, " --%-12s %s\n", param.Name, param.Allowed())
531-
if param.Detail != "" {
532-
fmt.Fprintf(out, " %-14s %s\n", "", param.Detail)
533-
}
534-
}
535-
}
536-
for _, name := range p.Reads {
537-
fmt.Fprintf(out, " --%-12s the global flag, this preset gives it a default\n", name)
538-
}
539-
540-
fmt.Fprintf(out, "\nbudget at these values:\n %s, %s, %s total, format %s\n",
541-
core.Count(b.Targets, "target", "targets"), core.Count(b.Files, "file", "files"),
542-
core.ExactBytes(b.Bytes), strings.Join(b.Formats, ", "))
543-
for _, note := range e.Notes() {
544-
fmt.Fprintf(out, "\nnote: %s\n", note)
545-
}
546-
547-
if len(p.Catches) > 0 {
548-
fmt.Fprint(out, "\nwhat it typically catches:\n")
549-
for _, c := range p.Catches {
550-
fmt.Fprintf(out, " - %s\n", c)
551-
}
552-
}
553-
fmt.Fprintf(out, "\nRun \"tfg preset eject %s\" for the recipe, or \"tfg generate --preset %s\" to produce the files.\n",
554-
p.ID, p.ID)
555-
}
556-
557-
func presetEject(args []string, out, errOut io.Writer) int {
558-
usage := func(w io.Writer) {
559-
fmt.Fprint(w, `tfg preset eject - the recipe a preset stands for.
560-
561-
Prints an ordinary recipe file. Edit it, commit it, run it with tfg generate -
562-
from here on it is yours and nothing about it is special.
563-
564-
The recipe goes to standard output and everything else to standard error, so
565-
"tfg preset eject size-boundaries > my.yaml" gives a clean file.
566-
567-
Usage:
568-
tfg preset eject size-boundaries > my.yaml
569-
tfg preset eject size-boundaries --limit 20mb --format png > my.yaml
570-
`)
571-
}
572-
expanded, code := presetFlagSet("eject", args, out, errOut, usage, nil)
573-
if expanded == nil {
574-
return code
575-
}
576-
577-
// The note goes to the error channel. The recipe is the data here, and a
578-
// sentence about a number we chose has no business inside a file somebody
579-
// is about to commit.
580-
for _, note := range expanded.Notes() {
581-
fmt.Fprintf(errOut, "note: %s\n", note)
582-
}
583-
if _, err := out.Write(expanded.Source); err != nil {
584-
fmt.Fprintf(errOut, "tfg: cannot write the recipe: %s\n", describeError(err))
585-
return ExitIO
586-
}
587-
return ExitOK
588-
}

0 commit comments

Comments
 (0)