Skip to content

refactor(cmd/morphic): dispatch subcommands via a command table - #204

Open
fuad-daoud wants to merge 1 commit into
mainfrom
refactor/cli-command-table
Open

refactor(cmd/morphic): dispatch subcommands via a command table#204
fuad-daoud wants to merge 1 commit into
mainfrom
refactor/cli-command-table

Conversation

@fuad-daoud

@fuad-daoud fuad-daoud commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

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 compile subcommand moves into a commands table that dispatch resolves against, and its flag binding is separated from its execution. The point of the table is the flagSet func() *flag.FlagSet field: help rendering and argument parsing draw from the same FlagSet, so a documented flag list can never drift from the flags that are actually accepted.

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 instead of flag's error, flag's usage dump, and the CLI's usage.

runCompile went from one 44-line function doing flag binding, validation, engine construction and output to runCompile plus compileSpec.

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:

  • -h and --help get worse. Discarding the FlagSet's output also discards the flag table -h used to print, so a help request now surfaces the stdlib sentinel morphic: flag: help requested. That is not a reason anyone can act on. --bogus also changes, for the better. The claim that this PR changes no CLI behaviour was wrong and has been removed.
  • usage and description on 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 why usage says [flags] while main.go's usage const 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

  • Every pre-existing test in cmd/morphic passes unmodified — that was the acceptance criterion for this refactor.
  • New TestLookup_KnownAndUnknown covers table resolution.
  • New TestCommands_TableIsWellFormed holds the table invariants lookup relies on. Verified by planting each defect: a blank name fails on Should NOT be empty, a duplicate compile entry fails on duplicate command "compile".
  • TestNewCompileFlags_DefinesEveryFlag and TestCommand_FlagSetBindsTheCommandsOwnFlags compare the whole flag set with ElementsMatch rather 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.

@OmarAlJarrah OmarAlJarrah left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 table is 70.
  • Strip the AI trailers. d3d9948 carries Co-Authored-By: and Claude-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.

Comment thread cmd/morphic/compile.go

positional, err := parseArgs(fs, args)
if err != nil {
emitf(stderr, "morphic: %v\n", err)

@OmarAlJarrah OmarAlJarrah Aug 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-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.

Suggested change
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())
}

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cmd/morphic/compile.go
var compileCommand = command{
name: "compile",
summary: "lower an API spec (OpenAPI 3.x) into Morphic IR JSON",
usage: "morphic compile <spec-file> [flags]",

@OmarAlJarrah OmarAlJarrah Aug 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added to the body: usage and description are unread until #205 renders them, so they can't be taken as live help text. That also covers why this one says [flags] while main.go's usage const spells them out — #205 deletes the const.

Comment thread cmd/morphic/compile.go
Comment on lines +32 to +33
description: "Lower an API spec (OpenAPI 3.x) into Morphic IR JSON on stdout, and write\n" +
"diagnostics to stderr.",

@OmarAlJarrah OmarAlJarrah Aug 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The \n is baked into the data, so this text can never rewrap. Break the source line instead of the string:

Suggested change
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.",

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cmd/morphic/command.go Outdated
Comment on lines +35 to +37
if name == "" {
return command{}, false
}

@OmarAlJarrah OmarAlJarrah Aug 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

Comment thread cmd/morphic/command_test.go Outdated
Comment on lines +35 to +37
for _, name := range compileFlagNames {
assert.NotNil(t, fs.Lookup(name), "flag %q must be defined", name)
}

@OmarAlJarrah OmarAlJarrah Aug 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cmd/morphic/command_test.go Outdated
Comment on lines +54 to +55
// The table's flagSet is what help rendering reads. It must produce the same
// flags Parse accepts, or documentation drifts from behaviour.

@OmarAlJarrah OmarAlJarrah Aug 2, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
// 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworded to your version.

@OmarAlJarrah
OmarAlJarrah force-pushed the refactor/cli-command-table branch from d3d9948 to 9387439 Compare August 2, 2026 01:23

@OmarAlJarrah OmarAlJarrah left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few simplifications on top of the earlier pass. All three are verified against the tree, not just read.

Comment thread cmd/morphic/compile.go Outdated
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() {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
fs.Usage = func() {}

Keeping both means the next reader has to work out which one actually silences it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cmd/morphic/compile.go
Comment on lines +34 to +37
flagSet: func() *flag.FlagSet {
fs, _ := newCompileFlags()
return fs
},

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread cmd/morphic/compile.go
Comment on lines +80 to 84
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
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@fuad-daoud
fuad-daoud force-pushed the refactor/cli-command-table branch from 9387439 to 1e64a75 Compare August 2, 2026 05:53
@fuad-daoud fuad-daoud changed the title refactor(cmd/morphic): dispatch subcommands through a command table refactor(cmd/morphic): dispatch subcommands via a command table Aug 2, 2026
@fuad-daoud

Copy link
Copy Markdown
Collaborator Author

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 Co-Authored-By: for now — say the word and it goes too.

Gate on the merged state: gofmt clean, go vet clean, golangci-lint 0 issues, build clean, coverage 4198/4198 (down 3 statements from the two dead branches you found).

One thing I found while checking your title measurement: #207's own title was over the cap too — 68 chars + (#207) = 75. Retitled to test(cmd/morphic): pin the rendered help text with goldens (65).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants