mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
The handler position takes `f(x)` in two unrelated shapes and handlerDoc read both as a builder — a function returning the handler, whose own doc comment is the only one there is. An ADAPTER has the same shape and the opposite meaning: its argument IS the handler, and its doc describes the adapter. So every operation registered through one published the adapter's sentence. Measured on hanzoai/cloud: 55 operations across 24 apps described `cloud.Handle` or `cloud.Terminal` instead of themselves — "Binds a Service-scoped handler to a route", true of every route that uses it and about none of them — while the sentence each handler had already written sat unread one call inside. The arguments tell the two apart, and nothing else has to: a builder takes what the handler needs, a wrapper takes the handler. Recursing on the last func-typed argument reaches through nested adapters and leaves the builder case exactly as it was.
934 lines
32 KiB
Go
934 lines
32 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"
|
|
"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.
|
|
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)
|
|
} else {
|
|
ops = append(ops, e.alias(p.TypesInfo, call, prefixes)...)
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
return ops, err
|
|
}
|
|
|
|
// call reads one zip.Get[In,Out](app, path, handler) — or Post/Put/Patch/Delete
|
|
// — 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 || len(call.Args) < 3 {
|
|
return Op{}, false, nil
|
|
}
|
|
// The instantiation, not the syntax: zip.Post(app, p, validate) infers In and
|
|
// Out from the handler and writes no type arguments at all, and a registration
|
|
// this pass cannot see is a hole in the spec.
|
|
args := info.Instances[id].TypeArgs
|
|
if args == nil || args.Len() != 2 {
|
|
return Op{}, false, nil
|
|
}
|
|
|
|
lit := info.Types[call.Args[1]].Value
|
|
if lit == nil || lit.Kind() != constant.String {
|
|
return Op{}, false, fmt.Errorf("%s: zip.%s route path is not a constant string, so the operation has no identity to document",
|
|
e.load.Position(call.Args[1].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. Filing prose under the
|
|
// leaf alone is how a doc comment on a group-declared op vanished from both
|
|
// the document and the MCP tool list: docFor looks up the composed path and
|
|
// never matched.
|
|
prefix, perr := routerPrefix(info, prefixes, call.Args[0])
|
|
if perr != nil {
|
|
return Op{}, false, fmt.Errorf("%s: zip.%s: %w", e.load.Position(call.Args[0].Pos()), fn.Name(), perr)
|
|
}
|
|
path = joinPath(prefix, path)
|
|
|
|
doc := e.handlerDoc(info, call, call.Args[2])
|
|
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(args.At(0), op.Fields, seen)
|
|
e.fields(args.At(1), op.Fields, seen)
|
|
return op, true, nil
|
|
}
|
|
|
|
// raw reads one router.Get("/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 || len(call.Args) < 2 {
|
|
return Op{}, false
|
|
}
|
|
method, ok := verbs[sel.Sel.Name]
|
|
if !ok {
|
|
return Op{}, false
|
|
}
|
|
// 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 !isZipRouter(info.Types[sel.X].Type) {
|
|
return Op{}, false
|
|
}
|
|
lit := info.Types[call.Args[0]].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[1])
|
|
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
|
|
}
|
|
|
|
// alias reads one zip.Alias(router.Get, canonical, legacy, handler) into TWO
|
|
// prose-only Ops carrying the same sentence.
|
|
//
|
|
// Both addresses serve one handler, so both mean the same thing and both must say
|
|
// so — a consumer that reads the document is as likely to arrive at the legacy
|
|
// spelling as the canonical one, and finding silence there is exactly the defect
|
|
// a legacy alias exists to avoid.
|
|
//
|
|
// A registration made inside a helper is invisible to a pass that reads
|
|
// router.Get(path, handler) calls, which is why zip owns Alias: this matcher can
|
|
// only recognise a function whose identity it knows.
|
|
func (e *extractor) alias(info *types.Info, call *ast.CallExpr, prefixes map[types.Object]string) []Op {
|
|
id := calleeIdent(call.Fun)
|
|
if id == nil || len(call.Args) < 4 {
|
|
return nil
|
|
}
|
|
fn, _ := info.Uses[id].(*types.Func)
|
|
if fn == nil || fn.Pkg() == nil || fn.Pkg().Path() != ZipPkg || fn.Name() != "Alias" {
|
|
return nil
|
|
}
|
|
// The registrar is a METHOD VALUE — `r.Get` — so it names both the verb and
|
|
// the router the paths hang off.
|
|
sel, ok := ast.Unparen(call.Args[0]).(*ast.SelectorExpr)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
method, ok := verbs[sel.Sel.Name]
|
|
if !ok || !isZipRouter(info.Types[sel.X].Type) {
|
|
return nil
|
|
}
|
|
prefix, err := routerPrefix(info, prefixes, sel.X)
|
|
if err != nil {
|
|
prefix = ""
|
|
}
|
|
doc := e.handlerDoc(info, call, call.Args[3])
|
|
prose, example, response, derr := splitDoc(doc)
|
|
if derr != nil || strings.TrimSpace(prose) == "" {
|
|
return nil
|
|
}
|
|
var out []Op
|
|
for _, arg := range call.Args[1:3] {
|
|
lit := info.Types[arg].Value
|
|
if lit == nil || lit.Kind() != constant.String {
|
|
continue
|
|
}
|
|
p := constant.StringVal(lit)
|
|
if prefix == "" && !strings.HasPrefix(p, "/") {
|
|
continue
|
|
}
|
|
out = append(out, Op{
|
|
Method: method,
|
|
Path: joinPath(prefix, p),
|
|
Description: prose,
|
|
Example: example,
|
|
Response: response,
|
|
Fields: map[string]string{},
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// isZipRouter reports whether t is a route table this pass may read: *zip.App,
|
|
// or any type zip.Router names (a group, an app, whatever a host hands down).
|
|
func isZipRouter(t types.Type) bool {
|
|
if t == nil {
|
|
return false
|
|
}
|
|
if isZipApp(t) {
|
|
return true
|
|
}
|
|
named, ok := t.(*types.Named)
|
|
if !ok || named.Obj().Pkg() == nil {
|
|
return false
|
|
}
|
|
return named.Obj().Pkg().Path() == ZipPkg && named.Obj().Name() == "Router"
|
|
}
|
|
|
|
// 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{}
|
|
ast.Inspect(f, func(n ast.Node) bool {
|
|
as, ok := n.(*ast.AssignStmt)
|
|
if !ok || len(as.Lhs) != 1 || len(as.Rhs) != 1 {
|
|
return true
|
|
}
|
|
id, ok := as.Lhs[0].(*ast.Ident)
|
|
if !ok {
|
|
return true
|
|
}
|
|
obj := info.Defs[id]
|
|
if obj == nil {
|
|
obj = info.Uses[id]
|
|
}
|
|
if obj == nil {
|
|
return true
|
|
}
|
|
if p, ok := groupCallPrefix(info, out, as.Rhs[0]); ok {
|
|
out[obj] = p
|
|
}
|
|
return true
|
|
})
|
|
return out
|
|
}
|
|
|
|
// groupCallPrefix reads `<router>.Group("<literal>")` 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 || len(call.Args) < 1 {
|
|
return "", false
|
|
}
|
|
sel, ok := ast.Unparen(call.Fun).(*ast.SelectorExpr)
|
|
if !ok || sel.Sel.Name != "Group" {
|
|
return "", false
|
|
}
|
|
lit := info.Types[call.Args[0]].Value
|
|
if lit == nil || lit.Kind() != constant.String {
|
|
return "", false
|
|
}
|
|
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
|
|
}
|
|
|
|
// 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\")`")
|
|
}
|
|
|
|
// isZipApp reports whether t is *zip.App — the root router, which has no prefix.
|
|
func isZipApp(t types.Type) bool {
|
|
ptr, ok := t.(*types.Pointer)
|
|
if !ok {
|
|
return false
|
|
}
|
|
named, ok := ptr.Elem().(*types.Named)
|
|
if !ok || named.Obj().Pkg() == nil {
|
|
return false
|
|
}
|
|
return named.Obj().Pkg().Path() == ZipPkg && named.Obj().Name() == "App"
|
|
}
|
|
|
|
// joinPath composes a prefix with a leaf the way the router does, so the key this
|
|
// pass writes is the path zip registered.
|
|
func joinPath(prefix, path string) string {
|
|
if path == "" {
|
|
path = "/"
|
|
}
|
|
if prefix == "" {
|
|
return path
|
|
}
|
|
if path[0] != '/' {
|
|
path = "/" + path
|
|
}
|
|
return strings.TrimRight(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
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
func (e *extractor) structFields(name string, obj *types.TypeName, st *types.Struct, out map[string]string) {
|
|
spec := e.typeSpec(obj.Pos(), obj.Name())
|
|
if spec == nil {
|
|
return
|
|
}
|
|
lit, ok := spec.Type.(*ast.StructType)
|
|
if !ok {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// 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) {
|
|
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
|
|
}
|