Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,8 @@ go.work.sum
.mcp.json
.worktrees
go.work.sum
internal/exportdata/exportdata.zip

# stray binaries from "go build ./hack/..."
/genexportdata
/genspec
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,23 @@ genspec-tui -workdir [my source location]

![tui_screenshot](docs/genspec-tui.png)

## Generate a spec from the command line

`genspec` is the headless counterpart: it writes the specification to standard output and takes no
dependency beyond the library, so it also cross-compiles to WebAssembly and runs under a WASI
runtime with no Go toolchain installed.

```cmd
go install github.com/go-openapi/codescan/cmd/genspec@latest
```

```cmd
genspec -workdir [my source location] ./...
```

See [cmd/genspec/README.md](cmd/genspec/README.md) for the WASI build, what a guest needs mounted,
and how to ship the standard library's types inside the artifact.

## Change log

See <https://github.com/go-openapi/codescan/releases>
Expand Down
1 change: 1 addition & 0 deletions cmd/genspec-tui/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ require (
github.com/go-openapi/swag/typeutils v0.28.0 // indirect
github.com/go-openapi/swag/yamlutils v0.28.0 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
Expand Down
5 changes: 3 additions & 2 deletions cmd/genspec-tui/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/go-openapi/codescan v0.36.2 h1:cZ581ShkoGn6ixJU67E5yZtEiHLQ3ZHo2r494byWBiU=
github.com/go-openapi/codescan v0.36.2/go.mod h1:n0z3IJDm3ysnon+bLfBbHoy0WrRzlBE0LfeuAxGEnuM=
github.com/go-openapi/core/json v0.0.3 h1:L4YuBIsLVtn5x52u27z+xRdJQkD0BaverQFNvxl7h6E=
github.com/go-openapi/core/json v0.0.3/go.mod h1:nQl4bCBPXPOLlpjfSUcT2hcRKUBVuz+xLHx92aH524w=
github.com/go-openapi/core/json/lexers/yaml-lexer v0.0.3 h1:SJ8eF4ebyJiiBMTcqWYve8mrFmwqxcyhJVuIJtefUq4=
Expand Down Expand Up @@ -62,8 +64,7 @@ github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAg
github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
Expand Down
1 change: 1 addition & 0 deletions cmd/genspec-tui/internal/ux/model_options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
var optionsDeliberatelyOmitted = map[string]string{ //nolint:gochecknoglobals // table for the drift guard
"DescWithRef": "deprecated in favour of EmitRefSiblings",
"Debug": "deprecated no-op; the stderr logger was retired",
"StubStdlib": "applies only to the virtual-filesystem loader; the TUI scans the real tree",
}

func newOptionsModel(t *testing.T) *Model {
Expand Down
6 changes: 6 additions & 0 deletions cmd/genspec-tui/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ type cliFlags struct {
packages *string
scanModels *bool
buildTags *string
goos *string
goarch *string
include *string
exclude *string
includeTags *string
Expand All @@ -57,6 +59,8 @@ func registerFlags(fs *flag.FlagSet) *cliFlags {
packages: fs.String("packages", "./...", "comma-separated package patterns to scan, relative to -workdir"),
scanModels: fs.Bool("scan-models", true, "also emit definitions for swagger:model types"),
buildTags: fs.String("build-tags", "", "comma-separated go build tags to apply while loading"),
goos: fs.String("goos", "", "GOOS the scanned code is built for (default: this machine's)"),
goarch: fs.String("goarch", "", "GOARCH the scanned code is built for (default: this machine's)"),
include: fs.String("include", "", "comma-separated patterns; only matching packages are scanned"),
exclude: fs.String("exclude", "", "comma-separated patterns; matching packages are skipped"),
includeTags: fs.String("include-tags", "",
Expand All @@ -77,6 +81,8 @@ func (c *cliFlags) options(workDir string) codescan.Options {
Packages: splitPatterns(*c.packages),
ScanModels: *c.scanModels,
BuildTags: *c.buildTags,
GOOS: *c.goos,
GOARCH: *c.goarch,
Include: splitList(*c.include),
Exclude: splitList(*c.exclude),
IncludeTags: splitList(*c.includeTags),
Expand Down
4 changes: 4 additions & 0 deletions cmd/genspec-tui/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ var optionFlags = map[string]string{ //nolint:gochecknoglobals // table for the
"WorkDir": "workdir",
"Packages": "packages",
"BuildTags": "build-tags",
"GOOS": "goos",
"GOARCH": "goarch",
"Include": "include",
"Exclude": "exclude",
"IncludeTags": "include-tags",
Expand All @@ -35,6 +37,8 @@ var optionsNotOnCLI = map[string]string{ //nolint:gochecknoglobals // table for
"InputSpec": "overlay mode: needs a spec loaded from disk, not yet exposed",
"OnDiagnostic": "wired internally to the diagnostics pane",
"OnProvenance": "wired internally to the cross-ref linker",
"FS": "virtual source filesystem: a programmatic seam, not expressible on a command line",
"ExportData": "applies only to the virtual-filesystem loader; the TUI scans the real tree with the go command",
}

func newTestFlags(t *testing.T) *cliFlags {
Expand Down
181 changes: 181 additions & 0 deletions cmd/genspec/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<!--
SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
SPDX-License-Identifier: Apache-2.0
-->

# genspec

A headless spec generator: point it at annotated Go source, get a Swagger 2.0
document on standard output. It is the non-interactive counterpart to
[`genspec-tui`](../genspec-tui/README.md), and the form codescan takes when it
is built for WebAssembly.

It depends on nothing beyond the library itself, which is the point: it
cross-compiles to `wasip1/wasm` and runs under any WASI runtime, with no Go
toolchain present and no subprocess.

Audience: codescan/go-swagger maintainers and contributors.

## Install and run

`genspec` lives in the **main module**, so it carries no dependencies the
library does not already have.

```sh
go install github.com/go-openapi/codescan/cmd/genspec@latest

# scan the module in the current directory
genspec

# or point it somewhere, and narrow the scope
genspec -workdir ../my-api ./internal/models/... ./internal/api/...
```

From a checkout:

```sh
go run ./cmd/genspec -workdir ./fixtures ./goparsing/petstore/...
```

| Flag | Default | Meaning |
|---|---|---|
| `-workdir` | `.` | directory the scan runs from; patterns are relative to it |
| `-scan-models` | `true` | also emit definitions for `swagger:model` types |
| *(one flag per boolean option)* | | every boolean knob on `codescan.Options` has a flag, named after the field in kebab-case — `-prune-unused-models`, `-ref-aliases`, `-clean-go-doc` … Run `genspec -h` for the list. |
| `-build-tags` | | comma-separated build tags to apply while loading |
| `-goos` / `-goarch` | this machine's | the platform the **scanned code** is built for |
| `-loader` | `auto` | `go` runs `go list`; `own` needs no toolchain; `auto` picks `own` wherever the build cannot exec |
| `-export-data` | | directory or `.zip` of precomputed dependency types (see below) |
| `-stub-stdlib` | `false` | synthesize standard-library types instead of reading GOROOT |
| `-output` | `-` | where to write the specification |
| `-indent` | `true` | indent the emitted JSON |
| `-quiet` | `false` | suppress scan diagnostics on standard error |

`-loader=auto` is why the same source builds for both worlds: WebAssembly has
no process model, so `go list` can never run there and the choice makes itself.

## Running it under a WASI runtime

Build the artifact, then hand it to a runtime along with the directories it is
allowed to read:

```sh
GOOS=wasip1 GOARCH=wasm go build -o genspec.wasm ./cmd/genspec
```

Verified against **wasmtime 41** and **wazero 1.11**. Their mount syntax
differs, which is the first thing to get wrong:

```sh
# wasmtime — <host>::<guest>
wasmtime run --dir "$PWD::$PWD" genspec.wasm -workdir "$PWD" ./...

# wazero — <host>:<guest>, no separator
wazero run -mount="$PWD:$PWD" genspec.wasm -workdir "$PWD" ./...
```

Two things a guest cannot work out for itself:

- **`-goos` / `-goarch` must be passed explicitly.** Left alone they default to
the platform the scanner is *running* on, which inside a guest is `wasip1`.
That silently drops every `_linux.go` file and produces a different
specification than the same scan run natively. Pass the platform of the code
under scan.
- **GOROOT and the module cache are found by path.** Nothing in a WASI
environment can ask the go command where they live, so if the scan needs
them they have to be mounted *and* named through the environment
(`--env GOROOT=…`, `--env GOMODCACHE=…`).

wazero is a pure-Go runtime and convenient to embed in tests; wasmtime is
several times faster on this workload. Both produce identical output.

## How much of the host to expose

The real choice is what the guest is allowed to see. Measured on the petstore
fixture under wasmtime:

| mounted | mode | time | peak RSS | result |
|---|---|---|---|---|
| GOROOT + module cache | default | 7.3 s | 681 MB | identical to a `go list` scan |
| module cache | `-export-data` | 1.0 s | 138 MB | identical |
| module cache | `-stub-stdlib` | 1.0 s | 147 MB | degraded — see below |
| project tree only | `-stub-stdlib` | 0.1 s | 123 MB | degraded |

Memory is usually the binding constraint rather than time: 681 MB for a fixture
this small is more than a browser tab can host.

### Precomputed dependency types

`-export-data` reads a scan's **dependencies** from the export data the
compiler already produced, instead of parsing and type-checking them. That is
where nearly all the time goes, so it costs a fraction — with no loss of
fidelity, because the types are the compiler's own. The module being scanned
is always read from source: its comments are the annotations.

It takes a directory or a `.zip`, so a host with somewhere to put a file but
no tree to build hands over one blob.

```sh
go run ./hack/genexportdata -out /tmp/exportdata std

wasmtime run --dir "$PWD::$PWD" --dir /tmp/exportdata::/tmp/exportdata \
genspec.wasm -export-data /tmp/exportdata -workdir "$PWD" ./...
```

The data is valid only for the toolchain that generated it, since the export
format is tied to the Go release. Regenerate it when the toolchain moves.

**A package whose meaning lives in comments cannot go in.** `strfmt` declares
its formats with `swagger:strfmt`, and export data holds types, not comments —
such a package comes back structurally intact and semantically empty, with
nothing erroring. `genexportdata` detects and skips them, saying which; they
have to be read from source.

### A build that needs nothing mounted but the project

The `exportdata` tag embeds that same data in the binary, so the artifact is
self-contained:

```sh
go run ./hack/genexportdata -out internal/exportdata/exportdata.zip std
GOOS=wasip1 GOARCH=wasm go build -tags exportdata -o genspec.wasm ./cmd/genspec

# no GOROOT, no toolchain, nothing but the sources being scanned
wasmtime run --dir "$PWD::$PWD" genspec.wasm -workdir "$PWD" ./...
```

That costs about 5 MB of artifact (20 MB, 8.5 MB compressed, against 15 MB) and
runs the petstore in 1.1 s. The archive is generated rather than committed.

### Synthesizing the standard library instead

`-stub-stdlib` fabricates standard-library types from the names the scanned code
selects through them. It needs no GOROOT and no module cache at all, and it is
the smallest footprint on offer — but it is **not failsafe**, and its failure
mode is quiet: the specification comes out slightly thinner rather than
erroring.

Recognition by type identity survives, so `time.Time` is still a `date-time`.
Structure does not: a synthesized type has no fields and no method set, so
`json.RawMessage` stops rendering as a byte array, `time.Duration` as an
integer, and a type is no longer seen to implement `encoding.TextMarshaler`.
Across codescan's fixture corpus 138 of 143 scans stay byte-identical.

Prefer a full graph, or the export data above, wherever GOROOT is available.

Whatever the mode, every import that had to be synthesized raises a
`scan.synthesized-import` diagnostic on standard error naming the import and
where it came from, so the loss is never silent. Drop `-quiet` to see them.

## Tests

The integration tests build the artifact and run it under whichever runtime is
on `PATH`, comparing the result against an in-process scan:

```sh
go test ./internal/integration/ -run TestWASIArtifact -v
```

They skip when no runtime is installed, when there is no go command to build
with, and under `-short`. The self-contained case additionally skips unless
`internal/exportdata/exportdata.zip` has been generated.
9 changes: 9 additions & 0 deletions cmd/genspec/exec_native.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

//go:build !wasm

package main

// canExec reports whether this build can start a subprocess.
func canExec() bool { return true }
12 changes: 12 additions & 0 deletions cmd/genspec/exec_wasm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

//go:build wasm

package main

// canExec reports whether this build can start a subprocess.
//
// WebAssembly has no process model under either wasip1 or js, so `go list` — and therefore
// packages.Load — can never run here.
func canExec() bool { return false }
47 changes: 47 additions & 0 deletions cmd/genspec/exportdata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers
// SPDX-License-Identifier: Apache-2.0

package main

import (
"archive/zip"
"fmt"
"io/fs"
"os"
"strings"
)

// openExportData resolves -export-data, which takes either a directory or a zip.
//
// The zip form exists for hosts that have somewhere to put a file but no directory tree to build: a
// browser drops one fetched blob into the guest filesystem instead of unpacking several hundred
// entries in JavaScript. archive/zip's reader is already an fs.FS, so nothing downstream can tell
// the difference.
func openExportData(path string) (fs.FS, error) {
if !strings.HasSuffix(path, ".zip") {
if info, err := os.Stat(path); err != nil || !info.IsDir() {
return nil, fmt.Errorf("export data %q is neither a directory nor a .zip", path)

Check failure on line 23 in cmd/genspec/exportdata.go

View workflow job for this annotation

GitHub Actions / test / Lint

do not define dynamic errors, use wrapped static errors instead: "fmt.Errorf(\"export data %q is neither a directory nor a .zip\", path)" (err113)
}

return os.DirFS(path), nil
}

f, err := os.Open(path) //nolint:gosec // the path comes from the command line
if err != nil {
return nil, fmt.Errorf("opening export data: %w", err)
}

info, err := f.Stat()
if err != nil {
return nil, fmt.Errorf("opening export data: %w", err)
}

// The reader keeps the file open and reads entries on demand, which is the point: the archive is
// several megabytes and a scan touches a fraction of it.
r, err := zip.NewReader(f, info.Size())
if err != nil {
return nil, fmt.Errorf("reading export data archive: %w", err)
}

return r, nil
}
Loading
Loading