Files
zeekay 999e2153b3 Type every wire: bytes, forms, streams, sockets, redirects, unions
A typed op could only take one JSON value and answer one, so uploads,
signed webhooks, form posts, YAML and merge-patch bodies, PDFs, SSE,
WebSockets, OAuth redirects and upstream relays stayed untyped routes
with no schema, tool, command or SDK method. Each is now a typed op and
its REST wire is unchanged.

Requests: a zip.Body field takes the body and Content-Type exactly as
sent; an In implementing Parser decodes itself; form: fields and
*zip.File parts bind url-encoded and multipart forms; cookie: binds a
request cookie as an in: cookie parameter. Consumes names the media.

Answers: zip.Body (bytes, filename, its own status and headers, a
Reader streams chunked), Verbatim[T] (a relay documented as T), Sse[T]
(events with keep-alive), Socket[M] (an upgrade after the contract
runs; 426 without one, 501 over ZAP), Redirect, CookieCoder, Produces,
and unions: a type whose OneOf method's results are the alternatives,
with Or[A,B] as the stock one filing each under the status it states.

Projections follow from the manifest: the document publishes binary
media, form schemas, x-events, x-socket with 101, 3xx with Location
and oneOf; MCP takes bytes as base64 and returns bytes, text or
collected events as tool content, listing a socket op in _meta as no
tool; the CLI reads --body @file|-, --field name=@file and --out,
prints events as they arrive and bridges a socket to stdin and stdout.
tools/list is ProjectMCP over the manifest, the projection MCPTools
already was.

Prose reaches promoted fields (filed under the declaring struct), a
type defined over another struct, and the type arguments of zip's
kinds and OneOf results; an instantiated generic is published under
its name followed by its argument names instead of an illegal
component key. The unused reflect-only document and tool builders go.
2026-10-09 09:42:00 -07:00

1086 lines
37 KiB
Go

