use: a handler that answers an address is refused, at every depth

Two things, both closing a gap between what walk.go states and what it checks.

A HANDLER MAY NEVER TERMINATE A REQUEST. Enforced now. The inert check only ever
asked something strictly weaker — that a definition carrying middleware has
routes beneath it — which app.Use(zip.Static(assets)) passes on any app with a
route, and its depth-0 skip meant the app being SERVED was never asked at all.
That is exactly where a wiring file writes it, and the static handler then ran
before every route in the composition and answered each one with a file, or a
404 where no file existed. walk.go's closing comment said so plainly: the rule
condemned no form anyone writes.

Terminality is now a property the walk can read. A leaf constructor returns its
handler through zip.Terminal — exported, so wsx.Upgrade and third-party leaves
come through the same door — and the identity is the closure's code pointer,
never a name match. AdaptNetHTTP and AdaptNetHTTPMiddleware return closures with
the same body from the same file, and only their authors can know which one
answers. The handler comes back unwrapped, so registering a leaf at an address
is byte for byte the registration it always was. The depth-0 exemption stays
where it belongs, in the inert check alone: root middleware is router middleware
and covers requests that match nothing, so it is never inert.

ONE INTERPRETER. routesUnder re-walked App.entries per definition with a cycle
guard of its own — a second authority that could disagree with the walk about
the program it was asked about, and that read a definition's LIVE entry list
rather than the one the generation was built from. It is now one pass over the
occurrence slice, because a trail cell carries its *App, which makes an
occurrence's breadcrumb its ancestor chain; a cyclic composition never reaches a
reducer, so there is no guard to need. materialise reads its Handler through the
third accessor the other two always documented, instead of asserting the
payload.

The two AST guards in binding_test.go become one that is strictly stronger:
function granularity rather than file, bare type assertions as well as switches,
and every allow-listed site must still be HIT — so deleting or renaming one
fails the test rather than leaving it quietly vacuous.

