Files
zeekayandHanzo Dev 170e572463 cli: the registry is the CLI, and the doc comment is what describes it
One registry, three projections. A typed op already produced a REST route, an
OpenAPI path and an MCP tool; it now also produces a command. cli.go derives
`<service> <operation>` from the op's own identity, so adding a route can never
rename an existing command, and LocalInvoke runs it through the op's own invoke
seam — a fused binary needs no client because the command IS the handler call.
clispec.go builds the same commands from a served spec, which is what lets the
CLI drive a service it was not linked against.

cmd/zipdoc + internal/zipdoc close the gap Go leaves: doc comments do not exist
at run time, so prose reaches the spec only via a build-time AST pass. Field
docs and examples are extracted from source and keyed "<Type>.<field>".

Two defects found by making the projections agree:

commandName treated "ends in a parameter" as the only way to address one thing,
so GET /v1/paas/apps/:app/deploy became apps-deploy-LIST — named for a
collection, returning one deploy. The route already says which it is past its
parameter: /apps/:app/deploy is singular, /nodes/:node/jobs is plural.

OpenAPI parameters carried no description while the registry had one, so the
same argument had help in-process and none from the spec. Both now read the
same key, and query parameters get it too.

internal/jsontag exists so the reflect view of the wire-name rule and the AST
view cannot drift; transport gains a dial-only https, because reaching a
published API is an ordinary Mount and terminating TLS stays the ingress's job.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 17:02:36 -07:00

166 lines
4.4 KiB
Go

package zipdoc
import (
"bytes"
"fmt"
"go/format"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
)
// Header marks the emitted file. It is the standard form `go generate` and every
// Go tool recognises, and the guard that stops this command from ever writing
// over something a person wrote.
const Header = "// Code generated by zipdoc; DO NOT EDIT."
// DefaultFile is the name written into each package's own directory, so the
// prose sits next to the source it came from and moves with it.
const DefaultFile = "zipdoc_gen.go"
// Render is the file for a package's operations, gofmt'd. Nil when the package
// registers nothing typed — there is no such thing as an empty documentation
// file, only one that should not exist.
func (p Package) Render() ([]byte, error) {
if len(p.Ops) == 0 {
return nil, nil
}
ops := append([]Op(nil), p.Ops...)
sort.Slice(ops, func(i, j int) bool { return ops[i].Key() < ops[j].Key() })
// zip documenting itself needs no import and no qualifier.
q := "zip."
if p.Path == ZipPkg {
q = ""
}
raw := false
for _, o := range ops {
raw = raw || o.Example != "" || o.Response != ""
}
var b strings.Builder
fmt.Fprintf(&b, "%s\n\npackage %s\n\n", Header, p.Name)
if raw || q != "" {
b.WriteString("import (\n")
if raw {
b.WriteString("\t\"encoding/json\"\n")
}
if q != "" {
if raw {
b.WriteString("\n")
}
fmt.Fprintf(&b, "\t%q\n", ZipPkg)
}
b.WriteString(")\n\n")
}
b.WriteString("func init() {\n")
for _, o := range ops {
fmt.Fprintf(&b, "\t%sDescribe(%q, %sDoc{\n", q, o.Key(), q)
if o.Description != "" {
fmt.Fprintf(&b, "\t\tDescription: %s,\n", strconv.Quote(o.Description))
}
if len(o.Fields) > 0 {
b.WriteString("\t\tFields: map[string]string{\n")
keys := make([]string, 0, len(o.Fields))
for k := range o.Fields {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(&b, "\t\t\t%q: %s,\n", k, strconv.Quote(o.Fields[k]))
}
b.WriteString("\t\t},\n")
}
if o.Example != "" {
fmt.Fprintf(&b, "\t\tExample: json.RawMessage(%s),\n", goString(o.Example))
}
if o.Response != "" {
fmt.Fprintf(&b, "\t\tResponse: json.RawMessage(%s),\n", goString(o.Response))
}
b.WriteString("\t})\n")
}
b.WriteString("}\n")
src, err := format.Source([]byte(b.String()))
if err != nil {
return nil, fmt.Errorf("generated source for %s does not parse: %w", p.Path, err)
}
return src, nil
}
// goString prefers a raw literal, so JSON reads as JSON, and falls back to a
// quoted one when the content would break out of it.
func goString(s string) string {
if !strings.ContainsAny(s, "`\r") {
return "`" + s + "`"
}
return strconv.Quote(s)
}
// Write puts the rendered file in the package directory, and removes a stale one
// when the package no longer registers anything. Reports whether it changed
// anything.
func (p Package) Write(name string) (bool, error) {
path := filepath.Join(p.Dir, name)
want, err := p.Render()
if err != nil {
return false, err
}
got, generated, err := read(path)
if err != nil {
return false, err
}
if bytes.Equal(got, want) {
return false, nil
}
if got != nil && !generated {
return false, fmt.Errorf("%s exists and was not generated by zipdoc; refusing to overwrite it", path)
}
if want == nil {
return true, os.Remove(path)
}
return true, os.WriteFile(path, want, 0o644)
}
// Stale reports why the file on disk does not match the source, or "" when it
// does. This is what CI runs: the spec is derived, so a stale file is a
// description that has drifted from the code it describes.
func (p Package) Stale(name string) (string, error) {
path := filepath.Join(p.Dir, name)
want, err := p.Render()
if err != nil {
return "", err
}
got, generated, err := read(path)
if err != nil {
return "", err
}
switch {
case bytes.Equal(got, want):
return "", nil
case got == nil:
return path + ": missing", nil
case !generated:
return path + ": not generated by zipdoc", nil
case want == nil:
return path + ": stale (the package no longer registers typed handlers)", nil
default:
return path + ": stale", nil
}
}
// read returns the file's bytes (nil if absent) and whether it carries the
// generated header.
func read(path string) ([]byte, bool, error) {
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return b, bytes.HasPrefix(b, []byte(Header)), nil
}