// Package zipdoc extracts the documentation a service has already written — the
// doc comments on its typed handlers and on the fields of their In/Out types —
// and emits the zip.Describe calls that carry it into the OpenAPI spec and the
// MCP tool list at run time.
//
// Go drops comments at compile time; reflection sees types and tags and never
// prose. So the ONLY way for a doc comment to reach the spec is a build-time
// pass over the AST, and this is it. cmd/zipdoc is the command around this
// package; the split is CLI (flags, files, exit codes) from extraction
// (source in, Doc out).
package zipdoc
import (
"bytes"
"encoding/json"
"fmt"
"github.com/zap-proto/zip/internal/addr"
"go/ast"
"go/constant"
"go/parser"
"go/token"
"go/types"
"path/filepath"
"reflect"
"strings"
"unicode"
"unicode/utf8"
"golang.org/x/tools/go/packages"
"github.com/zap-proto/zip/internal/jsontag"
)
// ZipPkg is the package whose generic registrars this pass recognises. Matching
// on the import path rather than on the local name means an aliased import, a
// dot import and a plain one all resolve the same.
const ZipPkg = "github.com/zap-proto/zip"
// verbs are the typed registrars and the method each one registers. An untyped
// app.Get has no In/Out and therefore no schema, no MCP tool and nothing here to
// document — the typed surface IS the documented surface.
var verbs = map[string]string{
"Get": "GET",
"Post": "POST",
"Put": "PUT",
"Patch": "PATCH",
"Delete": "DELETE",
}
// Op is one typed registration and everything its source says about it. The
// fields mirror zip.Doc — emit.go writes them straight into a zip.Doc literal,
// and a test pins the two shapes together.
type Op struct {
Method string
Path string
Description string
Fields map[string]string
// Example and Response are compacted JSON, "" when the comment gave none.
Example string
Response string
}
// Key is the operation's identity, the same one zip's registry uses.
// Key is the address half of the documentation key. The whole key also carries
// the declaring package, which Render adds — see [zip.DocKey] — because this map
// is one map for a process and an address is unique only within one app.
func (o Op) Key() string { return o.Method + " " + o.Path }
// Package is one loaded package and the operations registered in it.
type Package struct {
Dir string // directory the generated file belongs in
Name string // package clause
Path string // import path, so a package inside zip itself skips the import
Ops []Op
}
// Load type-checks the packages matched by patterns (relative to dir) and
// extracts every typed registration in them.
//
// It insists the packages compile. Extraction reads resolved types — which
// generic instantiation a call is, which declaration a handler name refers to —
// so a package that does not type-check yields answers that are wrong rather
// than missing, and wrong is what this whole design exists to prevent.
func Load(dir string, patterns []string) ([]Package, error) {
if len(patterns) == 0 {
patterns = []string{"."}
}
fset := token.NewFileSet()
pkgs, err := packages.Load(&packages.Config{
Mode: packages.NeedName | packages.NeedFiles | packages.NeedCompiledGoFiles |
packages.NeedImports | packages.NeedTypes | packages.NeedSyntax | packages.NeedTypesInfo,
Dir: dir,
Fset: fset,
}, patterns...)
if err != nil {
return nil, err
}
var errs []string
for _, p := range pkgs {
for _, e := range p.Errors {
errs = append(errs, p.PkgPath+": "+e.Error())
}
}
if len(errs) > 0 {
if len(errs) > 5 {
errs = append(errs[:5], fmt.Sprintf("(+%d more)", len(errs)-5))
}
return nil, fmt.Errorf("packages do not compile:\n\t%s", strings.Join(errs, "\n\t"))
}
e := &extractor{load: fset, own: token.NewFileSet(), files: map[string]*ast.File{}}
out := make([]Package, 0, len(pkgs))
for _, p := range pkgs {
ops, err := e.pkg(p)
if err != nil {
return nil, err
}
files := p.GoFiles
if len(files) == 0 {
files = p.CompiledGoFiles
}
if len(files) == 0 {
continue
}
out = append(out, Package{
Dir: filepath.Dir(files[0]),
Name: p.Name,
Path: p.PkgPath,
Ops: ops,
})
}
return out, nil
}
// extractor holds the two views of the source it needs: the loader's positions,
// and its own re-parse of each file WITH comments, which is where the prose is.
// One parse per file, cached; both views index the same bytes, so a position in
// one is a byte offset in the other.
type extractor struct {
load *token.FileSet
own *token.FileSet
files map[string]*ast.File
}
func (e *extractor) pkg(p *packages.Package) ([]Op, error) {
var ops []Op
var err error
for _, f := range p.Syntax {
prefixes := groupPrefixes(p.TypesInfo, f)
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok || err != nil {
return err == nil
}
op, found, cerr := e.call(p.TypesInfo, call, prefixes)
if cerr != nil {
err = cerr
return false
}
if !found {
op, found = e.raw(p.TypesInfo, call, prefixes)
}
if found {
ops = append(ops, op)
}
return true
})
}
return ops, err
}
// call reads one zip.Get[In,Out](app, path, handler) or scope.Get(path, handler)
// — or Post/Put/Patch/Delete siblings — into an Op. Anything that is not such a call
// is reported as not found.
func (e *extractor) call(info *types.Info, call *ast.CallExpr, prefixes map[types.Object]string) (Op, bool, error) {
id := calleeIdent(call.Fun)
if id == nil {
return Op{}, false, nil
}
fn, _ := info.Uses[id].(*types.Func)
if fn == nil || fn.Pkg() == nil || fn.Pkg().Path() != ZipPkg {
return Op{}, false, nil
}
method, ok := verbs[fn.Name()]
if !ok {
return Op{}, false, nil
}
sig, _ := fn.Type().(*types.Signature)
var targetExpr ast.Expr
var pathArg ast.Expr
var handlerArg ast.Expr
if sig != nil && sig.Recv() != nil {
// Method call on a receiver, e.g. scope.Post(path, handler, opts...)
if len(call.Args) < 2 {
return Op{}, false, nil
}
fun := ast.Unparen(call.Fun)
switch ef := fun.(type) {
case *ast.IndexExpr:
fun = ast.Unparen(ef.X)
case *ast.IndexListExpr:
fun = ast.Unparen(ef.X)
}
sel, ok := fun.(*ast.SelectorExpr)
if !ok {
return Op{}, false, nil
}
targetExpr = sel.X
pathArg = call.Args[0]
handlerArg = call.Args[1]
} else {
// Package-level function call: zip.Post(app, path, handler, opts...)
if len(call.Args) < 3 {
return Op{}, false, nil
}
targetExpr = call.Args[0]
pathArg = call.Args[1]
handlerArg = call.Args[2]
}
// Resolve In and Out types: from type arguments on call if present,
// or inferred from handler signature.
var inType, outType types.Type
if inst, ok := info.Instances[id]; ok && inst.TypeArgs != nil && inst.TypeArgs.Len() == 2 {
inType = inst.TypeArgs.At(0)
outType = inst.TypeArgs.At(1)
} else if hType := info.TypeOf(handlerArg); hType != nil {
if hSig, ok := hType.Underlying().(*types.Signature); ok && hSig.Params().Len() >= 2 && hSig.Results().Len() >= 1 {
inParam := types.Unalias(hSig.Params().At(1).Type())
if ptr, ok := inParam.(*types.Pointer); ok {
inType = ptr.Elem()
} else {
inType = inParam
}
outRes := types.Unalias(hSig.Results().At(0).Type())
if ptr, ok := outRes.(*types.Pointer); ok {
outType = ptr.Elem()
} else {
outType = outRes
}
}
}
if inType == nil || outType == nil {
return Op{}, false, nil
}
lit := info.Types[pathArg].Value
if lit == nil || lit.Kind() != constant.String {
return Op{}, false, fmt.Errorf("%s: %s route path is not a constant string, so the operation has no identity to document",
e.load.Position(pathArg.Pos()), fn.Name())
}
path := constant.StringVal(lit)
// The op's identity is the WHOLE path — a group's prefix composed with the
// leaf, exactly as zip composes it at registration.
prefix, perr := routerPrefix(info, prefixes, targetExpr)
if perr != nil {
return Op{}, false, fmt.Errorf("%s: %s: %w", e.load.Position(targetExpr.Pos()), fn.Name(), perr)
}
path = joinPath(prefix, path)
doc := e.handlerDoc(info, call, handlerArg)
prose, example, response, err := splitDoc(doc)
if err != nil {
return Op{}, false, fmt.Errorf("%s: %s %s: %w", e.load.Position(call.Pos()), method, path, err)
}
op := Op{
Method: method,
Path: path,
Description: prose,
Example: example,
Response: response,
Fields: map[string]string{},
}
seen := map[*types.Named]bool{}
e.fields(inType, op.Fields, seen)
e.fields(outType, op.Fields, seen)
return op, true, nil
}
// raw reads one group.Raw(method, "/path", handler) — a registration the wire
// keeps UNTYPED — into a prose-only Op.
//
// Some routes cannot become typed ops and it is the wire, not the author, that
// says so: an OIDC redirect, a JWKS document, a SCIM body governed by RFC 7643, a
// multipart form, an SSE stream. They are real operations that a paying caller
// reaches, and until now they had nowhere at all to state what they do, so every
// one of them published an address and silence.
//
// What is lifted is only the DESCRIPTION. There is no In and no Out to walk, so
// no schemas and no field prose — which is exactly right: this pass must never
// let an untyped route look typed. It also registers no route and invents no
// operation; zip.Describe is keyed by "METHOD /path" and renders only where a
// router already carries that address, so the router stays the sole authority on
// what exists and this adds only what it MEANS.
//
// A registration whose path is not a constant is skipped rather than refused,
// unlike the typed case. A typed op with a computed path is a hole in the schema
// surface and must stop the build; an untyped one is prose that cannot be filed,
// and refusing there would make every raw handler in the fleet a build error the
// day this shipped.
func (e *extractor) raw(info *types.Info, call *ast.CallExpr, prefixes map[types.Object]string) (Op, bool) {
sel, ok := ast.Unparen(call.Fun).(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Raw" || len(call.Args) < 3 {
return Op{}, false
}
// Raw NAMES ITS METHOD, so the method is an argument rather than the method
// name — http.MethodGet and "GET" are the same constant and both read here.
mlit := info.Types[call.Args[0]].Value
if mlit == nil || mlit.Kind() != constant.String {
return Op{}, false
}
method := strings.ToUpper(constant.StringVal(mlit))
// The RECEIVER decides: a *zip.App or anything satisfying zip.Router is a
// route table. Matching on the method name alone would claim every Get in
// every package in the fleet.
if !isRouteTable(info.Types[sel.X].Type) {
return Op{}, false
}
lit := info.Types[call.Args[1]].Value
if lit == nil || lit.Kind() != constant.String {
return Op{}, false
}
// A group assigned in this file resolves exactly. A router that ARRIVED as a
// parameter — `func routeFrontDoor(r zip.Router, …)`, the shape a subsystem
// uses to be mounted by its host — cannot: only the caller knows the prefix,
// and that call is usually in another file or another package.
//
// So an unresolvable router is read as mounted at the root, and ONLY for a
// path already written absolute. Getting that wrong costs nothing a reader can
// see: zip.Describe renders only where the router carries the key, so prose
// filed under a path nothing serves renders nowhere — the same silence as not
// lifting it, never a sentence attached to the wrong operation. The upside is
// the whole pre-auth surface of every subsystem written this way, which is
// otherwise the single largest undescribed set in the fleet.
prefix, err := routerPrefix(info, prefixes, sel.X)
if err != nil {
if !strings.HasPrefix(constant.StringVal(lit), "/") {
return Op{}, false
}
prefix = ""
}
doc := e.handlerDoc(info, call, call.Args[2])
prose, example, response, derr := splitDoc(doc)
if derr != nil || strings.TrimSpace(prose) == "" {
return Op{}, false
}
return Op{
Method: method,
Path: joinPath(prefix, constant.StringVal(lit)),
Description: prose,
Example: example,
Response: response,
Fields: map[string]string{},
}, true
}
// isRouteTable reports whether t is a place routes are declared: *zip.App or
// *zip.Group. Those are the only two, which is the point of the router having
// no interface — there is nothing else a host can hand down.
func isRouteTable(t types.Type) bool {
return t != nil && (isZipApp(t) || isZipGroup(t))
}
// groupPrefixes maps each variable that holds a router to the path prefix it was
// created with, so a typed op declared on a group can be filed under the path it
// actually serves. One forward pass is enough: Go requires a variable to be
// declared before it is used, so a group built from another group is always
// assigned after the one it derives from.
func groupPrefixes(info *types.Info, f *ast.File) map[types.Object]string {
out := map[types.Object]string{}
bind := func(name ast.Expr, value ast.Expr) {
id, ok := name.(*ast.Ident)
if !ok {
return
}
obj := info.Defs[id]
if obj == nil {
obj = info.Uses[id]
}
if obj == nil {
return
}
if p, ok := groupCallPrefix(info, out, value); ok {
out[obj] = p
}
}
// `g := r.Group(...)` and `var g = r.Group(...)` declare the same variable
// from the same call, so both are read. A group's prefix is a property of
// the call, not of the syntax that names the result.
ast.Inspect(f, func(n ast.Node) bool {
switch n := n.(type) {
case *ast.AssignStmt:
if len(n.Lhs) == 1 && len(n.Rhs) == 1 {
bind(n.Lhs[0], n.Rhs[0])
}
case *ast.ValueSpec:
if len(n.Names) == len(n.Values) {
for i := range n.Names {
bind(n.Names[i], n.Values[i])
}
}
}
return true
})
return out
}
// groupCallPrefix reads `<router>.Group("<literal>")`, `<router>.Scope("<literal>")`,
// or fluent chains like `<router>.Group(...).Use(...).Tag(...)` into the full prefix it
// yields, composing with the receiver's own prefix when the receiver is a group.
func groupCallPrefix(info *types.Info, known map[types.Object]string, e ast.Expr) (string, bool) {
call, ok := ast.Unparen(e).(*ast.CallExpr)
if !ok {
return "", false
}
sel, ok := ast.Unparen(call.Fun).(*ast.SelectorExpr)
if !ok {
return "", false
}
// Unwrap fluent chaining wrappers:
// A group's metadata and gating methods all answer the same group, so a
// prefix reads THROUGH them: users.Group("/mint").Use(gate).Tag("mint") is
// still /mint. OAuth and Undeclared return a group at the same prefix too.
switch sel.Sel.Name {
case "Use", "With", "Tag", "Defaults", "OAuth", "Undeclared":
return groupCallPrefix(info, known, sel.X)
}
switch sel.Sel.Name {
case "Group":
if len(call.Args) >= 1 {
lit := info.Types[call.Args[0]].Value
if lit != nil && lit.Kind() == constant.String {
outer := ""
if id, ok := ast.Unparen(sel.X).(*ast.Ident); ok {
if obj := info.Uses[id]; obj != nil {
outer = known[obj]
}
} else if p, ok := groupCallPrefix(info, known, sel.X); ok {
outer = p
}
return joinPath(outer, constant.StringVal(lit)), true
}
}
// If Scope() was called without arguments, it inherits the receiver's prefix
if sel.Sel.Name == "Scope" && len(call.Args) == 0 {
outer := ""
if id, ok := ast.Unparen(sel.X).(*ast.Ident); ok {
if obj := info.Uses[id]; obj != nil {
outer = known[obj]
}
} else if p, ok := groupCallPrefix(info, known, sel.X); ok {
outer = p
}
return outer, true
}
}
return "", false
}
// routerPrefix is the prefix an op registered on this router sits under. An *App
// is the root and has none; a group must be one this pass RESOLVED, because a
// prefix it cannot see is prose filed under the wrong identity — which is
// exactly the silent drop this resolution exists to end. So an unresolvable
// router is an error naming the call, never an assumed empty prefix.
func routerPrefix(info *types.Info, prefixes map[types.Object]string, arg ast.Expr) (string, error) {
if isZipApp(info.Types[arg].Type) {
return "", nil
}
if id, ok := ast.Unparen(arg).(*ast.Ident); ok {
if obj := info.Uses[id]; obj != nil {
if p, ok := prefixes[obj]; ok {
return p, nil
}
}
}
if p, ok := groupCallPrefix(info, prefixes, arg); ok {
return p, nil
}
return "", fmt.Errorf("cannot resolve the path prefix of the router this op registers on, so its doc comment " +
"would be filed under the wrong path and silently dropped from the document and the MCP tool. " +
"Register on the *zip.App, or on a group assigned in this file as `g := <router>.Group(\"/prefix\")` — " +
"a group reached through a parameter cannot be resolved, because the prefix belongs to the call that made " +
"it and this pass reads one file")
}
// isZipApp reports whether t is *zip.App — the root router, which has no prefix.
func isZipApp(t types.Type) bool {
ptr, ok := types.Unalias(t).(*types.Pointer)
if !ok {
return false
}
named, ok := types.Unalias(ptr.Elem()).(*types.Named)
if !ok || named.Obj().Pkg() == nil {
return false
}
return named.Obj().Pkg().Path() == ZipPkg && named.Obj().Name() == "App"
}
// isZipGroup reports whether t is *zip.Group, however it is named.
func isZipGroup(t types.Type) bool {
if t == nil {
return false
}
ptr, ok := types.Unalias(t).(*types.Pointer)
if !ok {
return false
}
named, ok := types.Unalias(ptr.Elem()).(*types.Named)
if !ok || named.Obj().Pkg() == nil {
return false
}
return named.Obj().Pkg().Path() == ZipPkg && named.Obj().Name() == "Group"
}
// joinPath composes a prefix with a leaf the way the router does, so the key this
// pass writes is the path zip registered.
// joinPath is [addr.Join] — the router's own rule, so a route this pass files
// prose under is the address the router serves.
func joinPath(prefix, path string) string { return addr.Join(prefix, path) }
// calleeIdent is the identifier being called, past any parens and any explicit
// type arguments: zip.Get, zip.Get[A,B], Get and Get[A,B] all yield the Get.
func calleeIdent(fun ast.Expr) *ast.Ident {
fun = ast.Unparen(fun)
switch e := fun.(type) {
case *ast.IndexExpr:
fun = ast.Unparen(e.X)
case *ast.IndexListExpr:
fun = ast.Unparen(e.X)
}
switch e := fun.(type) {
case *ast.Ident:
return e
case *ast.SelectorExpr:
return e.Sel
}
return nil
}
// handlerDoc is the comment that documents the operation. A handler reaches a
// registration in exactly four shapes, and each has one place its prose can be
// written:
//
// zip.Get(app, p, ListUsers) // named → the function's doc comment
// zip.Get(app, p, listUsers(db)) // built → the BUILDER's doc comment
// zip.Get(app, p, wrap(ListUsers))// wrapped → the WRAPPED handler's, not wrap's
// zip.Get(app, p, func(…) {…}) // inline → the comment above the call
//
// The middle shape is the one a service reaches for the moment a handler needs a
// dependency: a function that closes over db and returns the handler. The value
// it returns is a closure with no declaration of its own, so the builder is not
// merely where the prose is CONVENIENT — it is the only declaration there is.
// Reading the callee is therefore the same rule as the named case, applied one
// call deep, and not a second convention: in all three the sentence sits
// immediately above the code that does the work.
//
// Nothing about this was a style preference. Before it, `// listProviders returns
// every provider in the owner scope, newest first.` — already written, already
// reviewed, sitting on the builder — reached no document at all, and the
// operation published an operationId and silence.
func (e *extractor) handlerDoc(info *types.Info, call *ast.CallExpr, arg ast.Expr) string {
switch h := ast.Unparen(arg).(type) {
case *ast.Ident, *ast.SelectorExpr:
return e.funcDoc(info, calleeIdent(h))
case *ast.CallExpr:
// BUILT or WRAPPED, and the arguments say which. A builder takes what the
// handler needs — a db, a service, a limit — and returns the handler. A
// wrapper takes the HANDLER and returns another one, so its own doc
// describes the adapter: true of every route that uses it, and therefore
// about none of them. Measured on one fleet: 55 operations across 24 apps
// published `Binds a Service-scoped handler to a route` as their
// description, and the sentence each handler had already written sat
// unread one call inside.
//
// Taking the last func-typed argument is not a guess about naming — it is
// where a wrapper puts the thing it wraps, because everything it needs to
// do the wrapping has to come first.
if inner := lastFunc(info, h.Args); inner != nil {
return e.handlerDoc(info, call, inner)
}
return e.funcDoc(info, calleeIdent(h.Fun))
case *ast.FuncLit:
// An inline handler names no function, so only the sentence-shape
// evidence can apply — and it must, because the comment above the
// registration is written to the same convention.
//
// The comment is looked for above the REGISTRATION, not above the
// closure. The two are the same line only while the whole call fits on
// one; the moment options push the handler onto its own line — which is
// what any registration carrying WithSummary/WithTags looks like — the
// line above the closure is the `zip.Post(app, path,` line, and every
// sentence written above such a call was silently dropped.
return stripSelf(e.commentAbove(call.Pos()), "")
}
return ""
}
// lastFunc is the last of args whose type is a function, or nil when none is.
// That is the handler an adapter wraps; a call with none is a builder, and its
// own doc comment is the operation's.
func lastFunc(info *types.Info, args []ast.Expr) ast.Expr {
for i := len(args) - 1; i >= 0; i-- {
t := info.TypeOf(args[i])
if t == nil {
continue
}
if _, ok := t.Underlying().(*types.Signature); ok {
return args[i]
}
}
return nil
}
// funcDoc is the doc comment of the function id names, with the leading symbol
// stripped: godoc wants "ListUsers returns …" and a consumer reading the help
// for this operation wants "Returns …".
func (e *extractor) funcDoc(info *types.Info, id *ast.Ident) string {
if id == nil {
return ""
}
fn, ok := info.Uses[id].(*types.Func)
if !ok {
return ""
}
d := e.funcDecl(fn.Pos())
if d == nil {
return ""
}
return stripSelf(d.Doc.Text(), fn.Name())
}
// fields records the doc comment of every exported field of t, keyed exactly as
// zip's schema builder looks it up: the type's name, a dot, and the field's JSON
// name. It descends through pointers, slices, arrays and map values because the
// schema builder does, so a nested type's prose lands too.
func (e *extractor) fields(t types.Type, out map[string]string, seen map[*types.Named]bool) {
switch t := t.(type) {
case *types.Pointer:
e.fields(t.Elem(), out, seen)
case *types.Slice:
e.fields(t.Elem(), out, seen)
case *types.Array:
e.fields(t.Elem(), out, seen)
case *types.Map:
e.fields(t.Elem(), out, seen)
case *types.Named:
if seen[t] {
return
}
seen[t] = true
// A union's alternatives are the result types of its OneOf method, and
// they are what its schema shows, so their fields are documented as if
// the op named each one.
for _, alt := range alternatives(t) {
e.fields(alt, out, seen)
}
// zip's answer and request kinds carry what reaches the wire as type
// arguments — the events of an Sse, the documented shape of a
// Verbatim, the messages of a Socket, the alternatives of an Or — and
// their own fields are not a wire shape at all.
if kinds[zipName(t)] {
if args := t.TypeArgs(); args != nil {
for i := 0; i < args.Len(); i++ {
e.fields(args.At(i), out, seen)
}
}
return
}
st, ok := t.Underlying().(*types.Struct)
if !ok {
e.fields(t.Underlying(), out, seen)
return
}
e.structFields(reflectName(t), t.Obj(), st, out)
for i := 0; i < st.NumFields(); i++ {
e.fields(st.Field(i).Type(), out, seen)
}
case *types.Struct:
// An anonymous struct has no name to key on — the schema builder inlines
// it — but its named field types still deserve their prose.
for i := 0; i < t.NumFields(); i++ {
e.fields(t.Field(i).Type(), out, seen)
}
}
}
// kinds are zip's request and answer kinds: their type arguments are the wire
// shapes, and their own fields are not (a Body is bytes on the wire whatever
// it holds in the process).
var kinds = map[string]bool{"Body": true, "Or": true, "Sse": true, "Verbatim": true, "Socket": true, "Event": true}
// zipName is the name of a type declared in zip, or "".
func zipName(t *types.Named) string {
if pkg := t.Obj().Pkg(); pkg != nil && pkg.Path() == ZipPkg {
return t.Obj().Name()
}
return ""
}
// alternatives are the result types of t's OneOf method, which is how a union
// names what it may be. Nil when t declares none.
func alternatives(t *types.Named) []types.Type {
obj, _, _ := types.LookupFieldOrMethod(types.NewPointer(t), true, t.Obj().Pkg(), "OneOf")
fn, ok := obj.(*types.Func)
if !ok {
return nil
}
sig, ok := fn.Type().(*types.Signature)
if !ok || sig.Params().Len() != 0 || sig.Results().Len() < 2 {
return nil
}
out := make([]types.Type, sig.Results().Len())
for i := range out {
out[i] = sig.Results().At(i).Type()
}
return out
}
// structFields pairs the type-checked fields (authoritative for names and tags)
// with the parsed ones (which carry the comments). go/types preserves source
// order, so index i is the same field in both.
//
// A type defined over another (`type legalFiling Filing`) has the fields of
// the one it is defined over, written where that one is declared, so the
// prose is read there and filed under the defined type's own name.
func (e *extractor) structFields(name string, obj *types.TypeName, st *types.Struct, out map[string]string) {
lit := e.structLit(obj)
if lit == nil {
return
}
var flat []*ast.Field
for _, f := range lit.Fields.List {
n := len(f.Names)
if n == 0 {
n = 1 // embedded: one field, named for its type
}
for i := 0; i < n; i++ {
flat = append(flat, f)
}
}
if len(flat) != st.NumFields() {
return
}
for i := 0; i < st.NumFields(); i++ {
f := st.Field(i)
if !f.Exported() {
continue
}
jsonName := jsontag.Name(f.Name(), reflect.StructTag(st.Tag(i)).Get("json"))
if jsonName == "-" {
continue
}
doc := strings.TrimSpace(flat[i].Doc.Text())
if doc == "" {
doc = strings.TrimSpace(flat[i].Comment.Text())
}
if doc != "" {
out[name+"."+jsonName] = doc
}
}
}
// structLit is the struct literal that declares obj's fields: its own, or the
// literal of the type it is defined over, followed through each such step —
// within its package by name, and across an import by the file's import of
// it.
func (e *extractor) structLit(obj *types.TypeName) *ast.StructType {
for range 8 {
spec := e.typeSpec(obj.Pos(), obj.Name())
if spec == nil || obj.Pkg() == nil {
return nil
}
var next types.Object
switch x := ast.Unparen(spec.Type).(type) {
case *ast.StructType:
return x
case *ast.Ident:
next = obj.Pkg().Scope().Lookup(x.Name)
case *ast.SelectorExpr:
id, ok := x.X.(*ast.Ident)
if !ok {
return nil
}
if imp := e.imported(obj, id.Name); imp != nil {
next = imp.Scope().Lookup(x.Sel.Name)
}
}
named, ok := next.(*types.TypeName)
if !ok {
return nil
}
obj = named
}
return nil
}
// imported is the package a file refers to as name, from the file declaring
// obj: an import that names itself so, else the import whose package is so
// named.
func (e *extractor) imported(obj *types.TypeName, name string) *types.Package {
f, _ := e.file(obj.Pos())
if f == nil {
return nil
}
path := ""
for _, spec := range f.Imports {
if spec.Name != nil && spec.Name.Name == name {
path = strings.Trim(spec.Path.Value, `"`)
}
}
for _, imp := range obj.Pkg().Imports() {
if path != "" && imp.Path() == path || path == "" && imp.Name() == name {
return imp
}
}
return nil
}
// reflectName is the type's name as reflect.Type.Name reports it — which is what
// the schema builder keys on at run time. For an instantiated generic that
// includes the type arguments, qualified by package path.
func reflectName(n *types.Named) string {
name := n.Obj().Name()
args := n.TypeArgs()
if args == nil || args.Len() == 0 {
return name
}
parts := make([]string, args.Len())
for i := range parts {
parts[i] = types.TypeString(args.At(i), func(p *types.Package) string { return p.Path() })
}
return name + "[" + strings.Join(parts, ",") + "]"
}
// file is the comment-bearing parse of the file holding pos, with pos as a LINE
// in it.
//
// The line, not the byte offset, is what identifies a declaration across the two
// views. A position in a package this pass loaded from source has both; a position
// in an IMPORTED package comes from export data, whose positions carry the real
// filename and line and a synthetic offset (the importer fabricates a file whose
// every line is one byte long). Matching on the offset therefore matched nothing
// the moment a type lived in another package — which is every op whose In and Out
// live in the call plane. Those ops had a description and ZERO field
// descriptions: every field-level projection of the plane was nameless.
func (e *extractor) file(pos token.Pos) (*ast.File, int) {
p := e.load.Position(pos)
if p.Filename == "" {
return nil, 0
}
f, ok := e.files[p.Filename]
if !ok {
f, _ = parser.ParseFile(e.own, p.Filename, nil, parser.ParseComments|parser.SkipObjectResolution)
e.files[p.Filename] = f
}
return f, p.Line
}
func (e *extractor) line(pos token.Pos) int { return e.own.Position(pos).Line }
// funcDecl is the declaration of the function whose name is at pos — in this
// package or any other, since a handler is as often a package away.
func (e *extractor) funcDecl(pos token.Pos) *ast.FuncDecl {
f, line := e.file(pos)
if f == nil {
return nil
}
for _, d := range f.Decls {
if fd, ok := d.(*ast.FuncDecl); ok && e.line(fd.Name.Pos()) == line {
return fd
}
}
return nil
}
// typeSpec is the declaration of the type named `name` at pos. The name is
// checked as well as the line because `type ( A struct{}; B struct{} )` puts two
// declarations on one line, and prose filed under the wrong type is worse than
// none.
func (e *extractor) typeSpec(pos token.Pos, name string) *ast.TypeSpec {
f, line := e.file(pos)
if f == nil {
return nil
}
for _, d := range f.Decls {
gd, ok := d.(*ast.GenDecl)
if !ok || gd.Tok != token.TYPE {
continue
}
for _, s := range gd.Specs {
if ts, ok := s.(*ast.TypeSpec); ok && ts.Name.Name == name && e.line(ts.Name.Pos()) == line {
return ts
}
}
}
return nil
}
// commentAbove is the comment block immediately above the line at pos.
func (e *extractor) commentAbove(pos token.Pos) string {
f, _ := e.file(pos)
if f == nil {
return ""
}
want := e.load.Position(pos).Line - 1
for _, g := range f.Comments {
if e.own.Position(g.End()).Line == want {
return g.Text()
}
}
return ""
}
// stripSelf drops the leading identifier Go's doc convention requires — "RevokeKey
// revokes …" — when the comment is projected OUT of Go's namespace, where the
// function name is noise: an OpenAPI description, its derived summary, an MCP tool
// description, a CLI help line. The source stays idiomatic Go; the document reads
// as prose. One transform, here, because every projection downstream inherits
// this text.
//
// TWO EVIDENCES, one law, strongest first.
//
// The handler's own NAME is exact and needs no judgment. It is also, measured
// across 829 lifted comments in one fleet, the minority case: 449 of them opened
// with a symbol that is not the handler's identifier at all — `deleteLB` carrying
// "DeleteLoadBalancer removes one of the caller org's load balancers", `list`
// carrying "ListAgents returns every agent". The convention is being followed
// against a CONCEPTUAL name, so an exact-match rule leaves the majority of the
// leak in place, and every one of those sentences reached an SDK docstring, an
// MCP tool and a CLI help line opening with a Go symbol the reader cannot see,
// cannot call, and already has in operationId.
//
// The name in another CASE is the same evidence. An unexported handler documented
// the way its exported spelling would be — `list` carrying "List returns the
// caller org's live bot runs", `stop` carrying "Stop terminates one run" — names
// itself as exactly as "list returns" would; Go's convention asks for the
// identifier and the author capitalised it because it opens a sentence. Measured
// across one fleet, 136 lifted comments open this way, and a one-hump word is the
// one shape the rule below cannot read as a symbol ("List" is also a verb). It
// takes the same verb guard as the shape rule, so "Health is a liveness probe"
// keeps its subject.
//
// So failing an exact match, Go's own SENTENCE SHAPE is the evidence: a strict
// CamelCase word followed by a lowercase verb, which is the form the convention
// itself prescribes. Both halves are deliberately narrow, because a wrong strip
// damages a sentence somebody wrote while a missed one leaves it as it was:
//
// - strict CamelCase means two or more humps, each an initial capital followed
// by lowercase (ListAgents, D1DatabaseList). A run of capitals is not a hump,
// so GPUs, OpenAPI and HTTPHandler stay the ordinary words they are.
// - the verb is a plain lowercase word and never a copula. "ListAgents returns
// every agent" is a symbol and its verb; "CompleteDeployment is the CI
// completion hook" is a sentence ABOUT a name, and dropping it would leave
// "Is the CI completion hook".
//
// Anything else is left exactly as written.
func stripSelf(text, name string) string {
if rest, ok := strings.CutPrefix(text, name+" "); ok && name != "" {
return recapitalize(rest, text)
}
word, rest, spaced := strings.Cut(text, " ")
if !spaced || !(camelCase(word) || name != "" && strings.EqualFold(word, name)) {
return text
}
verb, _, _ := strings.Cut(rest, " ")
if !lowerWord(verb) || copula[verb] {
return text
}
return recapitalize(rest, text)
}
// copula is the verb that makes the leading word the sentence's SUBJECT rather
// than the symbol being documented.
var copula = map[string]bool{"is": true, "are": true, "was": true, "were": true}
// recapitalize starts rest with a capital, or answers whole when it cannot.
func recapitalize(rest, whole string) string {
r, size := utf8.DecodeRuneInString(rest)
if r == utf8.RuneError {
return whole
}
return string(unicode.ToUpper(r)) + rest[size:]
}
// camelCase reports whether w is strict CamelCase: two or more humps, each an
// initial capital followed by at least one lowercase letter or digit. ASCII only,
// which is the whole of what a Go identifier can be.
func camelCase(w string) bool {
humps := 0
for i := 0; i < len(w); humps++ {
if w[i] < 'A' || w[i] > 'Z' {
return false
}
tail := i + 1
for tail < len(w) && (w[tail] >= 'a' && w[tail] <= 'z' || w[tail] >= '0' && w[tail] <= '9') {
tail++
}
if tail == i+1 {
return false // a bare capital, or a run of them: not a hump
}
i = tail
}
return humps >= 2
}
// lowerWord reports whether w is a plain lowercase word — no punctuation, so a
// verb trailed by a comma or a parenthesis is not one and nothing is stripped.
func lowerWord(w string) bool {
if w == "" {
return false
}
for i := 0; i < len(w); i++ {
if w[i] < 'a' || w[i] > 'z' {
return false
}
}
return true
}
// splitDoc separates the prose from the Example:/Response: bodies.
//
// The bodies are part of the comment for the same reason the prose is: one
// place. A body may run onto the following lines — it is read until it parses —
// and is compacted, so the generated file is stable whatever the source
// formatting. Malformed JSON is an error, never a spec that ships broken.
func splitDoc(text string) (prose, example, response string, err error) {
lines := strings.Split(text, "\n")
var keep []string
for i := 0; i < len(lines); i++ {
key, rest, ok := marker(lines[i])
if !ok {
keep = append(keep, lines[i])
continue
}
body := rest
for j := i + 1; j < len(lines) && !json.Valid([]byte(body)); j++ {
if _, _, isMarker := marker(lines[j]); isMarker {
break
}
body += "\n" + lines[j]
i = j
}
compact, cerr := compactJSON(body)
if cerr != nil {
return "", "", "", fmt.Errorf("%s: %w", key, cerr)
}
if key == "Example" {
example = compact
} else {
response = compact
}
}
return strings.TrimSpace(strings.Join(keep, "\n")), example, response, nil
}
// marker recognises an "Example:" or "Response:" line and returns what follows.
func marker(line string) (key, rest string, ok bool) {
for _, k := range [...]string{"Example", "Response"} {
if r, found := strings.CutPrefix(strings.TrimSpace(line), k+":"); found {
return k, strings.TrimSpace(r), true
}
}
return "", "", false
}
// compactJSON validates and normalises one body. Compaction (rather than a
// decode/re-encode round trip) keeps the author's key order, and rejects
// trailing garbage as loudly as it rejects a missing brace.
func compactJSON(s string) (string, error) {
s = strings.TrimSpace(s)
if s == "" {
return "", fmt.Errorf("no JSON body")
}
var buf bytes.Buffer
if err := json.Compact(&buf, []byte(s)); err != nil {
return "", fmt.Errorf("invalid JSON: %w", err)
}
return buf.String(), nil
}