refactor(cmd/morphic): dispatch subcommands via a command table - #204
refactor(cmd/morphic): dispatch subcommands via a command table#204fuad-daoud wants to merge 1 commit into
Conversation
b72b8c8 to
d3d9948
Compare
OmarAlJarrah
left a comment
There was a problem hiding this comment.
Findings inline. Two with no line to hang them on:
- Title is over the cap. 67 chars + GitHub's
(#204)= 74.refactor(cmd/morphic): dispatch subcommands via a command tableis 70. - Strip the AI trailers.
d3d9948carriesCo-Authored-By:andClaude-Session:, and the body ends with the generated-with line and a session URL. Needs an amend + force-push, not just a body edit.
Mechanics are otherwise fine: test-merges clean onto main, and the full gate passes on the merged state — golangci-lint 0 issues including #226's new caps, coverage 4201/4201.
|
|
||
| positional, err := parseArgs(fs, args) | ||
| if err != nil { | ||
| emitf(stderr, "morphic: %v\n", err) |
There was a problem hiding this comment.
-h and --help land here, so they now print morphic: flag: help requested. That's a stdlib sentinel, not a reason anyone can act on. Before this PR they got the flag table.
| emitf(stderr, "morphic: %v\n", err) | |
| if !errors.Is(err, flag.ErrHelp) { | |
| emitf(stderr, "morphic: %v\n", err) | |
| } |
Needs "errors" in the import block. That's outside the diff so I can't suggest it from here.
Separately, the body says this changes no CLI behavior. It changes this path and --bogus. Fix that line and call out that -h stays broken until the help PR.
Worth a test either way. Nothing in the suite touches -h, which is how the output could change without a single red line:
func TestRunCompile_HelpFlagPrintsUsageOnly(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
code := run([]string{"compile", "-h"}, &stdout, &stderr)
assert.Equal(t, 2, code)
assert.True(t, strings.HasPrefix(stderr.String(), "usage:"),
"flag.ErrHelp must not surface as a reason line, got: %s", stderr.String())
}There was a problem hiding this comment.
Fixed the body claim rather than the code. The errors.Is branch is #205's job — adding it here only to have #205 restructure it around writeCommandHelp would make this PR harder to read, not easier. The body now says outright that -h is worse than before on this commit, and the commit message says the same.
| var compileCommand = command{ | ||
| name: "compile", | ||
| summary: "lower an API spec (OpenAPI 3.x) into Morphic IR JSON", | ||
| usage: "morphic compile <spec-file> [flags]", |
There was a problem hiding this comment.
This says [flags]. The usage const in main.go spells them out. So there are two help texts already out of step, and nothing renders this one yet.
Fine as groundwork, but put a line in the PR body saying it's unread until the help change lands, so the next reader doesn't take it for live.
| description: "Lower an API spec (OpenAPI 3.x) into Morphic IR JSON on stdout, and write\n" + | ||
| "diagnostics to stderr.", |
There was a problem hiding this comment.
The \n is baked into the data, so this text can never rewrap. Break the source line instead of the string:
| description: "Lower an API spec (OpenAPI 3.x) into Morphic IR JSON on stdout, and write\n" + | |
| "diagnostics to stderr.", | |
| description: "Lower an API spec (OpenAPI 3.x) into Morphic IR JSON on stdout, " + | |
| "and write diagnostics to stderr.", |
There was a problem hiding this comment.
Pushing back on this one. Your rewrite unwraps the first sentence to ~99 chars but leaves the --explain paragraph below it hard-wrapped, so the description ends up inconsistently wrapped — one sentence relying on the terminal, three lines not. And the \n\n between them is load-bearing regardless, so the data carries some layout either way.
Given #207 pins the render as a golden, I read the wrapping as deliberate rather than accidental: the output is the same at any terminal width, and the golden diff stays readable. Happy to unwrap the whole description — all four lines, plus the \n\n becoming something structural — if you think that trade is better, but I don't want to half-do it. Left as is for now.
| if name == "" { | ||
| return command{}, false | ||
| } |
There was a problem hiding this comment.
Drop this. c.name == "" is already false for every entry, so lookup("") misses without it. I deleted the guard locally and the package suite plus the 100% coverage gate both stayed green.
| if name == "" { | |
| return command{}, false | |
| } |
The doc comment two lines up still promises "The empty name never resolves", so trim that sentence with it.
If the point was catching a blank entry in the table, that's a table invariant and belongs in the test where it can actually fail:
seen := make(map[string]bool, len(commands))
for _, cmd := range commands {
require.NotEmpty(t, cmd.name)
require.False(t, seen[cmd.name], "duplicate command %q", cmd.name)
seen[cmd.name] = true
}That picks up duplicates too, which the linear search currently resolves to the first entry without a word.
There was a problem hiding this comment.
Dropped, and the doc sentence with it. Confirmed the guard was dead: removing it leaves the package suite green at 100% coverage, since no table entry has an empty name.
Took the table-invariant test too, as TestCommands_TableIsWellFormed. Verified both halves by planting the defect — a blank name fails on Should NOT be empty, a duplicate compile entry on duplicate command "compile".
| for _, name := range compileFlagNames { | ||
| assert.NotNil(t, fs.Lookup(name), "flag %q must be defined", name) | ||
| } |
There was a problem hiding this comment.
This only checks the listed names are a subset of what's defined, so it can't fail when a flag is added, which is what the name promises. I added a fifth flag to newCompileFlags and left it off the list; both flag tests stayed green.
| for _, name := range compileFlagNames { | |
| assert.NotNil(t, fs.Lookup(name), "flag %q must be defined", name) | |
| } | |
| var got []string | |
| fs.VisitAll(func(f *flag.Flag) { got = append(got, f.Name) }) | |
| assert.ElementsMatch(t, compileFlagNames, got) |
Needs "flag" in the imports.
There was a problem hiding this comment.
Done, both here and in TestCommand_FlagSetBindsTheCommandsOwnFlags. Reproduced your mutation first: a fifth flag left off compileFlagNames kept all three flag tests green. With ElementsMatch both go red on elements differ.
| // The table's flagSet is what help rendering reads. It must produce the same | ||
| // flags Parse accepts, or documentation drifts from behaviour. |
There was a problem hiding this comment.
c.flagSet is newCompileFlags, and runCompile calls the constructor directly rather than going through the table, so nothing can drift yet. Say what the test actually holds:
| // The table's flagSet is what help rendering reads. It must produce the same | |
| // flags Parse accepts, or documentation drifts from behaviour. | |
| // The table's flagSet is what help rendering reads. Today it just calls | |
| // newCompileFlags; this holds the line if the table ever gets its own. |
There was a problem hiding this comment.
Reworded to your version.
d3d9948 to
9387439
Compare
OmarAlJarrah
left a comment
There was a problem hiding this comment.
A few simplifications on top of the earlier pass. All three are verified against the tree, not just read.
| outPath := fs.String("o", "", "write IR JSON to this file instead of stdout") | ||
| failOn := fs.String("fail-on", "error", | ||
| fs.SetOutput(io.Discard) | ||
| fs.Usage = func() {} |
There was a problem hiding this comment.
Dead line. SetOutput(io.Discard) already swallows the default usage dump, so this adds nothing; with ContinueOnError the flag package writes its usage to Output(), which is io.Discard. I dropped it and the output on -h, --bogus and --fail-on with no value is byte for byte the same.
| fs.Usage = func() {} |
Keeping both means the next reader has to work out which one actually silences it.
There was a problem hiding this comment.
Dropped. Confirmed the same way you did — -h, --bogus and --fail-on with no value are byte-identical without it.
Also rewrote the newCompileFlags doc to name io.Discard as the whole of the silencing and say why, so the next reader doesn't re-add the override.
| flagSet: func() *flag.FlagSet { | ||
| fs, _ := newCompileFlags() | ||
| return fs | ||
| }, |
There was a problem hiding this comment.
Handing out a *flag.FlagSet is wider than help rendering needs, and it's a live footgun: anyone can call Parse on it, which writes into a compileOptions nobody is holding, and the values vanish silently.
Help only ever needs the flag table, so the field could say that:
// printFlags writes the command's flag table to w.
printFlags func(w io.Writer)printFlags: func(w io.Writer) {
fs, _ := newCompileFlags()
fs.SetOutput(w)
fs.PrintDefaults()
},Same single source as today, narrower contract, and it can't be misused. It also drops the awkwardness where runCompile has to ignore the table's flagSet and call newCompileFlags itself, because it needs the options handle too. Your call whether that belongs here or in the help PR.
| if opts.failOn != "error" && opts.failOn != "warning" { | ||
| emitf(stderr, "morphic: invalid --fail-on %q (want error or warning)\n", opts.failOn) | ||
| printUsage(stderr) | ||
| return 2 | ||
| } |
There was a problem hiding this comment.
These three blocks and the unknown-command block in main.go are the same three lines four times over: reason, usage, exit 2. Worth single-sourcing while you're in here:
// usageError renders an optional reason line plus the usage block and returns
// the exit code for a malformed invocation. An empty reason prints usage alone.
func usageError(stderr io.Writer, reason string) int {
if reason != "" {
emitf(stderr, "morphic: %s\n", reason)
}
printUsage(stderr)
return 2
}Call sites collapse to one line each:
if errors.Is(err, flag.ErrHelp) {
return usageError(stderr, "")
}
return usageError(stderr, err.Error())
...
return usageError(stderr, fmt.Sprintf("invalid --fail-on %q (want error or warning)", opts.failOn))
...
return usageError(stderr, "compile requires exactly one spec file")I ran it: existing tests pass untouched, lint clean, and cmd/morphic drops 6 statements. No suggestion block because the helper lands next to printUsage, outside the diff.
One thing it exposes, which is the real argument for it: written this way the ErrHelp branch is its own statement, and the coverage gate fails on it until -h has a test. That's the gap working as intended.
Move the compile subcommand into a table that dispatch reads from, and split its flag binding from its execution, so a later change can render help from the same FlagSet that parses arguments. Silencing the flag package's own output is part of this move: compile's FlagSet now writes to io.Discard and runCompile renders the parse error itself. A bad flag therefore prints one reason line and one usage block rather than flag's error, flag's usage dump, and the CLI's usage. That silencing also swallows the flag table -h used to print, so -h and --help now report flag.ErrHelp as a reason line. Help is rewritten in the change that follows this one; until then, -h is worse than it was. The command entry's usage and description fields are groundwork: nothing renders them yet, so they cannot be read as live help text. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9387439 to
1e64a75
Compare
|
Force-pushed with the review items addressed; the stack is rebased on top so #205 and #207 carry the same base. Also stripped the session trailer and the generated-with line, which were plain violations of the repo's "no LLM/session artifacts" rule. Left Gate on the merged state: One thing I found while checking your title measurement: #207's own title was over the cap too — 68 chars + |
Summary
Groundwork for the help fix in the next PR, split out because it is a structural change that can be read on its own.
The single
compilesubcommand moves into acommandstable that dispatch resolves against, and its flag binding is separated from its execution. The point of the table is theflagSet func() *flag.FlagSetfield: help rendering and argument parsing draw from the sameFlagSet, so a documented flag list can never drift from the flags that are actually accepted.Silencing the
flagpackage's own output is part of this move — compile'sFlagSetnow writes toio.DiscardandrunCompilerenders the parse error itself. A bad flag therefore prints one reason line and one usage block instead of flag's error, flag's usage dump, and the CLI's usage.runCompilewent from one 44-line function doing flag binding, validation, engine construction and output torunCompilepluscompileSpec.Two things this PR does not leave in a good state
Both are fixed by #205, which is why that PR is stacked directly on this one:
-hand--helpget worse. Discarding theFlagSet's output also discards the flag table-hused to print, so a help request now surfaces the stdlib sentinelmorphic: flag: help requested. That is not a reason anyone can act on.--bogusalso changes, for the better. The claim that this PR changes no CLI behaviour was wrong and has been removed.usageanddescriptionon the table entry are unread. Nothing renders them until the help PR lands, so they cannot be taken as live help text. That is also whyusagesays[flags]whilemain.go'susageconst spells them out — the two are already out of step, and fix(cmd/morphic): print help on stdout and exit 0 #205 deletes the const.Test plan
cmd/morphicpasses unmodified — that was the acceptance criterion for this refactor.TestLookup_KnownAndUnknowncovers table resolution.TestCommands_TableIsWellFormedholds the table invariantslookuprelies on. Verified by planting each defect: a blanknamefails onShould NOT be empty, a duplicatecompileentry fails onduplicate command "compile".TestNewCompileFlags_DefinesEveryFlagandTestCommand_FlagSetBindsTheCommandsOwnFlagscompare the whole flag set withElementsMatchrather than checking a subset. Verified by planting a fifth flag that no list mentions: both go red, where the subset form left both green.gofmt -l,go vet ./...,golangci-lint run(0 issues),go build ./...,./scripts/check-coverage.sh— 4198/4198 statements.