fix(cmd/morphic): print help on stdout and exit 0 - #205
Conversation
e507468 to
cc5cf42
Compare
cc5cf42 to
97b6f7b
Compare
OmarAlJarrah
left a comment
There was a problem hiding this comment.
Second pass on this branch. The help design itself is good: one renderer for all three command-help forms, errors.Is(err, flag.ErrHelp) rather than an argv pre-scan, and TestRun_HelpFlagAsFlagValue pinning that choice is exactly the right instinct. flagSet finally earns its place now that writeCommandHelp reads it.
Gate is clean on the merged state: build, vet, golangci-lint 0 issues, coverage 4234/4234.
One thing outside the diff: the Breaking section says "the README documents the new contract", but README isn't in this PR. Its exit-code line still reads 2 a usage or I/O error with no mention of help, the synopsis block doesn't show morphic help, -h or --help, and the flag table still omits --explain even though compile help now documents it. Either update it here, since this is the PR that makes help the source of truth, or drop the claim.
|
|
||
| // commands is the subcommand table. Adding a subcommand means adding one entry. | ||
| var commands = []command{compileCommand} | ||
| var commands = []command{newCompileCommand()} |
There was a problem hiding this comment.
Make this a function too. Turning compileCommand into newCompileCommand moved the cycle one hop instead of removing it, because commands is still a package-level var and still sits in the chain.
Proof: point compile's help path at writeRootHelp, which is the obvious next thing an error path wants, and the package stops compiling.
initialization cycle for commands
commands refers to newCompileCommand
newCompileCommand refers to runCompile
runCompile refers to writeRootHelp
| var commands = []command{newCompileCommand()} | |
| func commands() []command { return []command{newCompileCommand()} } |
Three call sites move to commands(): the loop in writeRootHelp, and the require.NotEmpty plus the range in TestRootHelp_ListsEveryCommand. I ran it both ways: with this in place the same mutation builds fine and TestRun_HelpFormsAgree goes red on it, which is what you want. Right now the compile error hides the fact that the test would have caught it.
There was a problem hiding this comment.
Done — commands() is a function, with the three call sites moved.
Confirmed both directions. Before: pointing compile's help path at writeRootHelp gives exactly the cycle you printed (commands → newCompileCommand → runCompile → writeRootHelp → commands). After: the same mutation builds, and TestRun_HelpFormsAgree goes red with help text differs for [help compile]. The compile error really was hiding the test.
| // newCompileCommand builds compile's command-table entry. It is a function, | ||
| // not a package-level var, because its run field refers to runCompile, and | ||
| // runCompile itself needs this same metadata to render help and usage text; | ||
| // a var holding that cycle back to itself would be a Go initialization | ||
| // cycle, while two package-level functions may freely refer to each other. | ||
| func newCompileCommand() command { |
There was a problem hiding this comment.
The doc comment on this one is worth tightening while you're at it. It says a var "would be a Go initialization cycle, while two package-level functions may freely refer to each other", which reads as though the problem is gone. It isn't, because commands is still a var; see the note there.
There was a problem hiding this comment.
Tightened. It now points at commands for the real reason instead of implying the cycle is gone.
| // compileUsageError reports a misuse of compile: one reason line, one short | ||
| // usage pointer, exit 2. It never touches stdout. | ||
| func compileUsageError(stderr io.Writer, format string, args ...any) int { | ||
| emitf(stderr, "morphic: "+format+"\n", args...) | ||
| writeCommandUsage(stderr, newCompileCommand()) |
There was a problem hiding this comment.
This is a printf wrapper that vet doesn't recognise, so a reason string with a literal % in it garbles silently. I dropped (100% of the time) into the spec-file message:
morphic: compile requires exactly one spec file (100%!o(MISSING)f the time)
go vet said nothing and TestRun_UsageErrors stayed green, because the assertion only checks the substring before the mangled part.
Take a finished string instead, so a stray % can't matter:
| // compileUsageError reports a misuse of compile: one reason line, one short | |
| // usage pointer, exit 2. It never touches stdout. | |
| func compileUsageError(stderr io.Writer, format string, args ...any) int { | |
| emitf(stderr, "morphic: "+format+"\n", args...) | |
| writeCommandUsage(stderr, newCompileCommand()) | |
| // compileUsageError reports a misuse of compile: one reason line, one short | |
| // usage pointer, exit 2. It never touches stdout. | |
| func compileUsageError(stderr io.Writer, reason string) int { | |
| emitf(stderr, "morphic: %s\n", reason) | |
| writeCommandUsage(stderr, newCompileCommand()) | |
| return 2 | |
| } |
Call sites become compileUsageError(stderr, err.Error()) and compileUsageError(stderr, fmt.Sprintf("invalid --fail-on %q (want error or warning)", opts.failOn)).
There was a problem hiding this comment.
Fixed — it takes a finished string now, with the reason recorded in the doc comment.
Reproduced your mutation first (100% in the spec-file message → 100%!o(MISSING)f, vet silent, tests green), then confirmed the same reason renders literally after the change.
| fs.VisitAll(func(f *flag.Flag) { | ||
| assert.Contains(t, got, f.Name, "compile help must document -%s", f.Name) | ||
| }) |
There was a problem hiding this comment.
f.Name is "o" for -o, so this assertion can't fail for that flag. Any text with the letter o in it passes, root help included (I checked: it does).
Assert the rendered form:
| fs.VisitAll(func(f *flag.Flag) { | |
| assert.Contains(t, got, f.Name, "compile help must document -%s", f.Name) | |
| }) | |
| fs.VisitAll(func(f *flag.Flag) { | |
| assert.Contains(t, got, "\n -"+f.Name, "compile help must document -%s", f.Name) | |
| }) |
There was a problem hiding this comment.
Fixed to the rendered form.
Isolated the -o case to be sure the new assertion is what's doing the work: with writeCommandHelp mutated to render root help — documenting no flags at all — the rendered form catches all four flags including -o, while the bare-name form catches only three and passes -o silently.
| if len(args) == 0 { | ||
| printUsage(stderr) | ||
| return 2 | ||
| if len(args) == 0 || isHelpFlag(args[0]) { |
There was a problem hiding this comment.
morphic -h compile takes this branch and prints root help, dropping compile without a word. That's the opposite of the care taken in runHelp, where help bogus --help deliberately reports the bad name rather than letting the help flag mask it. Worth making the two agree, or saying why root is different.
There was a problem hiding this comment.
Fixed, and made the two agree rather than documenting a difference. A leading help flag now routes through runHelp instead of shortcutting to root help:
if args[0] == "help" || isHelpFlag(args[0]) {
return runHelp(args[1:], stdout, stderr)
}So morphic -h compile prints compile help, morphic -h bogus reports the bad name, and morphic --help compile extra gives the at-most-one-command error — same as the help spellings, because it is now literally the same path. TestRun_HelpForms and TestRun_HelpFormsAgree gain the -h compile rows, TestRun_UsageErrors gains -h bogus and --help compile extra.
Help requests were treated as misuse. -h, --help and help were rejected as unknown commands at the root, and `compile --help` printed the flag table followed by the CLI's own usage block, both at exit 2. Every help form now renders one text to stdout and exits 0: bare morphic, -h/--help/-help, help, help <command>, and <command> -h/--help. A leading help flag routes through runHelp rather than shortcutting to root help, so `morphic -h compile` prints compile's help instead of dropping the name, and `morphic -h bogus` reports it. A help flag is stripped from help's own arguments before the command lookup, so `help bogus --help` still reports the bad name rather than masking it. Misuse prints one reason line and one short usage pointer to stderr and exits 2. Compile detects help through flag.ErrHelp rather than scanning argv, so `compile -o --help spec.yaml` still treats --help as -o's value. The command table becomes a function. As a var it sits in the package's initialization graph, so the first time a command's own code reaches back into the table — writeRootHelp already does — the result is an initialization cycle rather than a test failure. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
97b6f7b to
4cc6e0b
Compare
|
Force-pushed. The README claim in the Breaking section is gone — it now says the README is updated in #207, which is where the file actually is. Gate on the merged state: |
Summary
Asking for help was treated as an error.
morphic --help,-handhelpwere rejected as unknown commands.morphic compile --helpprinted theflagpackage's table and then the CLI's own usage block — two overlapping texts. All of it went to stderr at exit 2, and nothing reached stdout.Every help form now renders one text to stdout and exits 0:
morphicmorphic -h/--help/-helpmorphic helpmorphic help compilemorphic -h compilemorphic compile -h/--helpAll command-help forms render through one function, so they are byte-identical. Misuse prints one reason line and one short usage pointer to stderr at exit 2 —
unknown command,help accepts at most one command, a bad flag, the wrong positional count, an invalid--fail-on.Three details worth a reviewer's attention:
helpcommand spelled differently, so it routes throughrunHelprather than shortcutting to root help. That is what makesmorphic -h compileprint compile's help instead of silently dropping the name, andmorphic -h bogusreport the bad name instead of masking it behind root help.help's own arguments before the command lookup, somorphic help bogus --helpstill reports the bad name.helptakes only a bare command name, so no help token there can be a legitimate value.compiledetects help througherrors.Is(err, flag.ErrHelp), never by scanning argv. That is what keepsmorphic compile -o --help spec.yamltreating--helpas-o's value.TestRun_HelpFlagAsFlagValueexists to stop a future refactor from replacing this with a pre-scan — such a change would keep full statement coverage while silently breaking it.The command table becomes a function rather than a package-level var. As a var it sits in the package's initialization graph, so the first time a command's own code reaches back into the table —
writeRootHelpalready does — the result isinitialization cycle for commands, a compile error rather than a test failure. Verified: with the table as a function, pointing compile's help path atwriteRootHelpbuilds fine andTestRun_HelpFormsAgreegoes red on it, which is the failure you want.Bare
morphicnow prints help and exits 0 rather than printing usage and exiting 2.Closes #62
Test plan
TestRun_HelpFormscovers every help invocation: exit 0, text on stdout, nothing on stderr.TestRun_HelpFormsAgreebyte-compares all five command-help spellings, includingmorphic -h compile.TestRun_UsageErrorscovers-h bogusand--help compile extra.TestRun_HelpFlagAsFlagValueproves-o --helpwrites a file named--helpand prints no help.TestRun_CompileHelpListsEveryFlagasserts the rendered flag form (\n -<name>), not the bare name. Verified by making command help render root help: the rendered form catches all four flags including-o, where the bare-name form silently passed-obecause "o" is a substring of nearly any text.compileUsageErrortakes a finished string rather than a format. A printf-style wrapper here is invisible togo vet, so a reason carrying a literal%was mangled into the output with nothing to catch it; verified that a reason containing100%now renders literally.gofmt -l,go vet ./...,golangci-lint run(0 issues),go build ./...,./scripts/check-coverage.sh— 4232/4232 statements.Breaking
Bare
morphicchanges from exit 2 to exit 0. A script invokingmorphicwith no arguments and relying on failure would now see success. The README is updated to match in #207, which stacks on this PR.