Skip to content

Ask the standard library through muxt.Checker - #149

Merged
crhntr merged 34 commits into
mainfrom
split/4-checker
Sep 28, 2026
Merged

crhntr merged 34 commits into
mainfrom
split/4-checker

Conversation

@crhntr

@crhntr crhntr commented Sep 14, 2026

Copy link
Copy Markdown
Member

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

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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 Checker adapter. The internal/load tests currently do not cover StandardLibrary'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-backed muxt tests 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) binds id as string, while the outer Wrap requires int. Generation shares the parsed-name set, so it reuses the raw idPathParam for the later argument and emits a call to Wrap(..., 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.

Comment thread internal/load/stdlib.go
Comment thread internal/muxt/path_value_types_test.go Outdated
Comment thread internal/muxt/call.go Outdated
@crhntr
crhntr force-pushed the split/3-source-package branch from d59de39 to 38a38d8 Compare September 16, 2026 01:36
@crhntr
crhntr force-pushed the split/3-source-package branch from 38a38d8 to db40f24 Compare September 16, 2026 06:07
@crhntr
crhntr force-pushed the split/4-checker branch 2 times, most recently from 4ffbdd1 to d527e46 Compare September 16, 2026 06:22
@crhntr
crhntr force-pushed the split/3-source-package branch from db40f24 to 52afbac Compare September 16, 2026 06:22
@crhntr
crhntr requested a lite review from Copilot September 16, 2026 06:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 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

Comment thread internal/muxt/path_value_types_test.go Outdated
@crhntr
crhntr force-pushed the split/3-source-package branch from 52afbac to edb59b9 Compare September 16, 2026 06:38
@crhntr
crhntr force-pushed the split/3-source-package branch from edb59b9 to 49552c3 Compare September 16, 2026 06:42
@crhntr
crhntr force-pushed the split/3-source-package branch from 49552c3 to 3b51c46 Compare September 16, 2026 06:58
Base automatically changed from split/3-source-package to main September 16, 2026 07:18
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
@crhntr
crhntr merged commit 7e70895 into main Sep 28, 2026
2 checks passed
@crhntr
crhntr deleted the split/4-checker branch September 28, 2026 18:07
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