derived() already owns operation-id collisions; nothing here touches it.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-02 14:32:27 -07:00
co-authored by Hanzo Dev
parent 0692608056
commit 41f9a5d0fc
9 changed files with 547 additions and 152 deletions
+35 -7
View File
@@ -855,6 +855,13 @@ context: prefix path, middleware stack (copies share structure), origin
trail (`root → billing → /v1`). It returns the flattened result — one
occurrence per visit: (def, kind, ctx, site).
Trail cells carry the `*App` as well as its label, so an occurrence's trail
IS its ancestor chain. That is how a subtree question ("does anything
beneath this definition answer an address?") is answered by reducing the
slice instead of walking the tree a second time — `routesUnder` is one pass
and needs no cycle guard, because a cyclic composition never reaches a
reducer.
`occurrence` is unexported and non-semantic: the walk's internal
flattened result, not a public model, not a stored source of truth. The
tree remains canonical. The materialized form is deliberate: the
@@ -865,15 +872,23 @@ routes — the slice is free.
**Binding requirement.** Every validator and reducer consumes the walk's
result; none reopens `App.entries` or recurses the tree. Downstream code
may switch on a normalized occurrence kind, never on payload types. A
package test enforces it mechanically: the only type switch over `node`
lives in walk.go.
may switch on a normalized occurrence kind, never on payload types.
`TestOneInterpreter_...` enforces it mechanically, by parsing this package's
own source: outside `descend`, the kind normalizer and the three
`occurrence` accessors, a payload read (`x.n.(T)`, `switch x.n.(type)`,
`case route:`) or an entry-list traversal (`range x.entries`) fails, with
file:line. The allow-listed sites are the two WRITERS — `Host.Drop` and
`Host.Reload`, which edit the receiver's own entries between generations and
recurse nowhere — and every allowance must still be HIT, so deleting or
renaming an allow-listed site fails the test rather than quietly making it
vacuous.
Pipeline, in order, or nothing publishes:
App tree
→ walk once
→ structural validation, complete (cycles, conflicts, inert middleware)
→ structural validation, complete (cycles, conflicts, inert
middleware, terminal handler in Use position)
→ derived validation (ID derivation, post-derivation collisions)
→ reducers (router, registry, SDK, MCP, CLI, llms.txt)
→ publish — or write nothing
@@ -915,6 +930,19 @@ it at its address: `app.Get("/assets/*", h)`. Use cannot say *where* a
handler answers; that is what operations are for. Middleware with nothing
beneath it in the subtree is refused, naming its call site.
Terminality is **declared, never name-matched**. A leaf constructor returns
its handler through `zip.Terminal("zip.Static", h)` — exported, so
`wsx.Upgrade` and third-party leaves use the same door — and the identity is
the closure's code pointer, not the constructor's name. `AdaptNetHTTP` and
`AdaptNetHTTPMiddleware` return closures with the same body from the same
file; only their authors can know which one answers, so only a declaration
can tell them apart. The refusal applies at **every depth, including the app
being served**, which is exactly where a wiring file puts
`app.Use(zip.Static(assets))`. The depth-0 exemption belongs to the inert
check alone: root middleware is registered as router middleware and covers
requests that match no route (404 logging, CORS preflight, recovery), so it
is never inert.
**The diamond is legal.** One definition, two references, two occurrences,
two prefixes, possibly two environments. No backend may veto a composition
the language permits.
@@ -934,9 +962,9 @@ language exists to kill.
## Validation errors
Accumulate, with trails: conflicts, cycles, inert middleware,
post-derivation ID collisions — joined with `errors.Join`, each carrying
call sites. Never fail on the first. Suspicion that cannot be a rule
Accumulate, with trails: conflicts, cycles, inert middleware, a terminal
handler in Use position, post-derivation ID collisions — joined with
`errors.Join`, each carrying call sites. Never fail on the first. Suspicion that cannot be a rule
(middleware after an App on the same receiver: intentional co-located,
latent bug cross-scope, structurally identical) lints in vet; semantics
stay expressive.
+5 -1
View File
@@ -40,7 +40,11 @@ import (
// zip handlers when feasible.
func AdaptNetHTTP(h http.Handler) Handler {
wrapped := adaptor.HTTPHandler(h)
return func(c *Ctx) error { return wrapped(c.fc) }
// TERMINAL: an http.Handler writes the response, so this answers whatever
// address it is registered at. In Use position it would answer every one of
// them, which is why the wildcard route above is THE spelling and this is
// refused at build time — see [Terminal].
return Terminal("zip.AdaptNetHTTP", func(c *Ctx) error { return wrapped(c.fc) })
}
// AdaptNetHTTPMiddleware wraps a stdlib middleware (func(http.Handler) http.Handler)
+158 -105
View File
@@ -6,131 +6,184 @@ import (
"go/token"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// The binding requirement, enforced mechanically rather than by review.
//
// Every validator and reducer consumes the walk's result; none reopens
// App.entries or recurses the tree, and downstream code switches on the
// NORMALISED occurrence kind, never on payload types. The way that stays true is
// that there is exactly one place allowed to ask what a node IS.
// "One walk, and every projection is a reducer over its result" is the claim the
// whole design rests on, and it is the kind of claim that decays silently: a
// second reader of App.entries is four lines, looks harmless in review, reads
// exactly like the first one, and immediately becomes a second authority on what
// a composition means. That is how routesUnder came to re-walk the tree with a
// cycle guard of its own, able to disagree with the walk about the program it
// was asked about.
//
// This is the test the language document asks for by name: the only type switch
// over `node` lives in walk.go.
func TestOnlyOneTypeSwitchOverNode(t *testing.T) {
// A switch over node necessarily has a `route` case: route is the member
// unique to node. Component is the other sealed set (Handler | *App), and
// switching on it is CONSTRUCTION — deciding what to append — not reduction,
// so it is not what this rule governs.
payload := map[string]bool{"Handler": true, "route": true, "App": true}
// Prose cannot hold the line and neither can review, so the line is a test. It
// parses this package's own source and refuses any read of an entry's payload,
// and any traversal of an entry list, outside the sites named below.
//
// It is deliberately SYNTACTIC. A type-checked version would be more precise and
// would need the whole dependency graph loaded to say anything at all; the four
// shapes below are the only ones the offence can take, they are cheap to
// recognise, and a test that runs in milliseconds with no toolchain
// prerequisites is a test that keeps running.
//
// x.n.(type) a type switch over a payload
// x.n.(T) a type assertion on a payload
// case route: a switch that names a payload type, however it was reached
// range x.entries a traversal of a program's entry list
//
// What is NOT an offence: switching over [Component] (the Use surface is a
// different closed set, and compose is entitled to know it — deciding what to
// APPEND is construction, not reduction), and reading an occurrence's kind
// through [occurrence.kind] and the accessors, which is what every reducer does.
func TestOneInterpreter_NothingOutsideTheWalkReadsAPayloadOrWalksTheTree(t *testing.T) {
// file/func -> why this one site is allowed. Every entry must be HIT, so
// deleting the walk cannot make this test pass by having nothing to find.
allowed := map[string]string{
"walk.go/descend": "the ONE type switch over node, and the one traversal of an entry list",
"walk.go/kind": "the normaliser: turns the payload into the discriminator reducers switch on",
"walk.go/middleware": "sanctioned accessor: one assertion, no traversal",
"walk.go/route": "sanctioned accessor: one assertion, no traversal",
"walk.go/app": "sanctioned accessor: one assertion, no traversal",
"host.go/Drop": "the WRITER of an entry list: filters the receiver's own entries " +
"between generations, and recurses nowhere",
"host.go/Reload": "the WRITER of an entry list: swaps a definition for a new version " +
"of itself by name, and recurses nowhere",
}
hit := map[string]bool{}
var offences []string
fset := token.NewFileSet()
files, err := filepath.Glob("*.go")
for _, path := range packageFiles(t) {
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
base := filepath.Base(path)
for _, decl := range file.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Body == nil {
continue
}
site := base + "/" + fn.Name.Name
report := func(n ast.Node, what string) {
if _, ok := allowed[site]; ok {
hit[site] = true
return
}
offences = append(offences, fset.Position(n.Pos()).String()+"\n\t\t"+what+
"\n\t\tin "+site+" — reduce walk's []occurrence instead")
}
ast.Inspect(fn.Body, func(n ast.Node) bool {
switch s := n.(type) {
case *ast.TypeSwitchStmt:
if isPayload(typeSwitchSubject(s)) {
report(s, "type switch over an entry's payload (x.n.(type))")
} else if namesPayloadType(s) {
report(s, "type switch naming a node payload type (case route:)")
}
case *ast.TypeAssertExpr:
// A nil Type is the `x.(type)` inside the switch above,
// already reported as the switch it belongs to.
if s.Type != nil && isPayload(s.X) {
report(s, "type assertion on an entry's payload (x.n.(T))")
}
case *ast.RangeStmt:
if isEntries(s.X) {
report(s, "traversal of an App's entry list (range x.entries)")
}
}
return true
})
}
}
if len(offences) > 0 {
sort.Strings(offences)
t.Errorf("the walk is no longer the only interpreter — %d second authority/authorities:\n\t%s",
len(offences), strings.Join(offences, "\n\t"))
}
for site, why := range allowed {
if !hit[site] {
t.Errorf("%s no longer does what it is allow-listed for (%s) — "+
"if it moved, move the allowance; if it is gone, delete it, but do not leave this test "+
"passing because there is nothing left to check", site, why)
}
}
}
// packageFiles is this package's own source, tests excluded: a test file is
// allowed to take a program apart any way it likes, because a test asserts on
// the AST rather than interpreting it for a running server.
func packageFiles(t *testing.T) []string {
t.Helper()
ents, err := os.ReadDir(".")
if err != nil {
t.Fatal(err)
t.Fatalf("read package dir: %v", err)
}
offenders := map[string][]int{}
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
var out []string
for _, e := range ents {
name := e.Name()
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
file, err := parser.ParseFile(fset, f, src, 0)
if err != nil {
t.Fatalf("parse %s: %v", f, err)
}
ast.Inspect(file, func(n ast.Node) bool {
ts, ok := n.(*ast.TypeSwitchStmt)
if !ok {
return true
}
hits, overNode := 0, false
for _, stmt := range ts.Body.List {
cc, ok := stmt.(*ast.CaseClause)
if !ok {
continue
}
for _, expr := range cc.List {
name := ""
switch e := expr.(type) {
case *ast.Ident:
name = e.Name
case *ast.StarExpr:
if id, ok := e.X.(*ast.Ident); ok {
name = id.Name
}
}
if payload[name] {
hits++
}
if name == "route" {
overNode = true
}
}
}
if hits >= 2 && overNode {
offenders[f] = append(offenders[f], fset.Position(ts.Pos()).Line)
}
return true
})
out = append(out, name)
}
for f, lines := range offenders {
if f != "walk.go" {
t.Errorf("type switch over node at %s:%v — the only one may live in walk.go. "+
"Reducers switch on the normalised occurrence kind (o.kind()), never on payload types",
f, lines)
}
}
if len(offenders["walk.go"]) == 0 {
t.Error("no type switch over node found in walk.go — the walk is supposed to be the one place that asks what a node is")
if len(out) < 20 {
t.Fatalf("found %d source files; the scan is not seeing the package", len(out))
}
sort.Strings(out)
return out
}
// The other half of the same requirement: nothing outside the walk recurses the
// tree by reopening App.entries.
func TestNothingOutsideTheWalkRecursesTheTree(t *testing.T) {
// Construction and mutation necessarily touch entries — appending to a
// program, and editing one between generations, is what they ARE. The rule
// governs VALIDATORS and REDUCERS: those consume the walk's result.
allowed := map[string]bool{
"walk.go": true, // the traversal, and the validation that rides it
"compose.go": true, // Use / Group append
"host.go": true, // Drop / Reload edit the program between generations
"generation.go": true, // transact's snapshot and rollback
// typeSwitchSubject is the expression a type switch switches ON, for both
// spellings — `switch x := e.(type)` and `switch e.(type)`.
func typeSwitchSubject(s *ast.TypeSwitchStmt) ast.Expr {
var e ast.Expr
switch a := s.Assign.(type) {
case *ast.AssignStmt:
if len(a.Rhs) == 1 {
e = a.Rhs[0]
}
case *ast.ExprStmt:
e = a.X
}
fset := token.NewFileSet()
files, _ := filepath.Glob("*.go")
for _, f := range files {
if strings.HasSuffix(f, "_test.go") || allowed[f] {
if ta, ok := e.(*ast.TypeAssertExpr); ok {
return ta.X
}
return nil
}
// namesPayloadType catches a switch over a payload reached under any other name,
// by looking at what it decides: `route` is a node payload and belongs to no
// other closed set in this package, so a case for it is a case over node.
func namesPayloadType(s *ast.TypeSwitchStmt) bool {
for _, stmt := range s.Body.List {
cc, ok := stmt.(*ast.CaseClause)
if !ok {
continue
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
file, err := parser.ParseFile(fset, f, src, 0)
if err != nil {
t.Fatal(err)
}
ast.Inspect(file, func(n ast.Node) bool {
rng, ok := n.(*ast.RangeStmt)
if !ok {
for _, texp := range cc.List {
if id, ok := texp.(*ast.Ident); ok && id.Name == "route" {
return true
}
sel, ok := rng.X.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "entries" {
return true
}
t.Errorf("%s:%d ranges over App.entries — every reducer consumes the walk's "+
"result instead, so scoping semantics stay in one function",
f, fset.Position(rng.Pos()).Line)
return true
})
}
}
return false
}
// isPayload reports whether e reads an entry's or an occurrence's payload field.
// Both spell it `n`, which is what makes this one predicate.
func isPayload(e ast.Expr) bool { return fieldNamed(e, "n") }
// isEntries reports whether e is an App's program.
func isEntries(e ast.Expr) bool { return fieldNamed(e, "entries") }
func fieldNamed(e ast.Expr, name string) bool {
sel, ok := e.(*ast.SelectorExpr)
return ok && sel.Sel != nil && sel.Sel.Name == name
}
+2 -1
View File
@@ -173,7 +173,8 @@ func (a *App) materialise(occ []occurrence, ctl []route) *fiber.App {
switch o.kind() {
case kindMiddleware:
if o.ctx.depth == 0 {
f.Use(toFiberHandler(a, o.n.(Handler)))
h, _ := o.middleware()
f.Use(toFiberHandler(a, h))
}
case kindRoute:
r, _ := o.route()
+65
View File
@@ -3,9 +3,11 @@ package zip
import (
"fmt"
"path/filepath"
"reflect"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
fiber "github.com/zap-proto/fiber/v3"
@@ -172,6 +174,69 @@ func (*App) component() {}
// app.Use(zip.H(func(c *zip.Ctx) error {…})) // bare closure at Use
func H(h Handler) Component { return h }
// Terminal marks h as a handler that ANSWERS an address, and returns it.
//
// A [Handler] may never terminate a request — that is the rule the three node
// kinds encode, and the reason Use takes handlers that WRAP while an address is
// something only a route method can say. Enforcing it needs terminality to be a
// property the walk can READ, and a func value carries nothing a walk can read:
// two closures are the same type, and matching on the name of the constructor
// that built one would bless zip's own leaves and condemn nobody else's.
//
// So the constructor declares it, once, at the only place that knows what it
// built. Every leaf constructor in this module goes through here —
//
// func Static(fsys fs.FS, opts ...StaticOption) Handler {
// return Terminal("zip.Static", func(c *Ctx) error { … })
// }
//
// — and so can a leaf constructor anywhere else, which is why this is exported.
// The [structural] check then refuses any of them in Use position, naming what
// and where. The handler comes back UNCHANGED and unwrapped: marking costs a
// leaf nothing on the served path, and `app.Get("/assets/*", zip.Static(assets))`
// is the same registration it always was.
//
// what names the constructor for the diagnostic ("zip.Static"), because a
// refusal that says only "a terminal handler" leaves the reader to guess which
// argument on the line it means.
func Terminal(what string, h Handler) Handler {
if h == nil {
// A nil func has code pointer 0, and recording THAT would make every
// nil Handler in the process terminal. Nothing to mark, so mark nothing.
return nil
}
terminals.Store(codeOf(h), what)
return h
}
// terminalOf reports whether h was declared terminal, and by which constructor.
func terminalOf(h Handler) (string, bool) {
what, ok := terminals.Load(codeOf(h))
if !ok {
return "", false
}
return what.(string), true
}
// terminals maps a terminal handler's CODE to the constructor that declared it.
//
// The identity is the closure's code pointer, because a func value is the only
// thing there is to key on and every Handler a given constructor returns runs
// that constructor's one closure body. Go emits a distinct function symbol per
// closure literal and folds no two of them together, so this is an identity and
// not a heuristic — zip.AdaptNetHTTP and zip.AdaptNetHTTPMiddleware return
// closures with the SAME body from the same file and are distinguished
// correctly, which is what TestUse_TerminalityIsADeclaredPropertyNotAName pins.
// One entry covers every instance a constructor will ever hand out, including
// ones built long after this map was last read.
//
// A sync.Map because a constructor runs wherever a program is assembled while a
// build may be reducing a walk on another goroutine. It is read once per
// middleware occurrence at build time and never on the served path.
var terminals sync.Map // uintptr -> string
func codeOf(h Handler) uintptr { return reflect.ValueOf(h).Pointer() }
// Use appends components to this app's program, in order.
//
// # Snapshot semantics
+151
View File
@@ -3,10 +3,12 @@ package zip
import (
"context"
"encoding/json"
"net/http"
"strconv"
"strings"
"sync"
"testing"
"testing/fstest"
)
// These are internal tests on purpose. The walk, the seal and the occurrence
@@ -509,6 +511,155 @@ func TestConflicts_NamesTheCompositionPath(t *testing.T) {
}
}
// ── Use position: a Handler may never terminate a request ───────────────────
// TestUse_ATerminalHandlerIsRefusedOnTheAppBeingServed.
//
// This is the rule the three node kinds encode — Use is for handlers that WRAP,
// an address is something only a route method can say — and until the walk
// could SEE terminality, nothing enforced it. The inert check enforces something
// strictly weaker (a definition carrying middleware must have routes beneath
// it), which app.Use(zip.Static(assets)) passes on any app that has a route at
// all. It would then answer every one of them.
//
// Depth 0 is the sharp case: the app being SERVED is where a static handler gets
// composed in real wiring files, and it was skipped entirely.
func TestUse_ATerminalHandlerIsRefusedOnTheAppBeingServed(t *testing.T) {
assets := fstest.MapFS{"main.css": &fstest.MapFile{Data: []byte("body{}")}}
root := quiet("root")
root.Get("/x", func(c *Ctx) error { return nil })
root.Use(Static(assets)) // depth 0 — the app being served
err := build(root)
if err == nil {
t.Fatal("a terminal handler was composed with Use and the composition was accepted — " +
"it answers every request beneath it, so nothing composed after it is ever reached")
}
for _, want := range []string{"zip.Static", `"root"`, "compose_test.go:", "ANSWERS"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("refusal does not name %q: %v", want, err)
}
}
// The blessed spelling is untouched: registered AT an address, it serves.
fine := quiet("fine")
fine.Get("/assets/*", Static(assets))
if err := build(fine); err != nil {
t.Fatalf("a leaf registered at its address was refused: %v", err)
}
if code, body := wireGET(t, fine, "/assets/main.css"); code != 200 || body != "body{}" {
t.Errorf("GET /assets/main.css = %d %q, want 200 %q", code, body, "body{}")
}
}
// TestUse_ATerminalHandlerIsRefusedInsideAnIncludedDefinition: the same rule one
// level down, where the definition that wrote the line is not the one being
// served — so the message has to name the definition AND the composition trail
// to be actionable at all.
func TestUse_ATerminalHandlerIsRefusedInsideAnIncludedDefinition(t *testing.T) {
ui := quiet("ui")
ui.Use(Static(fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html>")}}))
ui.Get("/health", func(c *Ctx) error { return nil }) // routes, so it is not merely inert
root := quiet("root")
root.Group("/app").Use(ui)
err := build(root)
if err == nil {
t.Fatal("a terminal handler inside an included definition was accepted")
}
for _, want := range []string{"zip.Static", `"ui"`, "root → /app → ui", "compose_test.go:"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("refusal does not name %q: %v", want, err)
}
}
}
// TestUse_TerminalityIsADeclaredPropertyNotAName is the mechanism, pinned.
//
// A name match would bless zip's own three leaves and miss every leaf anyone
// else writes — the wrong half of the fleet. So a constructor DECLARES what it
// built, through [Terminal], and the two handlers below are the proof that the
// declaration and not the shape is what decides: AdaptNetHTTP and
// AdaptNetHTTPMiddleware return closures with the same body, over the same
// adapter type, from the same file. One answers, one wraps. Only their authors
// could know which, and only one of them is refused.
func TestUse_TerminalityIsADeclaredPropertyNotAName(t *testing.T) {
wraps := quiet("wraps")
wraps.Get("/x", func(c *Ctx) error { return nil })
wraps.Use(AdaptNetHTTPMiddleware(func(next http.Handler) http.Handler { return next }))
if err := build(wraps); err != nil {
t.Fatalf("a net/http MIDDLEWARE adapter was refused in Use position: %v", err)
}
answers := quiet("answers")
answers.Get("/x", func(c *Ctx) error { return nil })
answers.Use(AdaptNetHTTP(http.NotFoundHandler()))
err := build(answers)
if err == nil {
t.Fatal("a net/http HANDLER adapter was accepted in Use position; it answers every request")
}
if !strings.Contains(err.Error(), "zip.AdaptNetHTTP") {
t.Errorf("refusal does not name the constructor: %v", err)
}
// And it is not zip's own three: anyone's leaf constructor says so the same
// way, which is why Terminal is exported. (wsx.Upgrade is one of these.)
mine := quiet("mine")
mine.Get("/x", func(c *Ctx) error { return nil })
mine.Use(Terminal("spa.Shell", func(c *Ctx) error { return c.String(200, "<html>") }))
err = build(mine)
if err == nil {
t.Fatal("a third-party terminal handler was accepted in Use position")
}
if !strings.Contains(err.Error(), "spa.Shell") {
t.Errorf("refusal does not name the third-party constructor: %v", err)
}
// The SAME closure, undeclared, is ordinary middleware and stays legal —
// terminality is what the constructor said, never what the handler looks
// like or where it came from.
plain := quiet("plain")
plain.Get("/x", func(c *Ctx) error { return nil })
plain.Use(H(func(c *Ctx) error { return c.String(200, "<html>") }))
if err := build(plain); err != nil {
t.Fatalf("an undeclared handler was refused in Use position: %v", err)
}
}
// TestInert_TheServedAppsMiddlewareIsGlobalAndSoIsNeverInert documents the one
// thing the depth-0 skip in the inert check guards, because the terminal rule
// above deliberately does NOT share it.
//
// Middleware on the app being served is registered as ROUTER middleware
// (App.materialise), so it runs for every request the process answers —
// including requests that match no route at all, which is what 404 logging, CORS
// preflight and recovery exist for. A root that declares a logger and gets its
// routes from the definitions it composes has nothing wrong with it, and "no
// routes beneath it" is not evidence of anything there. That is the whole of the
// exemption: it is about COVERAGE, and it says nothing about whether a handler
// wraps — which is why the terminal check asks its own question, at depth 0 too.
func TestInert_TheServedAppsMiddlewareIsGlobalAndSoIsNeverInert(t *testing.T) {
ran := 0
root := quiet("root") // no routes of its own, ever
root.Use(H(func(c *Ctx) error { ran++; return c.Continue() }))
child := quiet("child")
child.Get("/x", func(c *Ctx) error { return c.String(200, "ok") })
root.Use(child)
if err := build(root); err != nil {
t.Fatalf("the served app's own middleware was called inert: %v", err)
}
if code, _ := wireGET(t, root, "/nowhere"); code != 404 {
t.Fatalf("GET /nowhere = %d, want 404", code)
}
if ran == 0 {
t.Error("root middleware did not run for an unmatched request — if that were true, " +
"the depth-0 exemption would be wrong and this middleware would be inert after all")
}
}
// ── concurrency ─────────────────────────────────────────────────────────────
// TestRegistry_RaceCleanOnALiveGeneration. Registry is read from serving goroutines —
+5 -2
View File
@@ -65,7 +65,10 @@ func Static(fsys fs.FS, opts ...StaticOption) Handler {
for _, o := range opts {
o(&cfg)
}
return func(c *Ctx) error {
// TERMINAL: it answers the address it is registered at, so composing it
// with Use — where it would answer every address beneath — is refused at
// build time rather than serving files in place of the program.
return Terminal("zip.Static", func(c *Ctx) error {
sub := c.fc.Params("*")
if cfg.stripPrefix != "" {
sub = strings.TrimPrefix(c.fc.Path(), cfg.stripPrefix)
@@ -85,7 +88,7 @@ func Static(fsys fs.FS, opts ...StaticOption) Handler {
return c.Next() // missing → SPA catch-all / next route wins
}
return serveFile(c, f, info)
}
})
}
// staticClean maps a raw wildcard subpath to a validated fs path. ok=false is
+120 -34
View File
@@ -93,9 +93,13 @@ func (o occurrence) kind() kind {
}
}
func (o occurrence) route() (route, bool) { r, ok := o.n.(route); return r, ok }
func (o occurrence) app() (*App, bool) { a, ok := o.n.(*App); return a, ok }
func (o occurrence) abs(path string) string { return joinPath(o.ctx.prefix, path) }
// middleware / route / app read the payload once a [kind] has already said
// which one it is. They are accessors and not a second interpreter: each is one
// assertion with no traversal, no ordering and no semantics of its own.
func (o occurrence) middleware() (Handler, bool) { h, ok := o.n.(Handler); return h, ok }
func (o occurrence) route() (route, bool) { r, ok := o.n.(route); return r, ok }
func (o occurrence) app() (*App, bool) { a, ok := o.n.(*App); return a, ok }
func (o occurrence) abs(path string) string { return joinPath(o.ctx.prefix, path) }
// scope is a visit's path context: everything that is a property of WHERE
// rather than of WHAT.
@@ -168,19 +172,27 @@ func (s *mwStack) included() []Handler {
}
// trail is a persistent composition breadcrumb: root → plugin → group.
//
// It carries the definition itself and not only the name it renders under,
// which makes it the ANCESTOR CHAIN as well as the breadcrumb: an occurrence's
// trail names, in pointers, every definition this event sits beneath. That is
// what lets a question about a subtree ("does anything beneath this definition
// answer an address?") be answered by reading the occurrence slice instead of
// walking the tree a second time — see [routesUnder].
type trail struct {
def *App
label string
site callsite
up *trail
n int
}
func (t *trail) push(label string, site callsite) *trail {
func (t *trail) push(def *App, label string, site callsite) *trail {
n := 1
if t != nil {
n = t.n + 1
}
return &trail{label: label, site: site, up: t, n: n}
return &trail{def: def, label: label, site: site, up: t, n: n}
}
// String renders the breadcrumb root-first.
@@ -209,11 +221,13 @@ func (t *trail) String() string {
//
// BINDING REQUIREMENT: every validator and reducer consumes this result. None
// reopens App.entries or recurses the tree, and none switches on payload types —
// they switch on the normalised [kind]. TestOnlyOneTypeSwitchOverNode enforces
// it mechanically.
// they switch on the normalised [kind].
// TestOneInterpreter_NothingOutsideTheWalkReadsAPayloadOrWalksTheTree enforces it
// mechanically, and every site it allow-lists must still be doing the thing it
// was allowed for.
func walk(root *App) ([]occurrence, error) {
out := make([]occurrence, 0, 32)
sc := scope{in: root, trail: (*trail)(nil).push(nameOr(root, "root"), callsite{})}
sc := scope{in: root, trail: (*trail)(nil).push(root, nameOr(root, "root"), callsite{})}
if err := descend(root, sc, nil, &out); err != nil {
return nil, err
}
@@ -261,7 +275,7 @@ func descend(a *App, sc scope, ancestors []*App, out *[]occurrence) error {
in: n,
prefix: sc.prefix + n.prefix,
mw: mw,
trail: sc.trail.push(n.label(), e.site),
trail: sc.trail.push(n, n.label(), e.site),
depth: sc.depth + 1,
}
*out = append(*out, occurrence{n: n, site: e.site,
@@ -276,8 +290,9 @@ func descend(a *App, sc scope, ancestors []*App, out *[]occurrence) error {
// structural is validation stage ONE: everything decidable from the shape of the
// composition alone — cycles (already caught by the traversal), duplicated
// addresses, MCP tool-name collisions, two open catalogues, and middleware with
// nothing beneath it.
// addresses, MCP tool-name collisions, two open catalogues, middleware with
// nothing beneath it, and a handler that ANSWERS an address composed in Use
// position.
//
// It reports EVERY problem, not the first. Failing fast would mean composing two
// apps that disagree about three addresses takes three builds to learn three
@@ -301,6 +316,14 @@ func structural(occ []occurrence) error {
mwOnly := map[*App]*inert{}
var mwOrder []*App
seenDef := map[*App]bool{}
// Keyed by the LINE, not the definition: one message per offending Use,
// however many times the definition holding it is included.
type useSite struct {
def *App
at callsite
}
badUse := map[useSite]bool{}
var errs []error
for _, o := range occ {
@@ -335,8 +358,56 @@ func structural(occ []occurrence) error {
at[method] = mine
case kindMiddleware:
// TERMINALITY IS ASKED FIRST, AND AT EVERY DEPTH.
//
// "A Handler may never terminate a request" is the rule the three
// node kinds encode, and until now nothing enforced it: the inert
// check below is strictly weaker — it asks whether a definition
// carrying middleware has routes beneath it — so
// `app.Use(zip.Static(assets))` passed on any app with a route, and
// then the static handler ran BEFORE every route in the composition
// and answered each one with a file, or a 404 where no file existed.
// The most literal spelling of the failure the whole rule exists to
// stop, accepted in silence.
//
// Use cannot say WHERE a handler answers. That is not a missing
// feature, it is what Use MEANS: a handler registered there runs for
// everything beneath it, so a handler that terminates terminates
// everything. The address belongs on the route method, where it is
// written down and where the router can resolve it against every
// other pattern by specificity.
//
// Terminality is a PROPERTY and never a name match. A constructor
// that builds a leaf says so once, at the only place that can know,
// by returning its handler through [Terminal] — so zip's own leaves
// and a third party's are refused by the same rule, and renaming a
// function changes nothing.
if h, isMw := o.middleware(); isMw {
if what, terminal := terminalOf(h); terminal {
k := useSite{o.ctx.in, o.site}
if !badUse[k] {
badUse[k] = true
errs = append(errs, fmt.Errorf("zip: %s composes %s as middleware at %s (via %s) — that handler ANSWERS "+
"an address, and Use is for handlers that WRAP.\n\tIn Use position it runs before every route beneath "+
"this definition and terminates the request there, so nothing composed after it is ever reached.\n\t"+
"Register it at the address it serves: app.Get(\"/assets/*\", h) rather than app.Use(h)",
o.ctx.in.who(), what, o.site, o.ctx.trail.String()))
}
}
}
// Depth 0 is the app being SERVED, and its middleware is never
// inert: materialisation registers it as ROUTER middleware (see
// [App.materialise]), so it runs for every request the process
// answers — including ones that match no route at all, which is what
// 404 logging, CORS preflight and recovery are for. A root that
// declares a logger and composes its routes from children has
// nothing wrong with it. The exemption is exactly this: middleware
// whose coverage does not come from routes beneath it. It is NOT an
// exemption from the terminal check above, which asks a different
// question — whether the handler wraps at all — and asks it of the
// served app too.
if o.ctx.depth == 0 {
continue // the app being served IS the server; its middleware is global
continue
}
if _, ok := mwOnly[o.ctx.in]; !ok {
mwOnly[o.ctx.in] = &inert{mw: o.site, trail: o.ctx.trail.String()}
@@ -375,17 +446,21 @@ func structural(occ []occurrence) error {
}
}
// The SUBTREE, not the definition's own entries. A group's routes usually
// come from the definitions it includes, and its middleware legitimately
// wraps those — that is what a scoped Group is for.
has := routesUnder(occ)
for _, def := range mwOrder {
if routesUnder(def, map[*App]bool{}) {
if has[def] {
continue
}
st := mwOnly[def]
errs = append(errs, fmt.Errorf("zip: %s declares middleware at %s and no routes anywhere beneath it (via %s) — "+
"a definition's middleware wraps the routes in its OWN subtree, so with none it would "+
"silently never run.\n\tIf it WRAPS: compose the routes it guards beneath this definition, "+
"or move it to the app that has them.\n\tIf it ANSWERS an address (zip.Static and friends), "+
"register it at that address instead: app.Get(\"/assets/*\", h) rather than app.Use(h) — "+
"Use cannot say WHERE a handler answers, which is why it is only for handlers that wrap",
"or move it to the app that has them.\n\tIf it ANSWERS an address, register it at that address "+
"instead: app.Get(\"/assets/*\", h) rather than app.Use(h) — Use cannot say WHERE a handler "+
"answers, which is why it is only for handlers that wrap",
def.who(), st.mw, st.trail))
}
return errors.Join(errs...)
@@ -438,23 +513,28 @@ func verify(root *App) error {
return derived(occ)
}
// routesUnder reports whether anything in def's subtree answers an address.
func routesUnder(def *App, seen map[*App]bool) bool {
if seen[def] {
return false // a cycle is reported elsewhere; do not spin here
}
seen[def] = true
for _, e := range def.entries {
switch n := e.n.(type) {
case route:
return true
case *App:
if routesUnder(n, seen) {
return true
}
// routesUnder is the set of definitions with a route ANYWHERE beneath them.
//
// One pass over the occurrences, no traversal: a route occurrence's trail IS
// its ancestor chain (see [trail]), so every definition it sits beneath is the
// cells above it. This used to recurse over `def.entries` per definition, which
// made it a SECOND interpreter of the same program — one that needed its own
// cycle guard, could disagree with the walk about what a composition contains,
// and read a definition's live entry list rather than the one the generation
// was built from. Reducing the slice needs no cycle guard at all: a cyclic
// composition never reaches a reducer, because [descend] returns the error and
// [walk] stops.
func routesUnder(occ []occurrence) map[*App]bool {
has := map[*App]bool{}
for _, o := range occ {
if o.kind() != kindRoute {
continue
}
for t := o.ctx.trail; t != nil; t = t.up {
has[t.def] = true
}
}
return false
return has
}
// addrKey is one path as the conflict check sees it: the router's own spelling,
@@ -535,6 +615,12 @@ func dotted(prefix string) string {
// A handler that ANSWERS an address is registered at that address; Use is for
// handlers that WRAP. app.Use(static) cannot say WHERE it answers, which is the
// same "structure encodes the semantics" argument that made Group the way to
// express a middleware scope rather than line position. This is why the inert
// check above condemns no form anyone writes: every zip.Static in the fleet is
// already registered at an address.
// express a middleware scope rather than line position.
//
// That rule is now CHECKED rather than merely stated. [structural] refuses a
// terminal handler in Use position — at any depth, including on the app being
// served, which is exactly where a wiring file puts app.Use(zip.Static(assets))
// — and it decides terminality by the property a constructor declares through
// [Terminal], never by the name of the function that built the handler. A name
// match would bless zip's three leaves and miss every leaf anyone else writes,
// which is the wrong half of the fleet.
+6 -2
View File
@@ -61,10 +61,14 @@ func Upgrade(fn Handler, opts ...Config) zip.Handler {
} else {
up.CheckOrigin = func(ctx *fasthttp.RequestCtx) bool { return true }
}
return func(c *zip.Ctx) error {
// TERMINAL: the upgrade takes over the connection, so this answers the
// address it is registered at and never yields to anything after it.
// zip.Terminal is what makes app.Use(wsx.Upgrade(…)) a build error instead
// of a server that tries to upgrade every request it receives.
return zip.Terminal("wsx.Upgrade", func(c *zip.Ctx) error {
rc := c.Fiber().RequestCtx()
return up.Upgrade(rc, func(ws *Conn) {
_ = fn(ws)
})
}
})
}