Ask the standard library through muxt.Checker - #149
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Critical findings remain in standard-library JSON lookup and repeated path-value bindings, with a moderate production-adapter test gap.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Refactors route resolution to query a muxt.Checker for standard-library types, with production and in-memory implementations.
Changes:
- Adds checker-backed resolution and generation metadata.
- Updates standard-library loading, CLI wiring, fixtures, and tests.
- Documents the checker-based architecture.
File summaries
| File | Summary / review notes |
|---|---|
internal/source/source.go |
Removes standard-library import data. |
internal/muxt/unmarshal.go |
Moves parsing classification to the checker. |
internal/muxt/testdata/example/std.go |
Adds test stand-in types. |
internal/muxt/testdata/example/methods.go |
Updates fixture methods to use stand-in types. |
internal/muxt/testdata/example/go.mod |
Removes obsolete fixture module metadata. |
internal/muxt/testdata/example/functions.go |
Updates fixture functions to use stand-in types. |
internal/muxt/path_value_types_test.go |
Critical (1 vote): incompatible repeated bindings can reuse a string for an int parameter and generate uncompilable code; reject differing types or preserve separate raw and parsed locals. Also applies on line 54. |
internal/muxt/muxttest/muxttest.go |
Adds the mock checker and in-memory type-checking helpers. |
internal/muxt/definition.go |
Stores path marshaler metadata. |
internal/muxt/checker.go |
Defines the Checker interface. |
internal/muxt/call.go |
Nit (3 votes): correct the Direct comment’s “not parses” and “not binds” grammar. |
internal/muxt/call_test.go |
Migrates call-resolution tests. |
internal/load/stdlib.go |
Moderate (1 vote): add focused adapter tests. Critical (1 vote): include encoding/json in load patterns so json.RawMessage resolves for unmarshalJSON(body). |
internal/load/source.go |
Stops hydrating the obsolete import graph. |
internal/generate/template_route_path.go |
Uses resolved text-marshaler information. |
internal/generate/source_test.go |
Adds in-memory generation fixtures. |
internal/generate/routes.go |
Passes the checker and consumes resolved metadata. |
internal/generate/routes_test.go |
Tests generation metadata and errors. |
internal/cli/commands.go |
Wires the production checker into generation. |
CLAUDE.md |
Documents the checker-based architecture. |
Review details
Suppressed comments (2)
internal/load/stdlib.go:17
- Please add focused tests for this new production
Checkeradapter. Theinternal/loadtests currently do not coverStandardLibrary's reserved-type mappings, transitive indexing, or text-interface checks, so regressions here can make CLI route generation bind or parse against the wrong types while the mock-backedmuxttests remain green.
func StandardLibrary(pl []*packages.Package) muxt.Checker {
return standardLibrary(indexImports(pl))
internal/muxt/path_value_types_test.go:54
- This case accepts incompatible repeated bindings: the nested
Echo(id)bindsidasstring, while the outerWraprequiresint. Generation shares the parsed-name set, so it reuses the rawidPathParamfor the later argument and emits a call toWrap(..., int)that cannot compile. Reject incompatible repeated path-value types during resolution (or preserve separate raw and parsed locals) instead of asserting this route is valid.
{name: "a nested call that takes a string decides before a later int", template: "GET /{id} Wrap(Echo(id), id)", param: "id"},
- Files reviewed: 20/20 changed files
- Comments generated: 3
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
d59de39 to
38a38d8
Compare
cb82817 to
4ab8fad
Compare
38a38d8 to
db40f24
Compare
4ffbdd1 to
d527e46
Compare
db40f24 to
52afbac
Compare
There was a problem hiding this comment.
🟡 Changes recommended
A critical path-parameter/callback naming collision can produce invalid generated code and must be handled before approval.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 20/20 changed files
- Comments generated: 1
- Review effort level: Lite
52afbac to
edb59b9
Compare
d527e46 to
a8856ac
Compare
edb59b9 to
49552c3
Compare
a8856ac to
8ebd9fa
Compare
49552c3 to
3b51c46
Compare
8ebd9fa to
5f2f882
Compare
5f2f882 to
fd50bdf
Compare
What route resolution needs to know about the standard library is narrow: the type each reserved argument binds to, whether a type marshals to or from text, and what a file header and a raw JSON message are. It found those by searching the loaded package's imports for net/http, context, encoding and the rest, so testing resolution meant loading or stubbing those packages, and a test stated the rules against one version of them. muxt.Checker asks exactly those questions. load.StandardLibrary is the one implementation, answering from the official standard library a run loaded. source.Package drops the imports it carried only for this. Resolution also records what generation used to work out from standard library types -- whether an argument passes to its parameter as it is, how it otherwise parses, and whether a path value formats with MarshalText -- so generate asks the standard library nothing and only forwards the checker to resolution. internal/muxt/muxtfakes holds the Checker fake, generated by counterfeiter, and internal/muxt/muxttest builds one from what a test says the standard library looks like: StandInChecker binds each reserved identifier to the stand-in of that shape the test's own package declares, and NewChecker with Binds, ParsesFromText and FormatsAsText says it a piece at a time. muxttest also type checks source that may import nothing. So muxt's tests declare stand-in types in their own source (a Request, a Context) rather than loading a module, and TestArgument, which did load one, now runs through muxt's exported API from muxt_test -- the fake imports muxt, so a test inside the package could not link it. TestPathValueTypes and TestPathValueTextMarshaler state what resolution records for a path parameter, and TestHandlerGenerationLeavesTheRouteAsResolved that generating a handler leaves its route as resolved -- the copy the generation fixes made. Generation reports an argument with no request value to parse, such as an sse message, as it did before. load.StandardLibrary, and what generation reads from resolution, are held by the integration suite here; unit tests over the real standard library come with the in-memory loader two changes on. Every generated file in the integration suite is unchanged. Assisted-by: Claude:claude-opus-5 gofumpt
A large share of the integration suite checks that flags mean what they say, and it could only do that by running the whole command: load a package, generate, compile. Deciding what a command line means happens before any of that, but nothing let a test stop there. Each command now builds its configuration -- flags parsed, defaults applied, what cannot work rejected -- and hands it to a runner, which loads the package and runs the implementation. Commands wires the real runners, in run.go; configurationOf wires ones that record what they are handed. TestCommandLineConfigurations states what command lines parse into, as composite literals, and TestCommandLineRejections the command lines refused, with the error a user sees. TestChangeDirectory states the directory -C runs a command in. None of them loads anything. Six commands have runners: the route listing, check, generate, the two template listings and the mutation run. --format is still read when a result is written, and generate-fake-server and explore-module still load in their own RunE; they are left for later. A generated file's header now records the version from the configuration rather than asking the build info again, so the version a run writes is the one its configuration holds. Assisted-by: Claude:claude-opus-5 gofumpt
generate's own tests covered a few percent of it: what a handler looks like for a form struct, an sse route, a status code in the name or a redirect in the template was only stated by the integration suite, which compiles and runs every scratch module and takes over a minute. internal/load/loadtest builds what a package load returns without loading the package graph: it writes the files, type checks them in memory, and imports the official standard library from the export data go list reports for it, reading it with gcexportdata -- as go/packages does -- and giving each package the imports go list reports, which is how check finds fmt behind html/template. The standard library is whichever the go command in use provides. A test binary pays a few hundred milliseconds once to read it. TestStandardLibrary states load.StandardLibrary's answers against it, and TestHydration the order load's hydration reports a missing package, a missing receiver and a variable that does not evaluate. internal/generate/testdata/generate/*.txtar holds one case per feature: the receiver's Go source, the templates, and the files and log lines generation produces. An archive holds the configuration it generates with, in its own config.json, beside the command line that parses into it; nothing outside the archive says what a case is. The directory an archive is in names the command, so a case runs alone as -run TestSnapshots/generate/sse. Reading it is encoding/json/v2, which the module now asks for Go 1.27 to have. TestSnapshots loads the case through loadtest and load.GenerateSource, as muxt generate does, generates, and compares; the 32 cases cover most of the package. It also fails on a generated file that imports a package it does not use. go test -run TestSnapshots -update rewrites the want/ files, and the diff is the review. A snapshot says what the generator does, not that the result compiles or serves requests; that stays the integration suite's job. Assisted-by: Claude:claude-opus-5 gofumpt
…mory muxt check is the command the integration suite runs most, and its reports -- a field the data type lacks, a template nothing renders, a route waiting for muxt generate -- were only stated there, behind a package load per script. internal/analysis/testdata/<command>/*.txtar holds cases for check, the route listing, and the template caller and call listings. The directory an archive is in names the command it runs, so a case runs alone as -run TestSnapshots/list-template-calls/calls, and the archive holds the configuration it runs with, in its own config.json, beside the command line that parses into it. A listing's configuration needs nothing to read as JSON: regexp.Regexp writes itself as the text it was compiled from, and internal/configjson says the rest -- a field the command line left alone is null rather than an empty list, and a member a configuration does not declare is an error. TestSnapshots loads the case through loadtest and internal/load's hydration, as the commands do, and compares what the analysis reports. Assisted-by: Claude:claude-opus-5 gofumpt
The mutation run's planning -- which templates each ExecuteTemplate call reaches, with what dot, and which variations apply -- read the loaded templates, the checker built on them, and the syntax trees it searched for the string literal a template was written in. So stating any of it took a module on disk and the go command. Planning now runs on an input: the source.Package internal/load reads. loadInput builds one with the go command; planFrom and revisionOf read nothing else. A template's string literal is found by parsing its file's text, which planning already holds, instead of searching the loader's syntax trees. With that, nothing reads load.Templates, and it goes. internal/mutation/testdata/test-template-mutations/*.txtar snapshots a dry run's report for ten cases -- a literal template, partials and trims, a template pattern, skipped mutants, the operand budget, other delimiters, a --diff revision, and the errors a plan returns -- each loaded through loadtest and planned in milliseconds. An archive holds the configuration it plans with, in its own config.json: regexp.Regexp writes itself as the text it was compiled from, so a pattern reads as the command line wrote it, and a pattern the command line left alone is null -- "" would be a pattern matching everything. As in the other two suites, the directory names the command, so a case runs alone as -run TestSnapshots/test-template-mutations/diff. CLAUDE.md's recipes for a feature, a bug and an error now start from the unit test at the layer that owns the behavior, and say what the in-memory loader cannot stand in for. Assisted-by: Claude:claude-opus-5 gofumpt
The snapshot archives hold the configuration they run with, and reading
one is encoding/json/v2: it knows how to read a *regexp.Regexp, which v1
could only do through a second struct carrying the pattern's source.
Everywhere else muxt reads or writes JSON went with it, so one library
answers "how does muxt read JSON": the module list, the overlay each
mutant is delivered through, the generated file's import map, and a
command's --format=json result.
Two differences the standard library documents, and the integration suite
insisted on:
- omitempty in v2 omits an empty JSON value -- null, "", [], {} -- and no
longer a zero number or a false. The fields that meant the latter say
omitzero now, so a mutant that took no measurable time still reports no
seconds, and a module listing still leaves out the flags a package does
not set.
- Reading is case sensitive, and a member the target does not declare is
an error rather than silence. Every name muxt reads was already written
by muxt or by the go command, so nothing had to change.
- Writing makes no promise about map order, writes a nil list or map as
[] or {}, and escapes neither <, >, & nor U+2028 and U+2029, where v1
sorted map keys, wrote null and escaped them. --format=json is read by
scripts, so it is written with the options that say v1's choices, and
TestWriteResultJSON states each one. A listing's import map now writes
through the encoder it is handed (MarshalJSONTo), so those options reach
it too, rather than a separate Marshal deciding its order.
The reports and listings are byte for byte what they were.
Assisted-by: Claude:claude-opus-5 gofumpt
A few loops spelled out what the slices and maps packages already name: gathering a map's keys and sorting them, asking whether a list holds something, copying a list, and a union of keys de-duplicated by hand. - slices.Sorted(maps.Keys(m)) where keys were gathered and then sorted: the module listing's directories, muxttest.Check's file names, and the template source files generation walks. sort is no longer imported anywhere in muxt's own code. - slices.ContainsFunc where a loop or an IndexFunc whose index went unused only asked whether something is there: marshalJSON refusing the execute callback, and a receiver method already in the interface. - slices.Clone for File.ImportSpecs' copy; its callers only range over the result or take its length. - sortedKeys in the generate snapshot harness appends every map's keys, sorts and compacts, rather than checking Contains before each append. Loops that find an element to change in place, build a different type, or return early with more than a yes or no stay as they were. Every generated file and report is unchanged. Assisted-by: Claude:claude-opus-5 gofumpt
reduces complexity with handling execute
A Definition now carries its path as a slice of Segment values, each a
literal or a {name} / {name...} wildcard. After ResolveCall a wildcard
segment also knows the type its value parses into and whether that type
marshals as text, replacing the pathValueTypes, pathValueMarshalers and
pathValueNames fields and the PathValueIdentifiers, ArgumentType and
PathValueTextMarshaler methods.
Route path generation iterates the segments instead of re-splitting the
pattern, and the muxt package answers whether an argument names a path
parameter through ArgumentIsPathParameter and PathParameter. A segment
that opens a brace without closing it as a wildcard is now rejected when
the definition is parsed rather than at ServeMux registration.
Assisted-by: Claude:claude-fable-5-1 gofumpt goimports staticcheck
A nested call argument records the ResultShape its results were validated against, so the generator switches on that instead of inspecting the signature again with go/types. Assisted-by: Claude:claude-fable-5-1 gofumpt
ResolveCall records whether the template data's result type has a StatusCode method or field, so the generator no longer builds a StatusCoder interface with go/types to probe the result itself. Assisted-by: Claude:claude-fable-5-1 gofumpt
Mutation testing showed the remainder wildcard, the unknown segment error and the argument predicates had no test in this package. ArgumentIsLastEventID no longer checks for a path parameter of that name, since the name is reserved and cannot be one. Assisted-by: Claude:claude-fable-5-1 gremlins gofumpt
The argument parser took the call's signature only to look up each parameter's type in step with the call's arguments, which the resolved Argument already carries. The arity check it repeated is muxt's. The result type it threaded through five functions was never read. Assisted-by: Claude:claude-fable-5-1 gofumpt goimports
A Type formats a Go type against a file's imports and answers whether it is a string, which basic kind it has, and whether it is identical to another, so packages after resolution can stop importing go/types. Assisted-by: Claude:claude-fable-5-1 gofumpt
Argument.ParamType becomes an accessor returning a source.Type, and a FieldBinding names its field and returns its element type the same way. File.TypeExpr spells a source.Type against the file's imports, and the form, multipart, JSON body and parse helpers take one. Assisted-by: Claude:claude-fable-5-1 gofumpt goimports
Assisted-by: Claude:claude-fable-5-1 gofumpt goimports
Definition.ResultDataType names the template data's result type for both handler shapes, and the callback and scope type accessors return a source.Type. writeHeadersAndStatusCode drops the result type it stopped reading. Assisted-by: Claude:claude-fable-5-1 gofumpt goimports
Assisted-by: Claude:claude-fable-5-1 gofumpt goimports
Assisted-by: Claude:claude-fable-5-1 gofumpt goimports
Resolve parses every templates variable's routes and resolves each call, inferring methods against an empty Receiver struct when no receiver type is named, so callers hand generation resolved definitions. Assisted-by: Claude:claude-fable-5-1 gofumpt goimports
TemplateRoutesFiles takes the definitions muxt.Resolve produced instead of the receiver type and checker it resolved them with, so generation reads only what resolution recorded and stops importing go/types. The CLI and the snapshot harness resolve before they generate. Resolution notes now print before generation logs rather than between them. Assisted-by: Claude:claude-fable-5-1 gofumpt goimports
Assisted-by: Claude:claude-fable-5-1 gofumpt
Assisted-by: Claude:claude-fable-5-1 gofumpt goimports
Assisted-by: Claude:claude-fable-5-1 gofumpt
Assisted-by: Claude:claude-fable-5-1 gremlins gofumpt
Assisted-by: Claude:claude-fable-5-1 gopls gofumpt
Assisted-by: Claude:claude-fable-5-1 gopls gofumpt
Assisted-by: Claude:claude-fable-5-1 gopls gofumpt
Assisted-by: Claude:claude-fable-5-1 gopls gofumpt
22be05b to
f1433ba
Compare
Route resolution asks a muxt.Checker about the standard library instead of searching imports. load.StandardLibrary is the real implementation, and internal/muxt/muxttest provides a mock plus in-memory type checking, so muxt tests no longer load a module.
Part 4 of 8, stacked on #148. Replaces #145.
🤖 Generated with Claude Code
https://claude.ai/code/session_01UjkpA4fvZ7xQAsY65Beprp