From 44bc18e75dde11e4db9d91ab0eef3691a7d74d02 Mon Sep 17 00:00:00 2001 From: DonislawDev Date: Wed, 9 Sep 2026 21:04:37 +0200 Subject: [PATCH] cli: one declaration for the commands, and the documents held to it The block tfg --help prints was a raw string sitting beside the switch that dispatched, and nothing compared the two. The website copied that block by hand. The flag table in README was typed out separately from the flag set generate builds. Three lists saying one thing, no two of them compared, and all three green - README was missing --damage, added to the program the same day. Run and the help now come from one table in internal/cli/commands.go, so the first of those cannot drift at all. The output is byte for byte what it was: the same sha256 across the help, the version and an unknown command. The declaration keeps the parts apart because they are different questions. Verb is what the dispatch matches; Aliases reach the same place and are not printed, which is how four spellings of licence exist without the help becoming a spelling lesson; Shown replaces the verb where they differ, and only recipe does, since the dispatch branches on "recipe" while a person types "recipe fmt"; an empty summary means reachable but unlisted, which is help itself. That shape came from measuring first. The dispatch offered eight verbs taking arguments and the help printed ten, with all four differences legitimate - a guard comparing the two lists would have raised four false alarms. The other two lists are documents, which no declaration can generate, so they get guards asking a rule rather than carrying a list of exceptions. Every command the help lists must be a verb the tool answers to, and every command it does not list must do nothing except print that help - which tells help apart from a verb somebody forgot to describe without naming either. The README table is compared against the flag set generate really builds, both directions. The English page must repeat the program's own sentences word for word. TestEveryCommandTakingItsOwnArgumentsIsWatchedForHelp read the dispatch out of cli.go with go/ast, and refused to pass when that switch disappeared - which is what it was built to do. It asks behaviour now: a command with its own flag set refuses an unknown flag with ExitUsage and one that ignores what follows answers normally, splitting all eleven verbs eight to three with no exception to write down. Five mutations were invalidated by the move and are repointed. Three came back NOT CAUGHT or BROKEN first, and one of those was a real hole in a new guard: it asked the declaration about its own verb rather than about the first word of the name the help prints, so an entry printing one word and dispatching on another passed. Co-Authored-By: Claude Opus 5 --- internal/cli/cli.go | 79 +------ internal/cli/commands.go | 195 +++++++++++++++++ internal/guard/commanddeclaration_test.go | 244 ++++++++++++++++++++++ internal/guard/damagecommand_test.go | 77 +++---- internal/guard/site_test.go | 75 ++----- 5 files changed, 492 insertions(+), 178 deletions(-) create mode 100644 internal/cli/commands.go create mode 100644 internal/guard/commanddeclaration_test.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index fe0e927..d4d4d2e 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -20,8 +20,6 @@ import ( "github.com/donislawdev/TestingFilesGenerator/internal/engine" "github.com/donislawdev/TestingFilesGenerator/internal/format" _ "github.com/donislawdev/TestingFilesGenerator/internal/format/all" - "github.com/donislawdev/TestingFilesGenerator/internal/legal" - "github.com/donislawdev/TestingFilesGenerator/internal/version" ) // Exit codes are a frozen contract. Changing what one means is a breaking @@ -71,52 +69,16 @@ func Run(ctx context.Context, args []string, out, errOut io.Writer) int { return ExitUsage } - switch args[0] { - case "generate": - return generate(ctx, args[1:], out, errOut) - case "validate": - return validate(ctx, args[1:], out, errOut) - case "verify": - return verify(ctx, args[1:], out, errOut) - case "cleanup": - return cleanup(ctx, args[1:], out, errOut) - case "recipe": - return recipeCmd(args[1:], out, errOut) - case "preset": - 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 - case "--license", "--licence", "license", "licence": - // Both spellings. The tool writes British English and half the people - // who reach for this will type the American one, and being right about - // spelling at the cost of answering is not a trade worth making. - // - // An answer, so it goes to out and ends with zero, the same as version. - fmt.Fprint(out, version.LicenceNotice) - // And then what this particular binary actually carries. The notice - // above points at a file, which is no help to somebody holding only - // the binary - a download of the window is one file and the notices - // are not in it. The list is read out of the build's own record, so - // it describes the binary being asked rather than the source tree it - // came from: the command line answers with two libraries, the window - // with twenty-seven and the fonts they bring. - printCarried(out, legal.CarriedHere()) - return ExitOK - case "--help", "-h", "help": - // Asking is not a mistake, so the answer goes where answers go and - // "tfg --help | less" works. - usage(out) - return ExitOK - default: - fmt.Fprintf(errOut, "tfg: unknown command %q.\n\n", args[0]) - usage(errOut) - return ExitUsage + // One list rather than a switch beside a help text, since 2026-09-09. See + // the comment on the command type in commands.go for what that cost. + for _, c := range commands() { + if c.matches(args[0]) { + return c.Run(ctx, args[1:], out, errOut) + } } + fmt.Fprintf(errOut, "tfg: unknown command %q.\n\n", args[0]) + usage(errOut) + return ExitUsage } // helpRequested reports whether these arguments explicitly ask for help. @@ -142,29 +104,6 @@ func helpRequested(args []string) bool { return false } -func usage(w io.Writer) { - fmt.Fprint(w, `tfg - generate test files and know how the system under test should react. - -Commands: - generate produce files, from a recipe or from flags - validate check a recipe and write nothing - verify check a directory against a manifest - cleanup remove the files a manifest lists - 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 - -Run "tfg --help" for the flags of one command. -`) -} - -// The licence notice this command prints lives in internal/version, on the -// bottom layer, because the window shows the same text and the two surfaces -// cannot import each other. See version.LicenceNotice. - // defaultManifestName mirrors the engine, which has to know the name to keep // a run from writing over an earlier one's record. const defaultManifestName = engine.DefaultManifestName diff --git a/internal/cli/commands.go b/internal/cli/commands.go new file mode 100644 index 0000000..a24c257 --- /dev/null +++ b/internal/cli/commands.go @@ -0,0 +1,195 @@ +// Part of package cli. See cli.go. +package cli + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/donislawdev/TestingFilesGenerator/internal/legal" + "github.com/donislawdev/TestingFilesGenerator/internal/version" +) + +// command is one verb of the command line: what the dispatch matches, what the +// help prints about it, and what runs. +// +// One declaration rather than two lists, since 2026-09-09. The help used to be +// a raw string beside a switch, and nothing compared them - measured that day, +// the dispatch offered eight verbs taking arguments while the help printed +// ten, and the four differences were all legitimate, so no naive comparison +// could have told a real gap from those. tfg damage had already reached the +// program and the website while the site's own list was a hand copy of this +// help. O201. +// +// The parts are separate because they are genuinely different questions: +// +// - Verb is the one word the dispatch matches and the help prints. +// - Aliases reach the same place and are NOT printed. Four spellings of +// licence exist because half the people who want it type the American one, +// and being right about spelling at the cost of answering is not a trade +// worth making. Printing all four would make the help a spelling lesson. +// - Shown replaces Verb in the help where the two differ. Only recipe does: +// the dispatch branches on "recipe", and "recipe fmt" is what a person +// types, because recipe on its own does nothing. +// - Summary empty means reachable but not listed, which is help itself. A +// reader looking at the help does not need to be told it exists. +type command struct { + Verb string + Aliases []string + Shown string + Summary string + Run func(ctx context.Context, args []string, out, errOut io.Writer) int +} + +// name is what the help prints for this command. +func (c command) name() string { + if c.Shown != "" { + return c.Shown + } + return c.Verb +} + +// matches says whether this is the command somebody asked for. +func (c command) matches(word string) bool { + if word == c.Verb { + return true + } + for _, alias := range c.Aliases { + if word == alias { + return true + } + } + return false +} + +// commands is every verb, in the order the help lists them. +// +// A function rather than a package variable, because help is in here and its +// body renders this list - which as a variable would be an initialisation +// cycle. Rebuilding eleven entries per invocation costs nothing next to the +// process start that precedes it. +// +// Three of these take no context and are wrapped rather than changed. The +// wrapper is where that shows, instead of five signatures being widened for a +// table. +func commands() []command { + return []command{ + {Verb: "generate", Summary: "produce files, from a recipe or from flags", Run: generate}, + {Verb: "validate", Summary: "check a recipe and write nothing", Run: validate}, + {Verb: "verify", Summary: "check a directory against a manifest", Run: verify}, + {Verb: "cleanup", Summary: "remove the files a manifest lists", Run: cleanup}, + { + Verb: "recipe", Shown: "recipe fmt", + Summary: "print a recipe in its settled shape", + Run: func(_ context.Context, args []string, out, errOut io.Writer) int { + return recipeCmd(args, out, errOut) + }, + }, + {Verb: "preset", Summary: "build a set of files from a named test question", Run: presetCmd}, + { + Verb: "formats", Summary: "list the formats this build supports", + Run: func(_ context.Context, args []string, out, errOut io.Writer) int { + return formats(args, out, errOut) + }, + }, + { + Verb: "damage", Summary: "list the ways this build can break a file on purpose", + Run: func(_ context.Context, args []string, out, errOut io.Writer) int { + return damageCmd(args, out, errOut) + }, + }, + { + Verb: "version", Aliases: []string{"--version"}, + Summary: "print the tool version", + Run: func(_ context.Context, _ []string, out, _ io.Writer) int { + fmt.Fprintln(out, version.Version) + return ExitOK + }, + }, + { + Verb: "license", + Aliases: []string{"--license", "--licence", "licence"}, + Summary: "print the licence and what it means for generated files", + Run: licenceCmd, + }, + { + // Listed nowhere, reachable three ways. Somebody reading the help + // has already found it. + Verb: "help", Aliases: []string{"--help", "-h"}, + Run: func(_ context.Context, _ []string, out, _ io.Writer) int { + // Asking is not a mistake, so the answer goes where answers go + // and "tfg --help | less" works. + usage(out) + return ExitOK + }, + }, + } +} + +// Command is what one verb is called and what the help says about it. +// +// Exported without the function that runs it, because what a caller outside +// this package can want is the FACTS - a guard checking that the website +// repeats them, or that every verb is watched. Handing out the behaviour as +// well would let something outside the command line invoke half of it. +type Command struct { + // Name is how the help prints it, which is "recipe fmt" where the verb is + // "recipe". Verb is the word the dispatch matches. + Name string + Verb string + // Summary is empty for a command the help does not list. + Summary string +} + +// Commands is every verb this build answers to, in the order the help lists +// them. +func Commands() []Command { + all := commands() + out := make([]Command, 0, len(all)) + for _, c := range all { + out = append(out, Command{Name: c.name(), Verb: c.Verb, Summary: c.Summary}) + } + return out +} + +// licenceCmd prints the licence and then what this particular binary carries. +// +// The notice points at a file, which is no help to somebody holding only the +// binary - a download of the window is one file and the notices are not in it. +// The list is read out of the build's own record, so it describes the binary +// being asked rather than the source tree it came from: the command line +// answers with two libraries, the window with twenty-seven and the fonts they +// bring. +// +// An answer, so it goes to out and ends with zero, the same as version. +func licenceCmd(_ context.Context, _ []string, out, _ io.Writer) int { + fmt.Fprint(out, version.LicenceNotice) + printCarried(out, legal.CarriedHere()) + return ExitOK +} + +// usage prints what this tool is and what it can be asked to do. +// +// The command block is rendered from the declaration above rather than written +// out here, so a verb added tomorrow appears without anybody remembering to +// add it. The column is worked out from the longest name for the same reason: +// a number here would be a second copy of "the longest is recipe fmt" and +// would go stale the day something longer arrives. +func usage(w io.Writer) { + fmt.Fprint(w, "tfg - generate test files and know how the system under test should react.\n\nCommands:\n") + widest := 0 + for _, c := range commands() { + if c.Summary != "" && len(c.name()) > widest { + widest = len(c.name()) + } + } + for _, c := range commands() { + if c.Summary == "" { + continue + } + pad := strings.Repeat(" ", widest-len(c.name())+2) + fmt.Fprintf(w, " %s%s%s\n", c.name(), pad, c.Summary) + } + fmt.Fprint(w, "\nRun \"tfg --help\" for the flags of one command.\n") +} diff --git a/internal/guard/commanddeclaration_test.go b/internal/guard/commanddeclaration_test.go new file mode 100644 index 0000000..55a4f9e --- /dev/null +++ b/internal/guard/commanddeclaration_test.go @@ -0,0 +1,244 @@ +package guard + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + "github.com/donislawdev/TestingFilesGenerator/internal/cli" +) + +// The command line describes itself from one declaration, and the documents +// that repeat it are held to it. +// +// Three lists said the same thing and nothing compared any two of them, which +// is O201. The block tfg --help prints was a raw string beside the switch that +// dispatched. The website copied that block by hand. The flag table in README +// was typed out separately from the flag set generate builds. Measured on +// 2026-09-09: README was missing --damage, a flag added to the program that +// same day, and nothing said so. +// +// The help is rendered from the declaration now, so the first of those cannot +// drift at all. The other two are documents, which no amount of structure can +// generate, so they get guards - and both are asked with a rule rather than a +// list of exceptions, because a list of exceptions is a guard with a hole. + +// commandsTheToolPrints is the command names out of tfg --help, which is the +// text the page is a copy of. +// +// Asked of the help rather than of the router beside it on purpose: the help +// is what a visitor compares the page against, so agreeing with anything else +// would prove the wrong thing. +func commandsTheToolPrints(t *testing.T) []string { + t.Helper() + var out, errOut bytes.Buffer + if code := cli.Run(context.Background(), []string{"--help"}, &out, &errOut); code != cli.ExitOK { + t.Fatalf("tfg --help ended with %d rather than %d, so there is no list to compare against", + code, cli.ExitOK) + } + names, err := commandNamesIn(out.String()) + if err != nil { + t.Fatalf("reading the command block out of tfg --help: %v", err) + } + return names +} + +// commandNamesIn takes the names out of the Commands: block of a help text. +// +// Split on two spaces rather than on the first one, because "recipe fmt" is a +// command whose name has a space in it - splitting on the first would name a +// command the router does not have. +// +// Finding nothing is an error rather than an empty list, and that is the whole +// reason this is a function of its own. A guard that quietly stopped finding +// the block would compare the page against nothing and stay green while +// proving it - which is the failure this file exists to make impossible. +func commandNamesIn(help string) ([]string, error) { + const header = "Commands:" + _, rest, found := strings.Cut(help, header+"\n") + if !found { + return nil, fmt.Errorf("no %q line in the help text", header) + } + var names []string + for _, line := range strings.Split(rest, "\n") { + if !strings.HasPrefix(line, " ") { + break + } + name, _, split := strings.Cut(strings.TrimPrefix(line, " "), " ") + if !split { + return nil, fmt.Errorf("the line %q under %s has no summary beside the name", line, header) + } + names = append(names, name) + } + if len(names) == 0 { + return nil, fmt.Errorf("the %s block is empty", header) + } + return names, nil +} + +// Every command the help lists can actually be run. +// +// The pair to it below is what makes this worth having: on its own this would +// pass for a declaration listing one command and hiding ten. +func TestEveryCommandTheHelpListsIsOneTheToolAnswersTo(t *testing.T) { + listed := 0 + for _, c := range cli.Commands() { + if c.Summary == "" { + continue + } + listed++ + // The first word of the PRINTED name, not the verb beside it in the + // declaration. That difference is the whole point: a reader types what + // the help shows them, so asking the declaration about its own verb + // would pass for an entry printing one word and dispatching on + // another. Mutation said so - the first version of this asked c.Verb + // and stayed green against exactly that swap. + // + // First word, because "recipe fmt" is printed as two and the dispatch + // branches on the first. + typed, _, _ := strings.Cut(c.Name, " ") + var out, errOut bytes.Buffer + // The word alone. Some need an operation or a file after it and will + // say so, which is an answer - what would mean the help is lying is + // "unknown command". + cli.Run(context.Background(), []string{typed}, &out, &errOut) + if strings.Contains(errOut.String(), "unknown command") { + t.Errorf("the help lists %q and the tool does not know %q", c.Name, typed) + } + } + if listed == 0 { + t.Fatal("the help lists nothing, so this guard would pass against any tree") + } + t.Logf("%d command(s) listed in the help", listed) +} + +// A command the help does not list does nothing except print that help. +// +// This is the other half, and it is a rule rather than an exception for the +// one command that has no summary today. Anything unlisted is either help +// itself - which a reader of the help has already found - or a verb somebody +// forgot to describe, and only the second is a defect. Comparing the output +// against the help tells them apart without naming anybody. +func TestACommandTheHelpDoesNotListOnlyPrintsTheHelp(t *testing.T) { + var wanted bytes.Buffer + cli.Run(context.Background(), []string{"--help"}, &wanted, &bytes.Buffer{}) + if wanted.Len() == 0 { + t.Fatal("the help printed nothing, so there is nothing to compare against") + } + + unlisted := 0 + for _, c := range cli.Commands() { + if c.Summary != "" { + continue + } + unlisted++ + var out, errOut bytes.Buffer + if code := cli.Run(context.Background(), []string{c.Verb}, &out, &errOut); code != cli.ExitOK { + t.Errorf("%q is not listed in the help and does not end with %d, so it is a command nobody is told about", + c.Verb, cli.ExitOK) + } + if out.String() != wanted.String() { + t.Errorf("%q is not listed in the help and does something other than print it, so it is a command nobody is told about", + c.Verb) + } + } + t.Logf("%d command(s) reachable but unlisted", unlisted) +} + +// The flag table in README names every flag generate has, and no others. +// +// README is what somebody reads before they have the binary, so a flag missing +// from it is a feature that does not exist for that reader. Measured on +// 2026-09-09: --damage was in the program, in the help and on the website, and +// not in this table - the same class as the site listing nine commands out of +// ten, one document further out. +// +// Both directions, because a row for a flag that was removed sends somebody to +// type something the tool will refuse. +func TestTheReadmeFlagTableNamesEveryFlagGenerateTakes(t *testing.T) { + declared := flagsOfGenerate(t) + if len(declared) == 0 { + t.Fatal("generate declared no flags, so this guard would pass against any tree") + } + documented := flagsInTheReadmeTable(t) + + for name := range declared { + if !documented[name] { + t.Errorf("generate takes --%s and the README flag table does not mention it", name) + } + } + for name := range documented { + if !declared[name] { + t.Errorf("the README flag table offers --%s and generate does not take it", name) + } + } + t.Logf("%d flag(s) declared, %d documented", len(declared), len(documented)) +} + +// flagsOfGenerate asks the command for its own flag set rather than reading +// the source, so a flag added by any route is covered. +// +// It runs generate with --help, which builds the set and prints it, and reads +// the names back out of what it printed. The alternative was exporting the set +// itself, which would put a seam in the command line for a test to hold. +func flagsOfGenerate(t *testing.T) map[string]bool { + t.Helper() + var out, errOut bytes.Buffer + if code := cli.Run(context.Background(), []string{"generate", "--help"}, &out, &errOut); code != cli.ExitOK { + t.Fatalf("generate --help ended with %d: %s", code, errOut.String()) + } + // The flag package prints " -name" at the head of each entry, one per + // flag, whatever its type. + found := map[string]bool{} + for _, line := range strings.Split(out.String(), "\n") { + if !strings.HasPrefix(line, " -") { + continue + } + name, _, _ := strings.Cut(strings.TrimPrefix(line, " -"), " ") + if name != "" { + found[name] = true + } + } + // A sanity check on the shape rather than on the count: flag.PrintDefaults + // is what this reads, and a change in its format would leave the map empty + // and every comparison below trivially true. + if !found["format"] { + t.Fatalf("no --format among %d name(s) read back, so the help format changed and this reads nothing", len(found)) + } + return found +} + +var readmeFlagRow = regexp.MustCompile(`(?m)^\| ` + "`" + `--([a-z-]+)`) + +// flagsInTheReadmeTable reads the flag names out of the generate table. +// +// Bounded to the table under the generate heading rather than the whole file, +// because every other command documents its own flags further down and those +// are not this table's business. +func flagsInTheReadmeTable(t *testing.T) map[string]bool { + t.Helper() + body, err := os.ReadFile(filepath.Join(repoRoot(t), "README.md")) + if err != nil { + t.Skipf("no README here: %v", err) + } + text := string(body) + const first = "| `--format `" + start := strings.Index(text, first) + if start < 0 { + t.Fatalf("the generate flag table does not start with %q any more, so this guard reads nothing", first) + } + end := strings.Index(text[start:], "\n\n") + if end < 0 { + t.Fatal("the generate flag table does not end") + } + found := map[string]bool{} + for _, m := range readmeFlagRow.FindAllStringSubmatch(text[start:start+end], -1) { + found[m[1]] = true + } + return found +} diff --git a/internal/guard/damagecommand_test.go b/internal/guard/damagecommand_test.go index 4dc6e49..3ba6c1f 100644 --- a/internal/guard/damagecommand_test.go +++ b/internal/guard/damagecommand_test.go @@ -1,10 +1,9 @@ package guard import ( + "bytes" + "context" "encoding/json" - "go/ast" - "go/parser" - "go/token" "strconv" "strings" "testing" @@ -271,9 +270,9 @@ func TestTheMachineReadableDamageListCarriesTheWholeDeclaration(t *testing.T) { // 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) + dispatched := commandsTakingTheirOwnArguments(t) if len(dispatched) == 0 { - t.Fatal("no dispatching case was found, so this guard would pass against any tree") + t.Fatal("not one verb refused an unknown flag, so this guard would pass against any tree") } watched := map[string]bool{} @@ -289,56 +288,28 @@ func TestEveryCommandTakingItsOwnArgumentsIsWatchedForHelp(t *testing.T) { 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 { +// commandsTakingTheirOwnArguments is every verb whose own flag set reads what +// follows it, found by ASKING each one rather than by reading the source. +// +// It read the dispatch out of cli.go with go/ast until 2026-09-09, and the +// switch it parsed no longer exists - the verbs live in one declaration now, +// which is what O201 was about. Rewriting the parser to walk that declaration +// instead would have kept a reader of syntax where a measurement will do. +// +// The measurement is an unknown flag. A command with its own flag set refuses +// it with ExitUsage, and one that ignores what follows answers normally - +// measured 2026-09-09 across all eleven verbs, splitting them eight to three +// with no exception to write down. That is the same split the parser produced, +// arrived at by watching behaviour instead of shape. +func commandsTakingTheirOwnArguments(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 _, c := range cli.Commands() { + var stdout, stderr bytes.Buffer + code := cli.Run(context.Background(), []string{c.Verb, "--nosuchflag"}, &stdout, &stderr) + if code == cli.ExitUsage { + out = append(out, c.Verb) } - 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 + return out } diff --git a/internal/guard/site_test.go b/internal/guard/site_test.go index ed4d7a1..ea86b7b 100644 --- a/internal/guard/site_test.go +++ b/internal/guard/site_test.go @@ -2,11 +2,9 @@ package guard import ( "bytes" - "context" "encoding/json" "encoding/xml" "errors" - "fmt" "io" "os" "path/filepath" @@ -92,59 +90,6 @@ func exitCodesInOrder() []int { } } -// commandsTheToolPrints is the command names out of tfg --help, which is the -// text the page is a copy of. -// -// Asked of the help rather than of the router beside it on purpose: the help -// is what a visitor compares the page against, so agreeing with anything else -// would prove the wrong thing. -func commandsTheToolPrints(t *testing.T) []string { - t.Helper() - var out, errOut bytes.Buffer - if code := cli.Run(context.Background(), []string{"--help"}, &out, &errOut); code != cli.ExitOK { - t.Fatalf("tfg --help ended with %d rather than %d, so there is no list to compare against", - code, cli.ExitOK) - } - names, err := commandNamesIn(out.String()) - if err != nil { - t.Fatalf("reading the command block out of tfg --help: %v", err) - } - return names -} - -// commandNamesIn takes the names out of the Commands: block of a help text. -// -// Split on two spaces rather than on the first one, because "recipe fmt" is a -// command whose name has a space in it - splitting on the first would name a -// command the router does not have. -// -// Finding nothing is an error rather than an empty list, and that is the whole -// reason this is a function of its own. A guard that quietly stopped finding -// the block would compare the page against nothing and stay green while -// proving it - which is the failure this file exists to make impossible. -func commandNamesIn(help string) ([]string, error) { - const header = "Commands:" - _, rest, found := strings.Cut(help, header+"\n") - if !found { - return nil, fmt.Errorf("no %q line in the help text", header) - } - var names []string - for _, line := range strings.Split(rest, "\n") { - if !strings.HasPrefix(line, " ") { - break - } - name, _, split := strings.Cut(strings.TrimPrefix(line, " "), " ") - if !split { - return nil, fmt.Errorf("the line %q under %s has no summary beside the name", line, header) - } - names = append(names, name) - } - if len(names) == 0 { - return nil, fmt.Errorf("the %s block is empty", header) - } - return names, nil -} - // factsFromTheProgram fills every number the pages may state. func factsFromTheProgram(t *testing.T) site.Facts { t.Helper() @@ -464,6 +409,26 @@ func TestEveryLanguageDescribesEverythingTheProgramCanProduce(t *testing.T) { t.Errorf("the command %q has no summary in %s, so the page would list it blank", name, lang.Code) } } + // And the English page says what the program says, word for word. + // + // The rows above ask whether a summary EXISTS, which is all that can + // be asked of a translation - the Polish page describes the same + // command in Polish on purpose. English is the language the program + // itself writes in, so there the two are copies of one sentence and + // nothing was comparing them. Added 2026-09-09 with O201: the site + // gained its own copy of these sentences that morning, and a copy + // nobody checks is the defect this whole file exists for. + if lang.Code == "en" { + for _, c := range cli.Commands() { + if c.Summary == "" { + continue + } + if said := lang.Commands[c.Name]; said != c.Summary { + t.Errorf("tfg --help describes %q as %q and the English page says %q", + c.Name, c.Summary, said) + } + } + } for name := range lang.Commands { if !slices.Contains(facts.Commands, name) { t.Errorf("%s describes a command %q that tfg --help does not print", lang.Code, name)