diff --git a/.gitignore b/.gitignore index 4528f287..0c4fedaf 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md index 6485bf54..71bb73aa 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/genspec-tui/go.mod b/cmd/genspec-tui/go.mod index 8d31780b..a785d95f 100644 --- a/cmd/genspec-tui/go.mod +++ b/cmd/genspec-tui/go.mod @@ -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 diff --git a/cmd/genspec-tui/go.sum b/cmd/genspec-tui/go.sum index bc1dd5cb..39e11ce0 100644 --- a/cmd/genspec-tui/go.sum +++ b/cmd/genspec-tui/go.sum @@ -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= @@ -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= diff --git a/cmd/genspec-tui/internal/ux/model_options_test.go b/cmd/genspec-tui/internal/ux/model_options_test.go index 10d85ae6..c47bd0fc 100644 --- a/cmd/genspec-tui/internal/ux/model_options_test.go +++ b/cmd/genspec-tui/internal/ux/model_options_test.go @@ -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 { diff --git a/cmd/genspec-tui/main.go b/cmd/genspec-tui/main.go index a185d19e..d0d082de 100644 --- a/cmd/genspec-tui/main.go +++ b/cmd/genspec-tui/main.go @@ -37,6 +37,8 @@ type cliFlags struct { packages *string scanModels *bool buildTags *string + goos *string + goarch *string include *string exclude *string includeTags *string @@ -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", "", @@ -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), diff --git a/cmd/genspec-tui/main_test.go b/cmd/genspec-tui/main_test.go index d7179cdc..28f255a4 100644 --- a/cmd/genspec-tui/main_test.go +++ b/cmd/genspec-tui/main_test.go @@ -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", @@ -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 { diff --git a/cmd/genspec/README.md b/cmd/genspec/README.md new file mode 100644 index 00000000..5bf5cde5 --- /dev/null +++ b/cmd/genspec/README.md @@ -0,0 +1,181 @@ + + +# 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 — :: +wasmtime run --dir "$PWD::$PWD" genspec.wasm -workdir "$PWD" ./... + +# wazero — :, 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. diff --git a/cmd/genspec/exec_native.go b/cmd/genspec/exec_native.go new file mode 100644 index 00000000..65f14c10 --- /dev/null +++ b/cmd/genspec/exec_native.go @@ -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 } diff --git a/cmd/genspec/exec_wasm.go b/cmd/genspec/exec_wasm.go new file mode 100644 index 00000000..8116448e --- /dev/null +++ b/cmd/genspec/exec_wasm.go @@ -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 } diff --git a/cmd/genspec/exportdata.go b/cmd/genspec/exportdata.go new file mode 100644 index 00000000..43b39adb --- /dev/null +++ b/cmd/genspec/exportdata.go @@ -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) + } + + 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 +} diff --git a/cmd/genspec/main.go b/cmd/genspec/main.go new file mode 100644 index 00000000..b9eb6f18 --- /dev/null +++ b/cmd/genspec/main.go @@ -0,0 +1,225 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Command genspec scans annotated Go source and writes the Swagger specification it describes. +// +// It is a headless counterpart to genspec-tui, and the form codescan takes when it is built for +// WebAssembly: it carries no dependency beyond the library itself, so it cross-compiles to +// wasip1/wasm and runs under any WASI runtime. +// +// go build -o genspec ./cmd/genspec +// GOOS=wasip1 GOARCH=wasm go build -o genspec.wasm ./cmd/genspec +// wazero run -mount /path/to/project:/src genspec.wasm -workdir /src ./... +// +// Under a WASI runtime the scan reads only what the host mounted, and runs no subprocess: use +// -loader=own, which needs neither the go command nor a toolchain. See -h for the full flag set. +// +// # Mounting, and what it costs +// +// A full scan resolves the standard library and the module cache by path, so both have to be mounted +// and named through GOROOT and GOMODCACHE. -stub-stdlib removes the first requirement, and an +// unresolvable import is synthesized rather than fatal, so a scan will complete with nothing but the +// project tree mounted. Measured on codescan's petstore fixture under wasmtime: +// +// everything mounted, full graph 8.1 s 848 MB byte-identical +// module cache only, -stub-stdlib 1.0 s 143 MB byte-identical +// project tree only, -stub-stdlib 0.1 s 123 MB one format lost +// +// The last row is the shape of the trade: it does not fail, it quietly emits slightly less. Prefer a +// full graph wherever GOROOT is available. +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + "strings" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/codescan/internal/exportdata" +) + +func main() { + if err := run(os.Args[1:], os.Stdout, os.Stderr); err != nil { + if errors.Is(err, flag.ErrHelp) { + return + } + fmt.Fprintln(os.Stderr, "genspec:", err) + os.Exit(1) + } +} + +type config struct { + set *flag.FlagSet + + workdir *string + bools map[string]*bool + buildTags *string + goos *string + goarch *string + loader *string + exportData *string + output *string + indent *bool + quiet *bool +} + +func registerFlags(fs *flag.FlagSet) *config { + return &config{ + set: fs, + workdir: fs.String("workdir", ".", "directory the scan runs from; patterns are relative to it"), + bools: registerBools(fs), + 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)"), + loader: fs.String("loader", "auto", + `package loader: "go" runs go list, "own" needs no toolchain, "auto" picks own where there is no exec`), + exportData: fs.String("export-data", "", + "directory or .zip of pre-computed export data for dependencies (see hack/genexportdata):\n"+ + "full fidelity, none of the cost of type-checking them from source"), + output: fs.String("output", "-", `write the specification here ("-" for standard output)`), + indent: fs.Bool("indent", true, "indent the emitted JSON"), + quiet: fs.Bool("quiet", false, "suppress scan diagnostics on standard error"), + } +} + +func run(argv []string, stdout, stderr io.Writer) error { + fs := flag.NewFlagSet("genspec", flag.ContinueOnError) + fs.SetOutput(stderr) + fs.Usage = func() { + fmt.Fprintln(stderr, "usage: genspec [flags] [packages...]") + fmt.Fprintln(stderr, "\nScans annotated Go source and writes a Swagger 2.0 specification.") + fmt.Fprintln(stderr, "\nFlags:") + fs.PrintDefaults() + } + + cfg := registerFlags(fs) + if err := fs.Parse(argv); err != nil { + return err + } + + opts, err := cfg.options(fs.Args(), stderr) + if err != nil { + return err + } + + doc, err := codescan.Run(opts) + if err != nil { + return err + } + + return cfg.emit(doc, stdout) +} + +func (c *config) options(patterns []string, stderr io.Writer) (*codescan.Options, error) { + if len(patterns) == 0 { + patterns = []string{"./..."} + } + + opts := &codescan.Options{ + Packages: patterns, + WorkDir: *c.workdir, + BuildTags: *c.buildTags, + GOOS: *c.goos, + GOARCH: *c.goarch, + } + applyBools(opts, c.bools) + + switch { + case *c.exportData != "": + data, err := openExportData(*c.exportData) + if err != nil { + return nil, err + } + opts.ExportData = data + default: + // A build carrying its own copy needs nothing mounted for its dependencies. + if embedded, ok := exportdata.Embedded(); ok { + opts.ExportData = embedded + } + } + + useOwn, err := resolveLoader(*c.loader) + if err != nil { + return nil, err + } + if useOwn { + // Options.FS is what selects the toolchain-free loader. Rooting it at the host filesystem + // keeps the patterns and -workdir meaning what they say; under WASI that root is whatever the + // runtime mounted, which is the whole point. + opts.FS = os.DirFS("/") + + abs, err := absolutePath(*c.workdir) + if err != nil { + return nil, err + } + opts.WorkDir = abs + } + + if !*c.quiet { + opts.OnDiagnostic = func(d codescan.Diagnostic) { + fmt.Fprintln(stderr, d.String()) + } + } + + return opts, nil +} + +// resolveLoader reports whether to use codescan's own loader. +// +// "auto" asks whether this build can start a subprocess at all: on wasm it cannot, so `go list` is +// not an option and the choice makes itself. +func resolveLoader(mode string) (bool, error) { + switch mode { + case "own": + return true, nil + case "go": + return false, nil + case "auto": + return !canExec(), nil + default: + return false, fmt.Errorf("unknown -loader %q: want one of go, own, auto", mode) + } +} + +// absolutePath resolves p against the working directory. +// +// path/filepath.Abs is not used: it consults the process working directory, which a WASI guest may +// not have, and the result has to be a slash path rooted at the mount anyway. +func absolutePath(p string) (string, error) { + if strings.HasPrefix(p, "/") { + return p, nil + } + + wd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("cannot resolve %q against the working directory: %w", p, err) + } + if p == "." || p == "" { + return wd, nil + } + + return strings.TrimSuffix(wd, "/") + "/" + strings.TrimPrefix(p, "./"), nil +} + +func (c *config) emit(doc any, stdout io.Writer) error { + out := stdout + if *c.output != "-" { + f, err := os.Create(*c.output) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + out = f + } + + enc := json.NewEncoder(out) + if *c.indent { + enc.SetIndent("", " ") + } + + return enc.Encode(doc) +} diff --git a/cmd/genspec/options.go b/cmd/genspec/options.go new file mode 100644 index 00000000..f72306bf --- /dev/null +++ b/cmd/genspec/options.go @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "flag" + + "github.com/go-openapi/codescan" +) + +// boolOptions is every boolean knob on codescan.Options that this command exposes, in the order the +// help text lists them. +// +// A table rather than a field per flag: the option set grows, and a caller that cannot see a knob has +// no way to reach it. TestFlagsCoverEveryBoolOption fails when a new one lands without a decision +// here, so the gap is caught at the source rather than by a user meeting +// "flag provided but not defined". +// +// Flag names are the field name in kebab-case, without exception — guessing a shorter spelling is how +// a caller ends up guessing wrong. +var boolOptions = []boolOption{ //nolint:gochecknoglobals // the flag table, read once at startup + { + "scan-models", true, "also emit definitions for swagger:model types", + func(o *codescan.Options) *bool { return &o.ScanModels }, + }, + { + "prune-unused-models", false, "drop discovered models nothing references (needs -scan-models)", + func(o *codescan.Options) *bool { return &o.PruneUnusedModels }, + }, + { + "exclude-deps", false, "scan only the packages matched by the patterns, never their dependencies", + func(o *codescan.Options) *bool { return &o.ExcludeDeps }, + }, + + { + "ref-aliases", false, "aliases produce a $ref instead of being expanded", + func(o *codescan.Options) *bool { return &o.RefAliases }, + }, + { + "transparent-aliases", false, "aliases dissolve entirely, never producing a definition", + func(o *codescan.Options) *bool { return &o.TransparentAliases }, + }, + + { + "emit-ref-siblings", false, "emit a $ref'd field's description and extensions beside the $ref", + func(o *codescan.Options) *bool { return &o.EmitRefSiblings }, + }, + { + "skip-all-of-compounding", false, "never wrap a $ref in an allOf compound", + func(o *codescan.Options) *bool { return &o.SkipAllOfCompounding }, + }, + { + "default-all-of-for-embeds", false, "render a plain struct embed as allOf composition", + func(o *codescan.Options) *bool { return &o.DefaultAllOfForEmbeds }, + }, + + { + "set-x-nullable-for-pointers", false, "mark pointer fields x-nullable", + func(o *codescan.Options) *bool { return &o.SetXNullableForPointers }, + }, + { + "skip-extensions", false, "omit the x-go-* vendor extensions", + func(o *codescan.Options) *bool { return &o.SkipExtensions }, + }, + { + "emit-x-go-type", false, "stamp x-go-type on every emitted definition", + func(o *codescan.Options) *bool { return &o.EmitXGoType }, + }, + + { + "single-line-comment-as-description", false, "a one-line doc comment is a description, never a title", + func(o *codescan.Options) *bool { return &o.SingleLineCommentAsDescription }, + }, + { + "after-decl-comments", false, "accept annotations inside or after a declaration", + func(o *codescan.Options) *bool { return &o.AfterDeclComments }, + }, + { + "clean-go-doc", false, "rewrite godoc-only syntax carried into a title or description", + func(o *codescan.Options) *bool { return &o.CleanGoDoc }, + }, + { + "skip-enum-descriptions", false, "keep the enum const-name mapping off the description", + func(o *codescan.Options) *bool { return &o.SkipEnumDescriptions }, + }, + + { + "skip-jsonify-interface-methods", false, "emit interface-method names verbatim", + func(o *codescan.Options) *bool { return &o.SkipJSONifyInterfaceMethods }, + }, + { + "emit-hierarchical-names", false, "emit over-budget collision groups as nested definitions", + func(o *codescan.Options) *bool { return &o.EmitHierarchicalNames }, + }, + + { + "stub-stdlib", false, + "synthesize standard-library types instead of reading GOROOT: no Go installation and no module\n" + + "cache needed, much smaller footprint, but not failsafe -- see the package documentation", + func(o *codescan.Options) *bool { return &o.StubStdlib }, + }, +} + +type boolOption struct { + name string + value bool + help string + field func(*codescan.Options) *bool +} + +// registerBools declares every boolean flag and returns where the parsed values land. +func registerBools(fs *flag.FlagSet) map[string]*bool { + parsed := make(map[string]*bool, len(boolOptions)) + for _, opt := range boolOptions { + parsed[opt.name] = fs.Bool(opt.name, opt.value, opt.help) + } + + return parsed +} + +// applyBools copies the parsed values onto the options the scan runs with. +func applyBools(opts *codescan.Options, parsed map[string]*bool) { + for _, opt := range boolOptions { + if got, ok := parsed[opt.name]; ok { + *opt.field(opts) = *got + } + } +} diff --git a/cmd/genspec/options_test.go b/cmd/genspec/options_test.go new file mode 100644 index 00000000..d6ce821a --- /dev/null +++ b/cmd/genspec/options_test.go @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "flag" + "io" + "reflect" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// boolsNotOnCLI are the boolean options deliberately without a flag, with the reason. +var boolsNotOnCLI = 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", +} + +// TestFlagsCoverEveryBoolOption is what stops a knob being unreachable from the command line. +// +// A caller cannot use an option that has no flag, and finds out by meeting "flag provided but not +// defined" — after writing something against a surface that was never there. Fail here instead. +// +// Coverage is decided by which field each entry actually writes to, not by deriving a name from the +// field: a mechanical rendering cannot know that JSONify is one word and HTTPServer is two. +func TestFlagsCoverEveryBoolOption(t *testing.T) { + t.Parallel() + + covered := map[string]string{} + for _, opt := range boolOptions { + covered[fieldWrittenBy(t, opt)] = opt.name + } + + typ := reflect.TypeFor[codescan.Options]() + for i := range typ.NumField() { + f := typ.Field(i) + if !f.IsExported() || f.Type.Kind() != reflect.Bool { + continue + } + if _, excused := boolsNotOnCLI[f.Name]; excused { + continue + } + + assert.NotEmpty(t, covered[f.Name], + "codescan.Options.%s is reachable from no flag. Add an entry to boolOptions, or excuse it "+ + "in boolsNotOnCLI with a reason.", f.Name) + } +} + +// fieldWrittenBy reports which field of codescan.Options an entry's setter targets, by writing +// through it and seeing what moved. +func fieldWrittenBy(t *testing.T, opt boolOption) string { + t.Helper() + + var opts codescan.Options + *opt.field(&opts) = true + + typ := reflect.TypeFor[codescan.Options]() + value := reflect.ValueOf(opts) + for i := range typ.NumField() { + if typ.Field(i).Type.Kind() == reflect.Bool && value.Field(i).Bool() { + return typ.Field(i).Name + } + } + require.Fail(t, "flag "+opt.name+" writes to no field of codescan.Options") + + return "" +} + +// TestBoolFlagTableIsCurrent catches two flags sharing a name, and two flags writing to the same +// field — either leaves one of them silently doing nothing. +func TestBoolFlagTableIsCurrent(t *testing.T) { + t.Parallel() + + names := map[string]bool{} + fields := map[string]string{} + + for _, opt := range boolOptions { + require.False(t, names[opt.name], "flag %q is declared twice", opt.name) + names[opt.name] = true + + field := fieldWrittenBy(t, opt) + require.Empty(t, fields[field], + "flags %q and %q both write to Options.%s", fields[field], opt.name, field) + fields[field] = opt.name + } +} + +func TestBoolFlagsRegisterAndApply(t *testing.T) { + t.Parallel() + + fs := flag.NewFlagSet("genspec", flag.ContinueOnError) + fs.SetOutput(io.Discard) + parsed := registerBools(fs) + + // scan-models defaults true, so turning it off proves the explicit -name=false form works — the + // reason booleans are rendered that way rather than as bare switches. + require.NoError(t, fs.Parse([]string{"-prune-unused-models=true", "-scan-models=false"})) + + var opts codescan.Options + applyBools(&opts, parsed) + + assert.True(t, opts.PruneUnusedModels) + assert.False(t, opts.ScanModels) + assert.False(t, opts.SkipExtensions, "an untouched flag must leave its field alone") +} diff --git a/docs/examples/go.sum b/docs/examples/go.sum index 92f4f54b..1c160748 100644 --- a/docs/examples/go.sum +++ b/docs/examples/go.sum @@ -32,8 +32,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/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= diff --git a/fixtures/go.mod b/fixtures/go.mod index c7caf672..1433f68a 100644 --- a/fixtures/go.mod +++ b/fixtures/go.mod @@ -3,16 +3,26 @@ module github.com/go-openapi/codescan/fixtures go 1.25.0 require ( - github.com/go-openapi/runtime v0.29.3 + github.com/go-openapi/runtime v0.33.0 github.com/go-openapi/strfmt v0.27.0 github.com/go-swagger/scan-repo-boundary v0.0.0-20180623220736-973b3573c013 ) require ( github.com/go-openapi/errors v0.22.8 // indirect + github.com/go-openapi/swag/conv v0.28.0 // indirect + github.com/go-openapi/swag/fileutils v0.28.0 // indirect + github.com/go-openapi/swag/jsonutils v0.28.0 // indirect + github.com/go-openapi/swag/loading v0.28.0 // indirect + github.com/go-openapi/swag/mangling v0.28.0 // indirect + github.com/go-openapi/swag/pools v0.28.0 // indirect + github.com/go-openapi/swag/stringutils v0.28.0 // indirect + github.com/go-openapi/swag/typeutils v0.28.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect golang.org/x/text v0.40.0 // indirect ) diff --git a/fixtures/go.sum b/fixtures/go.sum index f7724ee0..fbae1083 100644 --- a/fixtures/go.sum +++ b/fixtures/go.sum @@ -1,9 +1,25 @@ +github.com/go-openapi/analysis v0.25.5 h1:xPYEvTb90o1y0epuiOPAoG4QqahjP3cdp5xNlHeKJRI= github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= -github.com/go-openapi/runtime v0.29.3 h1:h5twGaEqxtQg40ePiYm9vFFH1q06Czd7Ot6ufdK0w/Y= -github.com/go-openapi/runtime v0.29.3/go.mod h1:8A1W0/L5eyNJvKciqZtvIVQvYO66NlB7INMSZ9bw/oI= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI= +github.com/go-openapi/runtime v0.33.0 h1:Dd3Oj2ig+WH8ckK95l0Wn2V8a4bH/UqWPRZVT0vc8yU= +github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM= +github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= +github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU= +github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= +github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= +github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM= +github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= +github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= +github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= +github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.26.1 h1:pZSbvtRO8G2R2FpWTYRn3w8LrsNwbtaVhP2dWiBa0Us= github.com/go-swagger/scan-repo-boundary v0.0.0-20180623220736-973b3573c013 h1:l9rI6sNaZgNC0LnF3MiE+qTmyBA/tZAg1rtyrGbUMK0= github.com/go-swagger/scan-repo-boundary v0.0.0-20180623220736-973b3573c013/go.mod h1:b65mBPzqzZWxOZGxSWrqs4GInLIn+u99Q9q7p+GKni0= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= @@ -13,7 +29,9 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= diff --git a/go.mod b/go.mod index 4e25bf7b..728aff51 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/go-openapi/swag/yamlutils v0.28.0 github.com/go-openapi/testify/v2 v2.6.0 go.yaml.in/yaml/v3 v3.0.5 + golang.org/x/mod v0.38.0 golang.org/x/tools v0.48.0 ) @@ -22,6 +23,6 @@ require ( github.com/go-openapi/swag/pools v0.28.0 // indirect github.com/go-openapi/swag/stringutils v0.28.0 // indirect github.com/go-openapi/swag/typeutils v0.28.0 // indirect - golang.org/x/mod v0.38.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect golang.org/x/sync v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index 73774876..01a118f3 100644 --- a/go.sum +++ b/go.sum @@ -26,8 +26,7 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= -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= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= diff --git a/go.work b/go.work index 976a6a2d..a555236e 100644 --- a/go.work +++ b/go.work @@ -17,4 +17,5 @@ use ( ./cmd/genspec-tui ./docs/examples ./fixtures + ./hack/genexportdata/bundle ) diff --git a/hack/browser/.gitignore b/hack/browser/.gitignore new file mode 100644 index 00000000..81eed219 --- /dev/null +++ b/hack/browser/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +genspec.wasm diff --git a/hack/browser/README.md b/hack/browser/README.md new file mode 100644 index 00000000..a92e8ebd --- /dev/null +++ b/hack/browser/README.md @@ -0,0 +1,54 @@ + + +# Browser probe + +A throwaway check that the `wasip1` artifact runs in a browser, and what it costs. Not the +playground — the seed of one. + +```sh +npm install +GOOS=wasip1 GOARCH=wasm go build -o genspec.wasm ../../cmd/genspec + +node node-probe.js # the whole thing without a browser +npm run serve # then open http://localhost:8099/ for the real one +``` + +`genspec.wasm` and `node_modules/` are generated; neither is committed. + +## What it establishes + +Under [`@bjorn3/browser_wasi_shim`](https://github.com/bjorn3/browser_wasi_shim) (114 KB, no +dependencies), scanning a module held entirely in the shim's in-memory filesystem: + +| workload | run | `poll_oneoff` | definitions | +|---|---|---|---| +| 1 package | 86 ms cold, 10 ms warm | 0 calls | 1 | +| 50 packages, 150 files | 492 ms | 0 calls | 750 | +| 200 packages, 600 files | 3548 ms | 0 calls | 6000 | + +Compiling the 14.2 MB module takes 22 ms once; instantiating is ~4–5 ms and does not grow with the +workload. + +**`poll_oneoff` never fires.** It was the risk worth checking, because the shim implements it by +spinning (`while (endTime > getNow()) {}`) rather than yielding. Go reaches it only through the +netpoller, and only when the scheduler runs out of work — which a single-goroutine scan never does. +It is not guaranteed to stay at zero: longer runs under wazero showed 9–14 calls. But the shim +supports exactly the shape Go emits (one clock subscription, which is all `netpollinit` ever +registers), so when it does fire it works, and the cost is bounded by the delay Go asked for. +That is a reason to keep the scan in a worker, not a reason to avoid this shim. + +Measured under Node, which shares V8 with Chrome. What it does not measure is a browser engine's +own compile time for a 14 MB module, or anything about Firefox and Safari. + +## One run per instance + +The module exports `_start` and `memory`, and nothing else. It is a WASI **command**: run to +completion, terminated by `proc_exit`. Go's `wasip1` target emits no reactor form — there is no +`_initialize` and no callable export — so a fresh instance per scan is forced rather than chosen. + +That costs nothing worth avoiding. Compile once, keep the `WebAssembly.Module`, and instantiate per +run; the measurements above are with the module reused across runs. It also means each scan starts +on a clean heap, so nothing leaks from one run into the next. diff --git a/hack/browser/fixture.js b/hack/browser/fixture.js new file mode 100644 index 00000000..ecbdc7f6 --- /dev/null +++ b/hack/browser/fixture.js @@ -0,0 +1,21 @@ +// The module the probe scans. Kept in one place so the worker and any later app agree on it. +export const goMod = `module example.com/demo + +go 1.25.0 +`; + +export const petSrc = `package models + +// Pet is an animal in the store. +// +// swagger:model pet +type Pet struct { + // The pet's identifier + // required: true + ID int64 \`json:"id"\` + + // The pet's name + // max length: 50 + Name string \`json:"name"\` +} +`; diff --git a/hack/browser/index.html b/hack/browser/index.html new file mode 100644 index 00000000..e64e70e2 --- /dev/null +++ b/hack/browser/index.html @@ -0,0 +1,15 @@ + + +codescan WASI probe + +

codescan WASI probe

+

Runs the wasip1 artifact under @bjorn3/browser_wasi_shim, in a worker, and reports what it cost.

+
running…
+

timings


+

stdout (the specification)


+

stderr


+
diff --git a/hack/browser/node-probe.js b/hack/browser/node-probe.js
new file mode 100644
index 00000000..81da84f1
--- /dev/null
+++ b/hack/browser/node-probe.js
@@ -0,0 +1,99 @@
+// The same run as the page, under Node, so the shim can be exercised without a browser.
+//
+// What this does NOT tell us: how long a browser engine takes to compile a 15 MB module. Everything
+// else — the shim's filesystem, its poll_oneoff, and whether the artifact produces the right spec —
+// is the same code.
+
+import { readFile } from "node:fs/promises";
+import {
+  WASI, File, Directory, OpenFile, ConsoleStdout, PreopenDirectory,
+} from "./node_modules/@bjorn3/browser_wasi_shim/dist/index.js";
+import { goMod, petSrc } from "./fixture.js";
+
+// The shim logs every path it touches unless told otherwise.
+const { debug } = await import("./node_modules/@bjorn3/browser_wasi_shim/dist/debug.js");
+debug.enable(false);
+
+const enc = new TextEncoder();
+const file = (s) => new File(enc.encode(s));
+
+const bytes = await readFile("./genspec.wasm");
+let t = performance.now();
+const module = await WebAssembly.compile(bytes);
+const compileMs = performance.now() - t;
+
+// bigTree builds a module with many packages, so the scan runs long enough for Go's scheduler to
+// idle — which is the only thing that makes it call poll_oneoff, and therefore the only thing that
+// makes the shim's busy-wait matter.
+function bigTree(pkgs, typesPer) {
+  const entries = [["go.mod", file(goMod)]];
+  for (let p = 0; p < pkgs; p++) {
+    const files = new Map();
+    for (let f = 0; f < 3; f++) {
+      let src = `package p${p}\n\n`;
+      for (let t = 0; t < typesPer; t++) {
+        src += `// T${f}_${t} is a model.\n//\n// swagger:model t${p}_${f}_${t}\ntype T${f}_${t} struct {\n` +
+          `\tID int64 \`json:"id"\`\n\tName string \`json:"name"\`\n\tTags []string \`json:"tags"\`\n}\n\n`;
+      }
+      files.set(`f${f}.go`, file(src));
+    }
+    entries.push([`p${p}`, new Directory(files)]);
+  }
+
+  return new PreopenDirectory("/src", new Map(entries));
+}
+
+async function run(label, extraArgs, tree) {
+  let out = "", err = "";
+  const fds = [
+    new OpenFile(new File([])),
+    ConsoleStdout.lineBuffered((l) => { out += l + "\n"; }),
+    ConsoleStdout.lineBuffered((l) => { err += l + "\n"; }),
+    tree || new PreopenDirectory("/src", new Map([
+      ["go.mod", file(goMod)],
+      ["models", new Directory(new Map([["pet.go", file(petSrc)]]))],
+    ])),
+  ];
+
+  const args = ["genspec.wasm", "-loader=own", "-goos", "linux", "-goarch", "amd64",
+    ...extraArgs, "-workdir", "/src", "./..."];
+  const wasi = new WASI(args, [], fds);
+
+  let pollCalls = 0, pollMs = 0, maxPollMs = 0;
+  const imports = wasi.wasiImport;
+  const rawPoll = imports.poll_oneoff.bind(imports);
+  imports.poll_oneoff = (...a) => {
+    const t0 = performance.now();
+    const r = rawPoll(...a);
+    const d = performance.now() - t0;
+    pollMs += d; maxPollMs = Math.max(maxPollMs, d); pollCalls++;
+
+    return r;
+  };
+
+  t = performance.now();
+  const instance = await WebAssembly.instantiate(module, { wasi_snapshot_preview1: imports });
+  const instantiateMs = performance.now() - t;
+
+  t = performance.now();
+  const code = wasi.start(instance);
+  const runMs = performance.now() - t;
+
+  let defs = null;
+  try { defs = Object.keys(JSON.parse(out).definitions || {}); } catch { /* not JSON */ }
+
+  console.log(`--- ${label} ---`);
+  console.log(`  exit=${code}  instantiate=${instantiateMs.toFixed(1)}ms  run=${runMs.toFixed(0)}ms`);
+  console.log(`  poll_oneoff: ${pollCalls} calls, ${pollMs.toFixed(1)}ms total, ${maxPollMs.toFixed(1)}ms worst`);
+  console.log(`  definitions: ${defs ? defs.length : "STDOUT WAS NOT JSON"}`);
+  if (err.trim()) console.log(`  stderr: ${err.trim().split("\n").slice(0, 3).join(" | ")}`);
+
+  return { defs, out };
+}
+
+console.log(`compile: ${compileMs.toFixed(0)}ms for ${(bytes.length / 1048576).toFixed(1)} MB\n`);
+const a = await run("small: 1 package", ["-stub-stdlib"]);
+const b = await run("small again, same compiled module", ["-stub-stdlib"]);
+await run("medium: 50 packages, 150 files, 750 models", ["-stub-stdlib"], bigTree(50, 5));
+await run("large: 200 packages, 600 files, 6000 models", ["-stub-stdlib"], bigTree(200, 10));
+console.log(`\nreuse of the compiled module across runs: ${JSON.stringify(a.defs) === JSON.stringify(b.defs) ? "OK" : "DIFFERENT"}`);
diff --git a/hack/browser/package-lock.json b/hack/browser/package-lock.json
new file mode 100644
index 00000000..70ae6b45
--- /dev/null
+++ b/hack/browser/package-lock.json
@@ -0,0 +1,20 @@
+{
+  "name": "codescan-browser-probe",
+  "version": "0.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "codescan-browser-probe",
+      "version": "0.0.0",
+      "dependencies": {
+        "@bjorn3/browser_wasi_shim": "^0.4.2"
+      }
+    },
+    "node_modules/@bjorn3/browser_wasi_shim": {
+      "version": "0.4.2",
+      "resolved": "https://registry.npmjs.org/@bjorn3/browser_wasi_shim/-/browser_wasi_shim-0.4.2.tgz",
+      "integrity": "sha512-/iHkCVUG3VbcbmEHn5iIUpIrh7a7WPiwZ3sHy4HZKZzBdSadwdddYDZAII2zBvQYV0Lfi8naZngPCN7WPHI/hA=="
+    }
+  }
+}
diff --git a/hack/browser/package.json b/hack/browser/package.json
new file mode 100644
index 00000000..29b86701
--- /dev/null
+++ b/hack/browser/package.json
@@ -0,0 +1,13 @@
+{
+  "name": "codescan-browser-probe",
+  "version": "0.0.0",
+  "private": true,
+  "type": "module",
+  "description": "Throwaway probe: does the WASI artifact run in a browser, and what does it cost?",
+  "scripts": {
+    "serve": "python3 -m http.server 8099"
+  },
+  "dependencies": {
+    "@bjorn3/browser_wasi_shim": "^0.4.2"
+  }
+}
diff --git a/hack/browser/probe.js b/hack/browser/probe.js
new file mode 100644
index 00000000..cd257e6c
--- /dev/null
+++ b/hack/browser/probe.js
@@ -0,0 +1,31 @@
+const worker = new Worker("./worker.js", { type: "module" });
+const $ = (id) => document.getElementById(id);
+
+worker.onmessage = (e) => {
+  const r = e.data;
+  if (!r.ok) {
+    $("status").innerHTML = `FAILED`;
+    $("err").textContent = r.error;
+
+    return;
+  }
+
+  const defs = (() => {
+    try { return Object.keys(JSON.parse(r.out).definitions || {}); } catch { return null; }
+  })();
+
+  $("status").innerHTML = defs
+    ? `ran, exit ${r.code}, definitions: ${defs.join(", ") || "(none)"}`
+    : `ran, exit ${r.code}, but stdout was not JSON`;
+
+  $("timings").textContent = [
+    `compile      ${r.compileMs.toFixed(0)} ms   (once; reusable across runs)`,
+    `instantiate  ${r.instantiateMs.toFixed(1)} ms`,
+    `run          ${r.runMs.toFixed(0)} ms`,
+    `poll_oneoff  ${r.pollCalls} calls, ${r.pollMs.toFixed(1)} ms spinning`,
+  ].join("\n");
+  $("out").textContent = r.out || "(empty)";
+  $("err").textContent = r.err || "(empty)";
+};
+
+worker.postMessage({ url: "./genspec.wasm" });
diff --git a/hack/browser/worker.js b/hack/browser/worker.js
new file mode 100644
index 00000000..1de97330
--- /dev/null
+++ b/hack/browser/worker.js
@@ -0,0 +1,84 @@
+// Runs one scan and reports what it cost.
+//
+// In a worker for two reasons: a scan is seconds of solid CPU, and the shim's poll_oneoff busy-waits
+// rather than yielding, so on the main thread it would stall the page outright.
+
+import {
+  WASI, File, Directory, OpenFile, ConsoleStdout, PreopenDirectory,
+} from "./node_modules/@bjorn3/browser_wasi_shim/dist/index.js";
+import { goMod, petSrc } from "./fixture.js";
+
+const enc = new TextEncoder();
+const file = (s) => new File(enc.encode(s));
+
+// Compiling a 14 MB module is the expensive part and the result is reusable: proc_exit ends an
+// instance for good, so every run needs a fresh instance but not a fresh compile.
+let compiled = null;
+let compileMs = 0;
+
+async function moduleOnce(url) {
+  if (compiled) return compiled;
+  const t0 = performance.now();
+  compiled = await WebAssembly.compileStreaming(fetch(url));
+  compileMs = performance.now() - t0;
+
+  return compiled;
+}
+
+function guestTree() {
+  return new PreopenDirectory("/src", new Map([
+    ["go.mod", file(goMod)],
+    ["models", new Directory(new Map([["pet.go", file(petSrc)]]))],
+  ]));
+}
+
+async function run(url) {
+  const module = await moduleOnce(url);
+
+  let out = "", err = "";
+  const fds = [
+    new OpenFile(new File([])),
+    ConsoleStdout.lineBuffered((l) => { out += l + "\n"; }),
+    ConsoleStdout.lineBuffered((l) => { err += l + "\n"; }),
+    guestTree(),
+  ];
+
+  const args = [
+    "genspec.wasm", "-loader=own", "-stub-stdlib",
+    "-goos", "linux", "-goarch", "amd64",
+    "-workdir", "/src", "./...",
+  ];
+  const wasi = new WASI(args, [], fds);
+
+  // The shim spins inside poll_oneoff for the whole requested delay. Measure it: if Go asks for real
+  // sleeps this is dead wall-clock, and it is the one thing that would rule this shim out.
+  let pollCalls = 0, pollMs = 0;
+  const imports = wasi.wasiImport;
+  const rawPoll = imports.poll_oneoff.bind(imports);
+  imports.poll_oneoff = (...a) => {
+    const t = performance.now();
+    const r = rawPoll(...a);
+    pollMs += performance.now() - t;
+    pollCalls++;
+
+    return r;
+  };
+
+  const t1 = performance.now();
+  const instance = await WebAssembly.instantiate(module, { wasi_snapshot_preview1: imports });
+  const instantiateMs = performance.now() - t1;
+
+  const t2 = performance.now();
+  const code = wasi.start(instance);
+  const runMs = performance.now() - t2;
+
+  return { out, err, code, compileMs, instantiateMs, runMs, pollCalls, pollMs };
+}
+
+self.onmessage = async (e) => {
+  try {
+    self.postMessage({ ok: true, ...(await run(e.data.url)) });
+  } catch (ex) {
+    self.postMessage({ ok: false, error: String(ex && ex.stack || ex) });
+  }
+};
diff --git a/hack/doc-site/genspec-wasi/.gitignore b/hack/doc-site/genspec-wasi/.gitignore
new file mode 100644
index 00000000..7e2429f6
--- /dev/null
+++ b/hack/doc-site/genspec-wasi/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+dist/
+.vite/
+public/genspec.wasm
+public/exportdata.zip
diff --git a/hack/doc-site/genspec-wasi/README.md b/hack/doc-site/genspec-wasi/README.md
new file mode 100644
index 00000000..c237d6c9
--- /dev/null
+++ b/hack/doc-site/genspec-wasi/README.md
@@ -0,0 +1,80 @@
+
+
+# genspec-wasi
+
+The codescan playground: a Svelte front-end around the `wasip1` artifact, so a page can scan Go
+source without a server, a toolchain, or an upload. The files never leave the browser.
+
+Destined for the doc site as a static bundle; [`../hugo`](../hugo) will embed it through a shortcode.
+For the conformance probe that established this works at all, see
+[`hack/browser`](../../browser/README.md).
+
+**Prototype.** It works end to end and is deliberately bare: no highlighting, no cross-references, no
+chrome. It lags [`cmd/genspec-tui`](../../../cmd/genspec-tui/README.md), which has had a great deal
+more polish, and closing that gap probably wants a component library rather than more hand-rolled
+markup.
+
+```sh
+npm install
+npm run wasm      # cross-compiles cmd/genspec into public/
+npm run dev       # http://localhost:5174
+npm run build     # dist/
+npm run check     # svelte-check
+```
+
+`public/genspec.wasm`, `node_modules/` and `dist/` are generated; none is committed.
+
+## Shape
+
+| | |
+|---|---|
+| `src/worker/` | owns WebAssembly: compiles once, instantiates per scan, builds the guest filesystem |
+| `src/lib/store.svelte.ts` | the single piece of state every panel reads |
+| `src/lib/flags.ts` | renders scan options as the command line the guest sees |
+| `src/lib/sample.ts` | what the playground opens with; a shortcode will replace it |
+| `src/components/FilePicker.svelte` | takes a module in one gesture, and re-roots it |
+| `src/components/` | toolbar, options, and the two panes |
+
+The scan runs in a **worker**. A large tree is seconds of solid CPU, and the WASI shim implements
+`poll_oneoff` by spinning rather than yielding — on the main thread either would stall the page.
+
+Each scan needs a **fresh instance**: the artifact is a WASI command that ends at `proc_exit`, and
+Go's `wasip1` target emits no reactor form. Compiling is the expensive half and is kept between
+runs, so a re-scan costs a few milliseconds of instantiation.
+
+## Opening a module
+
+**Open module…** replaces the tree; it does not merge into it. Merging looks harmless and is not: the
+sample, or whatever was loaded before, stays behind, and the next scan sees two modules at once with
+two `go.mod` files.
+
+A directory pick reports paths relative to the chosen folder, which may sit above the module — or
+below it. The store re-roots on the outermost `go.mod` and drops what falls outside, so it does not
+matter which level was picked. Test files are skipped, `vendor/` is kept (with no module cache to
+read, it is the only way a third-party import resolves), and a selection over 8 MB of Go source is
+refused rather than held in memory.
+
+## Weight
+
+A visitor downloads about **24 KB** of application and **3.6 MB** of artifact, both compressed. The
+artifact dominates by two orders of magnitude, which is worth remembering before optimising anything
+on the JavaScript side.
+
+## Known gap
+
+The standard library is currently **synthesized** (`-stub-stdlib`) rather than read, because nothing
+is mounted for it. That is fast and needs no extra asset, but it is not failsafe: structural detail
+is lost, so `json.RawMessage` stops rendering as a byte array and a type is no longer seen to
+implement `encoding.TextMarshaler`. Third-party imports degrade the same way — which matters here,
+since go-openapi's own examples lean on `strfmt`.
+
+Fixing that means shipping precomputed export data alongside the artifact and pointing
+`-export-data` at it (see `hack/genexportdata` and `cmd/genspec/README.md`). The switch is one
+argument in `src/lib/flags.ts`, plus fetching the archive into the guest filesystem.
+
+Note that export data cannot cover every dependency: a library whose meaning lives in comments —
+`strfmt` declaring its formats with `swagger:strfmt` — comes back structurally intact and
+semantically empty. Those have to reach the guest as source.
diff --git a/hack/doc-site/genspec-wasi/index.html b/hack/doc-site/genspec-wasi/index.html
new file mode 100644
index 00000000..aa4fcecf
--- /dev/null
+++ b/hack/doc-site/genspec-wasi/index.html
@@ -0,0 +1,12 @@
+
+
+  
+    
+    
+    codescan playground
+  
+  
+    
+ + + diff --git a/hack/doc-site/genspec-wasi/package-lock.json b/hack/doc-site/genspec-wasi/package-lock.json new file mode 100644 index 00000000..db8dc75c --- /dev/null +++ b/hack/doc-site/genspec-wasi/package-lock.json @@ -0,0 +1,2012 @@ +{ + "name": "genspec-wasi", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "genspec-wasi", + "version": "0.0.1", + "dependencies": { + "@bjorn3/browser_wasi_shim": "^0.4.2" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@tsconfig/svelte": "^5.0.4", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vitest": "^2.1.9" + } + }, + "node_modules/@bjorn3/browser_wasi_shim": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@bjorn3/browser_wasi_shim/-/browser_wasi_shim-0.4.2.tgz", + "integrity": "sha512-/iHkCVUG3VbcbmEHn5iIUpIrh7a7WPiwZ3sHy4HZKZzBdSadwdddYDZAII2zBvQYV0Lfi8naZngPCN7WPHI/hA==" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz", + "integrity": "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw==", + "dev": true, + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.1.tgz", + "integrity": "sha512-5m3B2cbqQ4TbwW6Xkh66Ntw6dD7gNc77cCxABTTesWcq9jxIzMgTk97pZx5vEtvQx8iokgi7GIphqZe+PGwcZA==", + "dev": true, + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz", + "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==", + "dev": true, + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", + "debug": "^4.3.7", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.12", + "vitefu": "^1.0.3" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz", + "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==", + "dev": true, + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0", + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@tsconfig/svelte": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/@tsconfig/svelte/-/svelte-5.0.8.tgz", + "integrity": "sha512-UkNnw1/oFEfecR8ypyHIQuWYdkPvHiwcQ78sh+ymIiYoF+uc5H1UBetbjyqT+vgGJ3qQN6nhucJviX6HesWtKQ==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", + "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", + "dev": true + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true + }, + "node_modules/esrap": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.0.tgz", + "integrity": "sha512-GQ/7RN8uOtEfNpzZzBMTzW9JBcX42oaSVtPzdF+6cEL8pqIL094iUpr9jzYGn4O4P/1S60dJ6izyT8F4LYARng==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "optional": true, + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "optional": true, + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + }, + "node_modules/svelte": { + "version": "5.56.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", + "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", + "dev": true, + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.4.tgz", + "integrity": "sha512-IW9ot9YqAoyv8FvyN+eb4ZTe8zgcKZrJLNYU6dzSKkGwEBsSPc4K7lmQ8bKn8W2YMXM6WDfZSSVOaGtekyUfOQ==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.1", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true + } + } +} diff --git a/hack/doc-site/genspec-wasi/package.json b/hack/doc-site/genspec-wasi/package.json new file mode 100644 index 00000000..0f12a121 --- /dev/null +++ b/hack/doc-site/genspec-wasi/package.json @@ -0,0 +1,28 @@ +{ + "name": "genspec-wasi", + "version": "0.0.1", + "private": true, + "type": "module", + "description": "In-browser codescan: scans Go source with the wasip1 artifact, no server involved", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "check": "svelte-check --tsconfig ./tsconfig.json", + "wasm": "GOOS=wasip1 GOARCH=wasm go build -o public/genspec.wasm ../../../cmd/genspec", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@bjorn3/browser_wasi_shim": "^0.4.2" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@tsconfig/svelte": "^5.0.4", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vitest": "^2.1.9" + } +} diff --git a/hack/doc-site/genspec-wasi/src/App.svelte b/hack/doc-site/genspec-wasi/src/App.svelte new file mode 100644 index 00000000..e6f9e7d6 --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/App.svelte @@ -0,0 +1,20 @@ + + +
+ (showOptions = !showOptions)} /> + {#if showOptions} + + {/if} + +
+ + +
+
diff --git a/hack/doc-site/genspec-wasi/src/app.css b/hack/doc-site/genspec-wasi/src/app.css new file mode 100644 index 00000000..7c6dc6e2 --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/app.css @@ -0,0 +1,31 @@ +:root { + --bg: #ffffff; + --fg: #1a1a1a; + --muted: #6b7280; + --line: #e5e7eb; + --panel: #fafafa; + --accent: #2563eb; + --bad: #b91c1c; + font: 14px/1.5 system-ui, sans-serif; + color: var(--fg); + background: var(--bg); +} + +* { box-sizing: border-box; } +body { margin: 0; } + +.app { display: flex; flex-direction: column; height: 100vh; } +.panes { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; flex: 1; min-height: 0; background: var(--line); } +.pane { display: flex; flex-direction: column; min-width: 0; background: var(--bg); } +.pane > header { padding: .4rem .75rem; border-bottom: 1px solid var(--line); background: var(--panel); font-weight: 600; } + +code, pre, textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; } +pre { margin: 0; padding: .75rem; overflow: auto; flex: 1; min-height: 0; } +textarea { flex: 1; border: 0; padding: .75rem; resize: none; outline: none; min-height: 0; } + +button { font: inherit; padding: .3rem .7rem; border: 1px solid var(--line); border-radius: 4px; background: var(--bg); cursor: pointer; } +button.primary { background: var(--accent); color: #fff; border-color: var(--accent); } +button:disabled { opacity: .55; cursor: default; } + +.muted { color: var(--muted); } +.bad { color: var(--bad); } diff --git a/hack/doc-site/genspec-wasi/src/components/FilePicker.svelte b/hack/doc-site/genspec-wasi/src/components/FilePicker.svelte new file mode 100644 index 00000000..78f0fc2d --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/components/FilePicker.svelte @@ -0,0 +1,67 @@ + + + + diff --git a/hack/doc-site/genspec-wasi/src/components/OptionsPanel.svelte b/hack/doc-site/genspec-wasi/src/components/OptionsPanel.svelte new file mode 100644 index 00000000..8555fd77 --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/components/OptionsPanel.svelte @@ -0,0 +1,48 @@ + + + + + diff --git a/hack/doc-site/genspec-wasi/src/components/SourcePane.svelte b/hack/doc-site/genspec-wasi/src/components/SourcePane.svelte new file mode 100644 index 00000000..d8310d03 --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/components/SourcePane.svelte @@ -0,0 +1,31 @@ + + +
+
+ + {playground.files.length} file{playground.files.length === 1 ? '' : 's'} +
+ + {#if current} + + {:else} +
No file selected.
+ {/if} +
+ + diff --git a/hack/doc-site/genspec-wasi/src/components/SpecPane.svelte b/hack/doc-site/genspec-wasi/src/components/SpecPane.svelte new file mode 100644 index 00000000..410449cf --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/components/SpecPane.svelte @@ -0,0 +1,46 @@ + + +
+
+ Specification + {#if playground.runMs} + scanned in {Math.round(playground.runMs)} ms + {/if} +
+ + {#if playground.error} +
{playground.error}
+ {:else if pretty} +
{pretty}
+ {:else} +
Press Scan.
+ {/if} + + {#if playground.diagnostics.trim()} +
+ Diagnostics +
{playground.diagnostics}
+
+ {/if} +
+ + diff --git a/hack/doc-site/genspec-wasi/src/components/Toolbar.svelte b/hack/doc-site/genspec-wasi/src/components/Toolbar.svelte new file mode 100644 index 00000000..ced2a39f --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/components/Toolbar.svelte @@ -0,0 +1,32 @@ + + +
+ codescan playground + scans in your browser — nothing is uploaded + + + + + + + +
+ + diff --git a/hack/doc-site/genspec-wasi/src/lib/flags.test.ts b/hack/doc-site/genspec-wasi/src/lib/flags.test.ts new file mode 100644 index 00000000..40261c11 --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/lib/flags.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { argvFor } from './flags'; +import { defaultOptions } from './types'; + +describe('argvFor', () => { + it('always spells out the loader and the build target', () => { + // A guest has no toolchain, and left alone would describe the platform it is running on rather + // than the code being scanned. + const argv = argvFor(defaultOptions, 'stub'); + + expect(argv).toContain('-loader=own'); + expect(argv.join(' ')).toContain('-goos linux'); + expect(argv.join(' ')).toContain('-goarch amd64'); + }); + + it('renders booleans as -name=value so a true-by-default flag can be turned off', () => { + const argv = argvFor({ ...defaultOptions, scanModels: false }, 'stub'); + + expect(argv).toContain('-scan-models=false'); + expect(argv).not.toContain('-scan-models'); + }); + + it('passes each option through under its real flag name', () => { + const argv = argvFor({ ...defaultOptions, pruneUnusedModels: true, refAliases: true }, 'stub'); + + expect(argv).toContain('-prune-unused-models=true'); + expect(argv).toContain('-ref-aliases=true'); + }); + + it('chooses between synthesizing the standard library and reading export data', () => { + expect(argvFor(defaultOptions, 'stub')).toContain('-stub-stdlib=true'); + expect(argvFor(defaultOptions, 'export-data').join(' ')).toContain('-export-data='); + }); + + it('omits build tags when there are none, rather than passing an empty one', () => { + expect(argvFor(defaultOptions, 'stub')).not.toContain('-build-tags'); + expect(argvFor({ ...defaultOptions, buildTags: ' integration ' }, 'stub')).toContain('integration'); + }); + + it('ends with the working directory and the pattern', () => { + expect(argvFor(defaultOptions, 'stub').slice(-3)).toEqual(['-workdir', '/src', './...']); + }); +}); diff --git a/hack/doc-site/genspec-wasi/src/lib/flags.ts b/hack/doc-site/genspec-wasi/src/lib/flags.ts new file mode 100644 index 00000000..08497e96 --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/lib/flags.ts @@ -0,0 +1,34 @@ +import type { ScanOptions } from './types'; + +// The flag each option maps to. These are genspec's real flag names — check `genspec -h`, or +// cmd/genspec/options.go, before adding one: a name that does not exist fails the whole scan with +// "flag provided but not defined". +const booleans: Array<[keyof ScanOptions, string]> = [ + ['scanModels', 'scan-models'], + ['pruneUnusedModels', 'prune-unused-models'], + ['refAliases', 'ref-aliases'], + ['transparentAliases', 'transparent-aliases'], + ['setXNullableForPointers', 'set-x-nullable-for-pointers'], + ['skipExtensions', 'skip-extensions'], +]; + +// argvFor renders the command line the guest sees. The guest has no toolchain and nothing mounted +// beyond the module under scan, so the loader and the build target are always spelled out. +// +// Booleans go as -name=value rather than bare -name: scan-models defaults to true, and Go's flag +// package needs the explicit form to turn one off. +export function argvFor(options: ScanOptions, stdlib: 'stub' | 'export-data'): string[] { + const argv = ['genspec.wasm', '-loader=own', '-goos', 'linux', '-goarch', 'amd64']; + + argv.push(stdlib === 'stub' ? '-stub-stdlib=true' : '-export-data=/exportdata.zip'); + + for (const [key, flag] of booleans) { + argv.push(`-${flag}=${options[key] ? 'true' : 'false'}`); + } + if (options.buildTags.trim()) { + argv.push('-build-tags', options.buildTags.trim()); + } + argv.push('-workdir', '/src', './...'); + + return argv; +} diff --git a/hack/doc-site/genspec-wasi/src/lib/sample.ts b/hack/doc-site/genspec-wasi/src/lib/sample.ts new file mode 100644 index 00000000..cdb25b52 --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/lib/sample.ts @@ -0,0 +1,64 @@ +import type { SourceFile } from './types'; + +// What the playground starts with. A shortcode will be able to replace this wholesale, which is why +// it is a function returning fresh objects rather than a shared constant. +export function sampleFiles(): SourceFile[] { + return [ + { + path: 'go.mod', + text: 'module example.com/petstore\n\ngo 1.25.0\n', + }, + { + path: 'models/pet.go', + text: `package models + +// Pet is an animal in the store. +// +// swagger:model pet +type Pet struct { + // The pet's identifier + // + // required: true + // minimum: 1 + ID int64 \`json:"id"\` + + // The pet's name + // + // required: true + // max length: 50 + Name string \`json:"name"\` + + // What sort of animal it is + // + // enum: cat,dog,bird + Kind string \`json:"kind"\` + + // Free-form labels + Tags []string \`json:"tags,omitempty"\` +} +`, + }, + { + path: 'api/handlers.go', + text: `package api + +import "example.com/petstore/models" + +// swagger:route GET /pets pets listPets +// +// Lists the pets in the store. +// +// Responses: +// 200: petList + +// PetList is the list of pets. +// +// swagger:response petList +type PetList struct { + // in: body + Body []models.Pet +} +`, + }, + ]; +} diff --git a/hack/doc-site/genspec-wasi/src/lib/store.svelte.ts b/hack/doc-site/genspec-wasi/src/lib/store.svelte.ts new file mode 100644 index 00000000..dfd729ad --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/lib/store.svelte.ts @@ -0,0 +1,93 @@ +import { defaultOptions, type ScanOptions, type SourceFile, type WorkerReply } from './types'; +import { sampleFiles } from './sample'; +import { reroot } from './tree'; + +// The playground's whole state. One module-level store rather than context, because there is exactly +// one playground on a page and every panel reads the same thing. +class Playground { + files = $state(sampleFiles()); + selected = $state(sampleFiles()[1]?.path ?? ''); + options = $state({ ...defaultOptions }); + + spec = $state(''); + diagnostics = $state(''); + error = $state(''); + running = $state(false); + runMs = $state(0); + + #worker: Worker | null = null; + + get current(): SourceFile | undefined { + return this.files.find((f) => f.path === this.selected); + } + + edit(path: string, text: string) { + const file = this.files.find((f) => f.path === path); + if (file) { + file.text = text; + } + } + + // open REPLACES the tree rather than merging into it. + // + // Merging was wrong in a way that only shows up on the second scan: whatever was already loaded — + // the sample, or a previous upload — stays behind, and the scan sees two modules at once, with two + // go.mod files and packages that do not belong together. + open(incoming: SourceFile[]) { + const files = reroot(incoming); + if (!files.length) { + this.error = 'No Go files found in that selection.'; + + return; + } + + this.files = files; + this.selected = (files.find((f) => f.path.endsWith('.go')) ?? files[0]).path; + + // The previous result describes a tree that is gone. + this.spec = ''; + this.diagnostics = ''; + this.error = ''; + this.runMs = 0; + } + + reset() { + this.files = sampleFiles(); + this.selected = this.files[1]?.path ?? ''; + this.spec = ''; + this.diagnostics = ''; + this.error = ''; + } + + run() { + if (this.running) { + return; + } + this.running = true; + this.error = ''; + + // The worker keeps the compiled module between runs; only the instance is new each time. + this.#worker ??= new Worker(new URL('../worker/scan-worker.ts', import.meta.url), { type: 'module' }); + this.#worker.onmessage = (event: MessageEvent) => { + const reply = event.data; + this.running = false; + + if (!reply.ok) { + this.error = reply.error; + + return; + } + this.spec = reply.spec; + this.diagnostics = reply.diagnostics; + this.runMs = reply.runMs; + }; + + this.#worker.postMessage({ + files: $state.snapshot(this.files), + options: $state.snapshot(this.options), + wasmUrl: new URL(`${import.meta.env.BASE_URL}genspec.wasm`, location.href).href, + }); + } +} + +export const playground = new Playground(); diff --git a/hack/doc-site/genspec-wasi/src/lib/tree.test.ts b/hack/doc-site/genspec-wasi/src/lib/tree.test.ts new file mode 100644 index 00000000..beb37ae8 --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/lib/tree.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { reroot } from './tree'; + +const at = (...paths: string[]) => paths.map((path) => ({ path, text: '' })); +const paths = (files: { path: string }[]) => files.map((f) => f.path); + +describe('reroot', () => { + it('leaves a tree already rooted at go.mod alone', () => { + expect(paths(reroot(at('go.mod', 'models/pet.go')))).toEqual(['go.mod', 'models/pet.go']); + }); + + it('strips the folder a directory pick prefixes', () => { + expect(paths(reroot(at('myapi/go.mod', 'myapi/models/pet.go')))) + .toEqual(['go.mod', 'models/pet.go']); + }); + + it('re-roots when the pick sat above the module, dropping what falls outside', () => { + expect(paths(reroot(at('work/proj/myapi/go.mod', 'work/proj/myapi/m.go', 'work/other/x.go')))) + .toEqual(['go.mod', 'm.go']); + }); + + it('roots at the outermost go.mod, so a submodule does not steal it', () => { + expect(paths(reroot(at('myapi/go.mod', 'myapi/tools/go.mod', 'myapi/tools/t.go', 'myapi/m.go')))) + .toEqual(['go.mod', 'tools/go.mod', 'tools/t.go', 'm.go']); + }); + + it('keeps vendor, which is how a third-party import resolves with no module cache', () => { + expect(paths(reroot(at('myapi/go.mod', 'myapi/vendor/github.com/x/y/y.go')))) + .toContain('vendor/github.com/x/y/y.go'); + }); + + it('leaves a selection with no go.mod untouched, for the scan to complain about', () => { + expect(paths(reroot(at('loose/a.go', 'loose/b.go')))).toEqual(['loose/a.go', 'loose/b.go']); + }); + + it('survives an empty selection', () => { + expect(reroot([])).toEqual([]); + }); +}); diff --git a/hack/doc-site/genspec-wasi/src/lib/tree.ts b/hack/doc-site/genspec-wasi/src/lib/tree.ts new file mode 100644 index 00000000..f9df8acb --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/lib/tree.ts @@ -0,0 +1,22 @@ +import type { SourceFile } from './types'; + +// reroot makes paths relative to the module root, and drops anything outside it. +// +// A directory pick reports paths relative to the chosen folder, which may sit above the module — or +// below it, if someone picks a subdirectory. The scan needs go.mod at the top, so find it and move +// everything to match. With no go.mod at all the paths are left alone and the scan will say so. +export function reroot(files: SourceFile[]): SourceFile[] { + const mods = files.filter((f) => f.path === 'go.mod' || f.path.endsWith('/go.mod')); + if (!mods.length) { + return files; + } + + // The outermost go.mod wins: a nested one belongs to a submodule the scan should not be rooted at. + const root = mods + .map((f) => f.path.slice(0, Math.max(0, f.path.length - 'go.mod'.length))) + .reduce((a, b) => (a.length <= b.length ? a : b)); + + return files + .filter((f) => f.path.startsWith(root)) + .map((f) => ({ ...f, path: f.path.slice(root.length) })); +} diff --git a/hack/doc-site/genspec-wasi/src/lib/types.ts b/hack/doc-site/genspec-wasi/src/lib/types.ts new file mode 100644 index 00000000..5497ef3b --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/lib/types.ts @@ -0,0 +1,45 @@ +// A file in the tree the user is scanning. Paths are relative to the module root and always use +// forward slashes, matching what the guest filesystem expects. +export type SourceFile = { + path: string; + text: string; +}; + +// The subset of codescan's options the playground exposes. Every one of these is a genspec flag; +// see cmd/genspec/README.md for the whole set. +export type ScanOptions = { + scanModels: boolean; + pruneUnusedModels: boolean; + refAliases: boolean; + transparentAliases: boolean; + setXNullableForPointers: boolean; + skipExtensions: boolean; + buildTags: string; +}; + +export const defaultOptions: ScanOptions = { + scanModels: true, + pruneUnusedModels: false, + refAliases: false, + transparentAliases: false, + setXNullableForPointers: false, + skipExtensions: false, + buildTags: '', +}; + +export type ScanResult = { + spec: string; + diagnostics: string; + exitCode: number; + runMs: number; +}; + +export type ScanRequest = { + files: SourceFile[]; + options: ScanOptions; + wasmUrl: string; +}; + +export type WorkerReply = + | ({ ok: true } & ScanResult) + | { ok: false; error: string }; diff --git a/hack/doc-site/genspec-wasi/src/main.ts b/hack/doc-site/genspec-wasi/src/main.ts new file mode 100644 index 00000000..dd416429 --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/main.ts @@ -0,0 +1,5 @@ +import { mount } from 'svelte'; +import './app.css'; +import App from './App.svelte'; + +export default mount(App, { target: document.getElementById('app')! }); diff --git a/hack/doc-site/genspec-wasi/src/worker/scan-worker.ts b/hack/doc-site/genspec-wasi/src/worker/scan-worker.ts new file mode 100644 index 00000000..e8d32eda --- /dev/null +++ b/hack/doc-site/genspec-wasi/src/worker/scan-worker.ts @@ -0,0 +1,95 @@ +// Owns the WebAssembly side of the playground. +// +// It runs in a worker for two reasons. A scan is seconds of solid CPU on a large tree, and the WASI +// shim implements poll_oneoff by spinning rather than yielding — rare, but on the main thread either +// would stall the page. + +import { + WASI, File, Directory, OpenFile, ConsoleStdout, PreopenDirectory, +} from '@bjorn3/browser_wasi_shim'; +import type { Inode } from '@bjorn3/browser_wasi_shim'; +import type { ScanRequest, WorkerReply, SourceFile } from '../lib/types'; +import { argvFor } from '../lib/flags'; + +const encoder = new TextEncoder(); + +// The artifact is a WASI command: it exports _start and nothing else, runs to completion, and ends +// at proc_exit. So every scan needs its own instance — but not its own compile, which is the +// expensive half and is reusable. +let compiled: WebAssembly.Module | null = null; + +async function moduleOnce(url: string): Promise { + if (!compiled) { + compiled = await WebAssembly.compileStreaming(fetch(url)); + } + + return compiled; +} + +// treeFor turns a flat list of paths into the nested directories the guest filesystem expects. +function treeFor(files: SourceFile[]): PreopenDirectory { + const root = new Map(); + + for (const file of files) { + const parts = file.path.split('/').filter(Boolean); + const name = parts.pop(); + if (!name) { + continue; + } + + let dir: Map = root; + for (const part of parts) { + const existing = dir.get(part); + const child = existing instanceof Directory ? existing : new Directory(new Map()); + if (child !== existing) { + dir.set(part, child); + } + dir = child.contents; + } + dir.set(name, new File(encoder.encode(file.text))); + } + + return new PreopenDirectory('/src', root); +} + +async function scan(request: ScanRequest): Promise { + const module = await moduleOnce(request.wasmUrl); + + let spec = ''; + let diagnostics = ''; + const fds = [ + new OpenFile(new File([])), + ConsoleStdout.lineBuffered((line) => { spec += line + '\n'; }), + ConsoleStdout.lineBuffered((line) => { diagnostics += line + '\n'; }), + treeFor(request.files), + ]; + + const wasi = new WASI(argvFor(request.options, 'stub'), [], fds); + const instance = await WebAssembly.instantiate(module, { + wasi_snapshot_preview1: wasi.wasiImport as unknown as WebAssembly.ModuleImports, + }); + + const started = performance.now(); + // The shim wants the memory export named in the type; the artifact does export it. + const exitCode = wasi.start( + instance as unknown as { exports: { memory: WebAssembly.Memory; _start: () => unknown } }, + ); + + return { + ok: true, + spec, + diagnostics, + exitCode, + runMs: performance.now() - started, + }; +} + +self.onmessage = async (event: MessageEvent) => { + try { + self.postMessage(await scan(event.data)); + } catch (err) { + // A failure here is the scanner refusing the input, not a crash to hide: surface it where the + // diagnostics go. + self.postMessage({ ok: false, error: err instanceof Error ? err.message : String(err) } as WorkerReply); + } +}; diff --git a/hack/doc-site/genspec-wasi/svelte.config.js b/hack/doc-site/genspec-wasi/svelte.config.js new file mode 100644 index 00000000..36c98fd0 --- /dev/null +++ b/hack/doc-site/genspec-wasi/svelte.config.js @@ -0,0 +1,3 @@ +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +export default { preprocess: vitePreprocess() }; diff --git a/hack/doc-site/genspec-wasi/tsconfig.json b/hack/doc-site/genspec-wasi/tsconfig.json new file mode 100644 index 00000000..70f412f1 --- /dev/null +++ b/hack/doc-site/genspec-wasi/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowJs": true, + "checkJs": true, + "isolatedModules": true, + "strict": true, + "lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"], + "types": ["vite/client"] + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/hack/doc-site/genspec-wasi/vite.config.ts b/hack/doc-site/genspec-wasi/vite.config.ts new file mode 100644 index 00000000..9071af70 --- /dev/null +++ b/hack/doc-site/genspec-wasi/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +// The playground is a plain SPA embedded in a static Hugo page, so it is built with a relative base +// and never assumes it is served from the site root. +// +// The artifact lives in public/ rather than being imported as an asset: it is 15 MB, there is nothing +// for the bundler to do to it, and Hugo will serve it from static/ the same way. +export default defineConfig({ + base: './', + plugins: [svelte()], + server: { port: 5174, open: false }, + build: { target: 'es2022', assetsDir: 'assets' }, + worker: { format: 'es' }, +}); diff --git a/hack/genexportdata/bundle/deps.go b/hack/genexportdata/bundle/deps.go new file mode 100644 index 00000000..bb02c6de --- /dev/null +++ b/hack/genexportdata/bundle/deps.go @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package bundle names the dependencies the published export-data bundle covers. +// +// Nothing here is called. The imports exist so `go list` can resolve them, and so the set is a +// reviewable list in one place rather than an argument someone remembers to pass. +// +// What belongs here: the libraries an annotated API is likely to mention in a type the scanner has +// to render — a format, a stream, a spec type. What does not: anything a scan never sees. +package bundle + +import ( + _ "github.com/go-openapi/runtime" + _ "github.com/go-openapi/spec" + _ "github.com/go-openapi/strfmt" + _ "github.com/go-openapi/swag" +) diff --git a/hack/genexportdata/bundle/go.mod b/hack/genexportdata/bundle/go.mod new file mode 100644 index 00000000..70e42b1f --- /dev/null +++ b/hack/genexportdata/bundle/go.mod @@ -0,0 +1,39 @@ +// This module exists to name what goes into the published export-data bundle. +// +// Its requirements ARE the manifest: whatever is imported below is what a scan can resolve without +// reading source. Keeping it a module of its own means the versions are pinned and visible, rather +// than inherited from whichever module the generator happened to run in. +module github.com/go-openapi/codescan/hack/genexportdata/bundle + +go 1.25.0 + +require ( + github.com/go-openapi/runtime v0.33.0 + github.com/go-openapi/spec v0.22.9 + github.com/go-openapi/strfmt v0.27.0 + github.com/go-openapi/swag v0.28.0 +) + +require ( + github.com/go-openapi/errors v0.22.8 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/swag/cmdutils v0.28.0 // indirect + github.com/go-openapi/swag/conv v0.28.0 // indirect + github.com/go-openapi/swag/fileutils v0.28.0 // indirect + github.com/go-openapi/swag/jsonutils v0.28.0 // indirect + github.com/go-openapi/swag/loading v0.28.0 // indirect + github.com/go-openapi/swag/mangling v0.28.0 // indirect + github.com/go-openapi/swag/netutils v0.28.0 // indirect + github.com/go-openapi/swag/pools v0.28.0 // indirect + github.com/go-openapi/swag/stringutils v0.28.0 // indirect + github.com/go-openapi/swag/typeutils v0.28.0 // indirect + github.com/go-openapi/swag/yamlutils v0.28.0 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/text v0.40.0 // indirect +) diff --git a/hack/genexportdata/bundle/go.sum b/hack/genexportdata/bundle/go.sum new file mode 100644 index 00000000..6800e9fe --- /dev/null +++ b/hack/genexportdata/bundle/go.sum @@ -0,0 +1,62 @@ +github.com/go-openapi/analysis v0.25.5 h1:xPYEvTb90o1y0epuiOPAoG4QqahjP3cdp5xNlHeKJRI= +github.com/go-openapi/analysis v0.25.5/go.mod h1:d3UGtQC5uq5Kqqqis2VH09Km/v3vwsWrYkbp4gdm+Rc= +github.com/go-openapi/errors v0.22.8 h1:oP7sW7TWc3wFFjrzzj0nI83H2qMBkNjNfSd+XRejk/I= +github.com/go-openapi/errors v0.22.8/go.mod h1:BuUoHcYrU6E7V9gfj1I5wLQqgtIHnup/alXZ8KdgQ0w= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/loads v0.25.0 h1:74Bc2snfaVlsHzwdQj/3gsA9XJz3daXTJVs+4ZaK7jI= +github.com/go-openapi/loads v0.25.0/go.mod h1:JFBw4SIB9+PTIFHDfcXuSSy5h6aWzjtUCrPYyx3qWU8= +github.com/go-openapi/runtime v0.33.0 h1:Dd3Oj2ig+WH8ckK95l0Wn2V8a4bH/UqWPRZVT0vc8yU= +github.com/go-openapi/runtime v0.33.0/go.mod h1:+rsupH3+TFKqmFysqkmgBOTxpVJV8eV+j9myvvea2Xw= +github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= +github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= +github.com/go-openapi/strfmt v0.27.0 h1:kbcTeaD9TXuXD0hhMXzuYa1sdTo6+dWGvwjW93E80IM= +github.com/go-openapi/strfmt v0.27.0/go.mod h1:s/qhDqfY72irigXUGJmtgid2Rm+3tnz3k8hZaRmvWYc= +github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= +github.com/go-openapi/swag v0.28.0/go.mod h1:4qYnT3Cqr1p1VknOdPo70evN4rgQnAg6jwApHyxSGIg= +github.com/go-openapi/swag/cmdutils v0.28.0 h1:7TOeNtkYru1SG8Y34tDh9WBbLsMqGnptuxWiHREPZ4Q= +github.com/go-openapi/swag/cmdutils v0.28.0/go.mod h1:Sm1MVFMkF6guJJ+pQqHnQA3N0j9qALV3NxzDSv6bETM= +github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= +github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= +github.com/go-openapi/swag/fileutils v0.28.0 h1:Z04XWQD7R8Eq+7GnOrjovBxPPmZzsS4gt2H2GPGIViU= +github.com/go-openapi/swag/fileutils v0.28.0/go.mod h1:VvJFZLTZS0AI854gEQz5tk7dBESdLjiNUMSZ/th2ry8= +github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= +github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= +github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= +github.com/go-openapi/swag/mangling v0.28.0 h1:pH8eyeNO9SLYsTMWJrurnNfKmDa28XrlA+HePVD53VM= +github.com/go-openapi/swag/mangling v0.28.0/go.mod h1:jtBE2+V+3pILxOR7Vgce+Cwp6A2PgZbvVqfNntbVs0w= +github.com/go-openapi/swag/netutils v0.28.0 h1:YXN6TALEi2pzts8/8GNm6T61HTAZsieukGZidap989k= +github.com/go-openapi/swag/netutils v0.28.0/go.mod h1:J+WYyFMLtvtCGqa6jLv+YNUmIKI3ZRQRrvfNDMoQoEQ= +github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= +github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= +github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= +github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= +github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= +github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= +github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= +github.com/go-openapi/testify/v2 v2.6.0/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= +github.com/go-openapi/validate v0.26.1 h1:pZSbvtRO8G2R2FpWTYRn3w8LrsNwbtaVhP2dWiBa0Us= +github.com/go-openapi/validate v0.26.1/go.mod h1:B8UMgXiQiwwQWIbmuROlwJZDPGlikPuh7iHV1vPX9Oo= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= diff --git a/hack/genexportdata/main.go b/hack/genexportdata/main.go new file mode 100644 index 00000000..9b6d8c3d --- /dev/null +++ b/hack/genexportdata/main.go @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Command genexportdata extracts the compiler's export data for a set of packages into a directory +// tree keyed by import path. +// +// The result is what internal/packages reads through WithStdlibExportData: pre-digested types, so a +// scan never has to parse or type-check those packages. It is generated here, natively, because +// producing it needs the toolchain — which is precisely what the consumer does not have. +// +// go run ./hack/genexportdata -out ./exportdata std +// go run ./hack/genexportdata -dir hack/genexportdata/bundle -out bundle.zip std ./... +// +// The bundle module names what the published archive covers; see hack/genexportdata/bundle. +// +// The output is valid only for the toolchain that produced it. Export data format is tied to the Go +// release, so regenerate it whenever the supported toolchain moves. +package main + +import ( + "archive/zip" + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + + "golang.org/x/tools/go/gcexportdata" +) + +func main() { + out := flag.String("out", "exportdata", "directory, or .zip file, to write the export data into") + dir := flag.String("dir", "", "module to resolve the patterns in (default: the current one)") + flag.Parse() + + patterns := flag.Args() + if len(patterns) == 0 { + patterns = []string{"std"} + } + moduleDir = *dir + + write := run + if strings.HasSuffix(*out, ".zip") { + // A zip is what an embedded build wants: archive/zip's Reader is already an fs.FS, so the + // artifact carries one file and still serves per-package reads lazily. + write = runZip + } + + n, bytes, err := write(*out, patterns) + if err != nil { + fmt.Fprintln(os.Stderr, "genexportdata:", err) + os.Exit(1) + } + fmt.Printf("wrote %d packages, %.1f MB, to %s\n", n, float64(bytes)/(1<<20), *out) +} + +// runZip writes the same tree into a single archive. +func runZip(dest string, patterns []string) (int, int64, error) { + pkgs, err := list(patterns) + if err != nil { + return 0, 0, err + } + + if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil { + return 0, 0, err + } + f, err := os.Create(dest) //nolint:gosec // dest comes from the -out flag of a dev tool + if err != nil { + return 0, 0, err + } + defer func() { _ = f.Close() }() + + zw := zip.NewWriter(f) + var count int + for _, p := range pkgs { + if p.Export == "" { + continue + } + if skipAnnotated(p) { + continue + } + w, err := zw.Create(p.ImportPath + ".export") + if err != nil { + return 0, 0, err + } + if err := copyExportSection(p.Export, w); err != nil { + return 0, 0, fmt.Errorf("%s: %w", p.ImportPath, err) + } + count++ + } + if err := zw.Close(); err != nil { + return 0, 0, err + } + + info, err := f.Stat() + if err != nil { + return 0, 0, err + } + + return count, info.Size(), nil +} + +// moduleDir is where `go list` runs, so a bundle can be resolved in a module that requires what it +// should cover rather than in whichever one the tool was invoked from. +var moduleDir string //nolint:gochecknoglobals // set once from the command line + +// listed is the slice of `go list -json` output this tool needs. +type listed struct { + ImportPath string + Export string + Standard bool + Dir string + GoFiles []string +} + +// annotated reports whether a package carries swagger annotations in its comments. +// +// Such a package must never go into the bundle. Export data holds types, not comments, so a package +// whose meaning is written in annotations — strfmt declaring its formats with `swagger:strfmt`, say — +// comes back structurally intact and semantically empty. Nothing errors; the spec is just quietly +// poorer. The scan has to read those from source. +func annotated(p listed) bool { + for _, name := range p.GoFiles { + blob, err := os.ReadFile(filepath.Join(p.Dir, name)) //nolint:gosec // paths come from go list + if err != nil { + continue + } + if bytes.Contains(blob, []byte("swagger:")) { + return true + } + } + + return false +} + +func run(outDir string, patterns []string) (int, int64, error) { + pkgs, err := list(patterns) + if err != nil { + return 0, 0, err + } + + var count int + var total int64 + for _, p := range pkgs { + if p.Export == "" { + continue // no compiled form: a package with no Go files, or one that failed to build + } + if skipAnnotated(p) { + continue + } + written, err := extract(p.Export, filepath.Join(outDir, filepath.FromSlash(p.ImportPath)+".export")) + if err != nil { + return 0, 0, fmt.Errorf("%s: %w", p.ImportPath, err) + } + count++ + total += written + } + + return count, total, nil +} + +// skipAnnotated drops a package that carries annotations, saying so: a silent omission here would +// show up much later as a thinner spec with nothing to point at. +func skipAnnotated(p listed) bool { + if !annotated(p) { + return false + } + fmt.Fprintf(os.Stderr, "skipping %s: carries swagger annotations, which export data cannot hold — "+ + "it has to be read from source\n", p.ImportPath) + + return true +} + +// list asks the go command to build the patterns and report where it put each compiled archive. +func list(patterns []string) ([]listed, error) { + args := append([]string{"list", "-export", "-json=ImportPath,Export,Standard,Dir,GoFiles", "-deps"}, patterns...) + cmd := exec.Command("go", args...) //nolint:gosec // patterns come from the command line of a dev tool + cmd.Dir = moduleDir + + var stderr strings.Builder + cmd.Stderr = &stderr + stdout, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("go list: %w: %s", err, stderr.String()) + } + + var pkgs []listed + dec := json.NewDecoder(strings.NewReader(string(stdout))) + for dec.More() { + var p listed + if err := dec.Decode(&p); err != nil { + return nil, fmt.Errorf("decoding go list output: %w", err) + } + pkgs = append(pkgs, p) + } + + return pkgs, nil +} + +// extract copies just the export section out of a compiled archive. +// +// The archive also holds object code, which is the bulk of it and of no use here: the export section +// alone is around a twentieth of the size. +func extract(archive, dest string) (int64, error) { + if err := os.MkdirAll(filepath.Dir(dest), 0o750); err != nil { + return 0, err + } + w, err := os.Create(dest) //nolint:gosec // dest is derived from the -out flag + if err != nil { + return 0, err + } + defer func() { _ = w.Close() }() + + if err := copyExportSection(archive, w); err != nil { + return 0, err + } + + info, err := w.Stat() + if err != nil { + return 0, err + } + + return info.Size(), w.Close() +} + +// copyExportSection writes just the export section of a compiled archive to w. +// +// The archive also holds object code, which is the bulk of it and of no use here: the export section +// alone is around a twentieth of the size. +func copyExportSection(archive string, w io.Writer) error { + f, err := os.Open(archive) //nolint:gosec // path comes from go list + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + r, err := gcexportdata.NewReader(f) + if err != nil { + return err + } + + _, err = io.Copy(w, r) + + return err +} diff --git a/internal/exportdata/absent.go b/internal/exportdata/absent.go new file mode 100644 index 00000000..3b93f990 --- /dev/null +++ b/internal/exportdata/absent.go @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +//go:build !exportdata + +package exportdata + +import "io/fs" + +// Embedded reports that this build carries no export data. +func Embedded() (fs.FS, bool) { return nil, false } diff --git a/internal/exportdata/doc.go b/internal/exportdata/doc.go new file mode 100644 index 00000000..d6449983 --- /dev/null +++ b/internal/exportdata/doc.go @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package exportdata optionally carries dependencies' export data inside the binary. +// +// A scan that cannot reach GOROOT or a module cache — a WASI guest with only the project mounted, a +// browser — still needs its dependencies' types, and reading them from precomputed export data costs +// a fraction of type-checking them from source. Carrying that data in the binary is what makes such +// a build self-contained. +// +// It is opt-in, because it is several megabytes that most builds have no use for: +// +// go run ./hack/genexportdata -out internal/exportdata/exportdata.zip std github.com/go-openapi/... +// go build -tags exportdata ./cmd/genspec +// +// Without the tag, [Embedded] reports that nothing is embedded and the caller falls back to whatever +// the environment offers. The archive is valid only for the toolchain that generated it. +package exportdata diff --git a/internal/exportdata/embedded.go b/internal/exportdata/embedded.go new file mode 100644 index 00000000..70dba89c --- /dev/null +++ b/internal/exportdata/embedded.go @@ -0,0 +1,41 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +//go:build exportdata + +package exportdata + +import ( + "archive/zip" + "bytes" + _ "embed" + "io/fs" + "sync" +) + +//go:embed exportdata.zip +var archive []byte + +var ( + once sync.Once + reader *zip.Reader +) + +// Embedded returns the embedded export data. +// +// A zip is used rather than a directory of embedded files because archive/zip's reader already +// implements fs.FS: one file in the binary, still read one package at a time. +func Embedded() (fs.FS, bool) { + once.Do(func() { + r, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive))) + if err != nil { + return // a corrupt archive degrades to "nothing embedded" rather than killing the scan + } + reader = r + }) + if reader == nil { + return nil, false + } + + return reader, true +} diff --git a/internal/integration/virtual_fs_test.go b/internal/integration/virtual_fs_test.go new file mode 100644 index 00000000..ce4bdeba --- /dev/null +++ b/internal/integration/virtual_fs_test.go @@ -0,0 +1,221 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "sort" + "strings" + "testing" + "testing/fstest" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestScanVirtualFS scans a module that exists only in memory. +// +// Nothing here is on disk and no toolchain is invoked, which is the property the WASI build and the +// browser playground both depend on. +func TestScanVirtualFS(t *testing.T) { + t.Parallel() + + tree := fstest.MapFS{ + "go.mod": &fstest.MapFile{Data: []byte("module example.com/api\n\ngo 1.25.0\n")}, + "models/pet.go": &fstest.MapFile{Data: []byte(`package models + +// Pet describes an animal in the store. +// +// swagger:model pet +type Pet struct { + // The pet's identifier + // required: true + ID int64 ` + "`json:\"id\"`" + ` + + // The pet's name + // max length: 50 + Name string ` + "`json:\"name\"`" + ` +} +`)}, + } + + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./models"}, + WorkDir: ".", + ScanModels: true, + FS: tree, + // Pinned so the expectations describe the fixture, not the platform running the test — and, + // under a WASI guest, so the default does not silently become wasip1. + GOOS: "linux", + GOARCH: "amd64", + }) + require.NoError(t, err) + require.NotNil(t, doc) + + def, ok := doc.Definitions["pet"] + require.True(t, ok, "expected the swagger:model to be discovered, got %v", definitionNames(doc.Definitions)) + + assert.Equal(t, "Pet describes an animal in the store.", def.Title) + assert.Equal(t, []string{"id"}, def.Required) + + name, ok := def.Properties["name"] + require.True(t, ok) + require.NotNil(t, name.MaxLength, "field-level validations must survive the virtual load") + assert.Equal(t, int64(50), *name.MaxLength) +} + +// TestScanVirtualFSHonoursBuildTags checks that constraint resolution reaches a virtual tree: the +// annotated model lives in a file that only builds under a tag. +func TestScanVirtualFSHonoursBuildTags(t *testing.T) { + t.Parallel() + + tree := fstest.MapFS{ + "go.mod": &fstest.MapFile{Data: []byte("module example.com/api\n\ngo 1.25.0\n")}, + "models/base.go": &fstest.MapFile{Data: []byte( + "package models\n\n// swagger:model base\ntype Base struct {\n\tA string `json:\"a\"`\n}\n")}, + "models/extra.go": &fstest.MapFile{Data: []byte( + "//go:build extras\n\npackage models\n\n// swagger:model extra\ntype Extra struct {\n\tB string `json:\"b\"`\n}\n")}, + } + + scan := func(tags string) map[string]struct{} { + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./models"}, + WorkDir: ".", + ScanModels: true, + FS: tree, + BuildTags: tags, + GOOS: "linux", + GOARCH: "amd64", + }) + require.NoError(t, err) + + return keySet(doc.Definitions) + } + + withoutTag := scan("") + assert.Contains(t, withoutTag, "base") + assert.NotContains(t, withoutTag, "extra", "a tag-gated file must not be scanned without its tag") + + withTag := scan("extras") + assert.Contains(t, withTag, "base") + assert.Contains(t, withTag, "extra", "the tag must reach constraint matching inside the virtual FS") +} + +func keySet[V any](m map[string]V) map[string]struct{} { + out := make(map[string]struct{}, len(m)) + for k := range m { + out[k] = struct{}{} + } + + return out +} + +func definitionNames[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + + return out +} + +// TestScanVirtualFSStubbedStdlib scans without any standard library at all. +// +// The tree is in memory and StubStdlib withholds GOROOT, so this scan touches no filesystem +// whatsoever — the situation a browser or a WASI guest with nothing mounted is in. +func TestScanVirtualFSStubbedStdlib(t *testing.T) { + t.Parallel() + + tree := fstest.MapFS{ + "go.mod": &fstest.MapFile{Data: []byte("module example.com/api\n\ngo 1.25.0\n")}, + "models/event.go": &fstest.MapFile{Data: []byte(`package models + +import "time" + +// Event happened at some point. +// +// swagger:model event +type Event struct { + // When it happened + At time.Time ` + "`json:\"at\"`" + ` + + // What happened + What string ` + "`json:\"what\"`" + ` +} +`)}, + } + + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{"./models"}, + WorkDir: ".", + ScanModels: true, + FS: tree, + StubStdlib: true, + GOOS: "linux", + GOARCH: "amd64", + }) + require.NoError(t, err) + + def, ok := doc.Definitions["event"] + require.True(t, ok, "got %v", definitionNames(doc.Definitions)) + + // time.Time is recognised on identity — (package "time", type "Time") — which a synthesized type + // still carries, so the date-time format survives having no standard library to read. + at, ok := def.Properties["at"] + require.True(t, ok) + assert.Equal(t, "string", at.Type[0]) + assert.Equal(t, "date-time", at.Format) + + what, ok := def.Properties["what"] + require.True(t, ok) + assert.Equal(t, "string", what.Type[0]) +} + +// TestScanVirtualFSReportsSynthesizedImports checks that withholding the standard library is visible +// to the caller rather than silently thinning the spec. +func TestScanVirtualFSReportsSynthesizedImports(t *testing.T) { + t.Parallel() + + tree := fstest.MapFS{ + "go.mod": &fstest.MapFile{Data: []byte("module example.com/api\n\ngo 1.25.0\n")}, + "models/event.go": &fstest.MapFile{Data: []byte( + "package models\n\nimport (\n\t\"time\"\n\t\"example.com/gone/z\"\n)\n\n" + + "// swagger:model event\ntype Event struct {\n\tAt time.Time `json:\"at\"`\n\tZ z.Thing `json:\"z\"`\n}\n")}, + } + + var diags []codescan.Diagnostic + _, err := codescan.Run(&codescan.Options{ + Packages: []string{"./models"}, + WorkDir: ".", + ScanModels: true, + FS: tree, + StubStdlib: true, + GOOS: "linux", + GOARCH: "amd64", + OnDiagnostic: func(d codescan.Diagnostic) { diags = append(diags, d) }, + }) + require.NoError(t, err) + + bySeverity := map[string]codescan.Severity{} + for _, d := range diags { + if d.Code == "scan.synthesized-import" { + for _, p := range []string{"time", "example.com/gone/z"} { + if strings.Contains(d.Message, `"`+p+`"`) { + bySeverity[p] = d.Severity + } + } + } + } + + // Withholding the standard library was asked for, so it is a hint. The import that is merely + // absent is a warning: that one is usually a mounting or module-cache mistake. + sev, ok := bySeverity["time"] + require.True(t, ok, "no report for the withheld standard library; got %v", diags) + assert.Equal(t, codescan.SeverityHint, sev) + + sev, ok = bySeverity["example.com/gone/z"] + require.True(t, ok, "no report for the unresolvable import; got %v", diags) + assert.Equal(t, codescan.SeverityWarning, sev) +} diff --git a/internal/integration/wasi_imports_test.go b/internal/integration/wasi_imports_test.go new file mode 100644 index 00000000..ffd37b8a --- /dev/null +++ b/internal/integration/wasi_imports_test.go @@ -0,0 +1,246 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "encoding/binary" + "fmt" + "os" + "sort" + "testing" + + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// allowedWASIImports is everything the artifact may ask a host to provide. +// +// Every entry reads. That is not an accident of the current dependencies but a property worth +// keeping: it is what lets a host refuse writes outright, and it shrinks what a browser shim has to +// implement correctly before the scanner will run at all. +// +// The list was 24 until the `go list` loader was excluded from WebAssembly builds — path_create_directory, +// path_remove_directory and path_unlink_file arrived through the temporary files it needs, not through +// anything a scan does. Linking one package that writes would put them back silently. +var allowedWASIImports = map[string]string{ //nolint:gochecknoglobals // table for the guard below + "args_get": "argv", + "args_sizes_get": "argv", + "clock_time_get": "the runtime's timers", + "environ_get": "GOROOT, GOMODCACHE and the build target", + "environ_sizes_get": "as above", + "fd_close": "reading source", + "fd_fdstat_get": "reading source", + "fd_fdstat_set_flags": "reading source", + "fd_filestat_get": "reading source", + "fd_prestat_dir_name": "discovering what the host mounted", + "fd_prestat_get": "discovering what the host mounted", + "fd_pread": "reading an export-data archive at an offset, without seeking", + "fd_read": "reading source", + "fd_readdir": "walking a package directory", + "fd_write": "the specification, on stdout, and diagnostics on stderr", + "path_filestat_get": "resolving a package directory", + "path_open": "reading source", + "path_readlink": "resolving a symlinked directory", + "poll_oneoff": "the Go scheduler's timers", + "proc_exit": "termination", + "random_get": "map seeding", + "sched_yield": "the Go scheduler", +} + +// TestWASIArtifactImportsOnlyReads pins what the artifact demands of a host. +// +// It needs a Go toolchain to build the artifact but no WASI runtime to inspect it, so it is the one +// check in this file that can run anywhere. +func TestWASIArtifactImportsOnlyReads(t *testing.T) { + if testing.Short() { + t.Skip("cross-compiles the artifact") + } + t.Parallel() + + artifact := buildWASIArtifact(t) + + blob, err := os.ReadFile(artifact) //nolint:gosec // written by this test into its own temp dir + require.NoError(t, err) + + imports, err := wasmImports(blob) + require.NoError(t, err) + require.NotEmpty(t, imports, "no imports found; the parser is probably wrong, not the artifact") + + // A module may import the same function more than once; report each name once. + unexpectedSet := map[string]struct{}{} + for _, imp := range imports { + // A second import module would mean the artifact stopped being plain WASI — GOOS=js, for + // instance, needs its own `go` module and a JavaScript runtime to go with it. + assert.Equal(t, "wasi_snapshot_preview1", imp.module, + "import %q comes from %q: the artifact is no longer hostable by a plain WASI runtime", imp.name, imp.module) + + // Anything that is not a function is a shared resource — an imported memory in particular would + // mean SharedArrayBuffer, and with it the COOP/COEP headers that rule out plain static hosting. + assert.Equal(t, wasmImportFunc, imp.kind, + "import %q is not a function: the artifact now shares state with its host", imp.name) + + if _, ok := allowedWASIImports[imp.name]; !ok { + unexpectedSet[imp.name] = struct{}{} + } + } + + unexpected := make([]string, 0, len(unexpectedSet)) + for name := range unexpectedSet { + unexpected = append(unexpected, name) + } + sort.Strings(unexpected) + + assert.Empty(t, unexpected, + "the artifact asks its host for %v.\n"+ + "Something now linked into the WebAssembly build needs more than reading source. Either drop it from "+ + "the wasm build (see internal/scanner/load_wasm.go for how the go/packages loader was excluded), or "+ + "add it here with the reason, knowing every browser host must then implement it.", unexpected) +} + +const ( + wasmImportFunc byte = iota + wasmImportTable + wasmImportMemory + wasmImportGlobal +) + +type wasmImport struct { + module string + name string + kind byte +} + +// wasmImports decodes the import section of a WebAssembly module. +// +// Hand-rolled because the alternative is a dependency on a wasm toolchain that only this guard would +// use; the encoding is a handful of LEB128 integers and length-prefixed strings. +func wasmImports(blob []byte) ([]wasmImport, error) { + if len(blob) < 8 || string(blob[:4]) != "\x00asm" { + return nil, fmt.Errorf("not a WebAssembly module") + } + + r := &wasmReader{blob: blob, pos: 8} + for r.pos < len(blob) { + id, err := r.byte() + if err != nil { + return nil, err + } + size, err := r.uvarint() + if err != nil { + return nil, err + } + if id != 2 { // 2 is the import section + r.pos += int(size) + + continue + } + + return r.importSection() + } + + return nil, nil +} + +type wasmReader struct { + blob []byte + pos int +} + +func (r *wasmReader) byte() (byte, error) { + if r.pos >= len(r.blob) { + return 0, fmt.Errorf("truncated module at %d", r.pos) + } + b := r.blob[r.pos] + r.pos++ + + return b, nil +} + +func (r *wasmReader) uvarint() (uint64, error) { + v, n := binary.Uvarint(r.blob[r.pos:]) + if n <= 0 { + return 0, fmt.Errorf("bad integer at %d", r.pos) + } + r.pos += n + + return v, nil +} + +func (r *wasmReader) name() (string, error) { + n, err := r.uvarint() + if err != nil { + return "", err + } + if r.pos+int(n) > len(r.blob) { + return "", fmt.Errorf("truncated name at %d", r.pos) + } + s := string(r.blob[r.pos : r.pos+int(n)]) + r.pos += int(n) + + return s, nil +} + +// limits skips a limits record, which follows every table and memory import. +func (r *wasmReader) limits() error { + flag, err := r.byte() + if err != nil { + return err + } + if _, err := r.uvarint(); err != nil { // minimum + return err + } + if flag&0x01 == 0 { + return nil + } + _, err = r.uvarint() // maximum + + return err +} + +func (r *wasmReader) importSection() ([]wasmImport, error) { + count, err := r.uvarint() + if err != nil { + return nil, err + } + + imports := make([]wasmImport, 0, count) + for range count { + module, err := r.name() + if err != nil { + return nil, err + } + name, err := r.name() + if err != nil { + return nil, err + } + kind, err := r.byte() + if err != nil { + return nil, err + } + + switch kind { + case wasmImportFunc: + _, err = r.uvarint() // type index + case wasmImportTable: + if _, err = r.byte(); err == nil { // element type + err = r.limits() + } + case wasmImportMemory: + err = r.limits() + case wasmImportGlobal: + if _, err = r.byte(); err == nil { // value type + _, err = r.byte() // mutability + } + default: + err = fmt.Errorf("unknown import kind %d for %q", kind, name) + } + if err != nil { + return nil, err + } + + imports = append(imports, wasmImport{module: module, name: name, kind: kind}) + } + + return imports, nil +} diff --git a/internal/integration/wasi_test.go b/internal/integration/wasi_test.go new file mode 100644 index 00000000..03fa5ae6 --- /dev/null +++ b/internal/integration/wasi_test.go @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package integration_test + +import ( + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/go-openapi/codescan" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" +) + +// TestWASIArtifactMatchesNativeScan builds cmd/genspec for wasip1/wasm and runs it under a WASI +// runtime, with the fixture tree mounted into the guest. +// +// The guest has no process model, so nothing it does can reach the go command: whatever spec comes +// back was produced by parsing and type-checking alone. Comparing it against an in-process scan is +// what makes that a claim about correctness rather than about not crashing. +func TestWASIArtifactMatchesNativeScan(t *testing.T) { + if testing.Short() { + t.Skip("builds a wasm artifact and runs it under an interpreter; minutes, not seconds") + } + t.Parallel() + + runtimeName, runtimeArgs := findWASIRuntime(t) + artifact := buildWASIArtifact(t) + + const pattern = "./goparsing/petstore/..." + fixtures, err := filepath.Abs("../../fixtures") + require.NoError(t, err) + + // The guest resolves the standard library and the module cache by path, so it has to be told + // where they are: nothing in a WASI environment can ask the go command. + args := append(runtimeArgs, artifact, + "-quiet", "-loader=own", "-workdir", fixtures, + "-goos", "linux", "-goarch", "amd64", pattern) + + cmd := exec.Command(runtimeName, args...) //nolint:gosec // arguments are assembled above, not user input + cmd.Env = append(os.Environ(), "GOOS=linux", "GOARCH=amd64") + var stderr strings.Builder + cmd.Stderr = &stderr + + out, err := cmd.Output() + require.NoError(t, err, "running the artifact under %s failed: %s", runtimeName, stderr.String()) + + var fromGuest map[string]any + require.NoError(t, json.Unmarshal(out, &fromGuest)) + + // The control: the same scan, in this process, through the stock loader. + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{pattern}, + WorkDir: fixtures, + ScanModels: true, + GOOS: "linux", + GOARCH: "amd64", + }) + require.NoError(t, err) + + native, err := json.Marshal(doc) + require.NoError(t, err) + var fromHost map[string]any + require.NoError(t, json.Unmarshal(native, &fromHost)) + + assert.Equal(t, fromHost, fromGuest, + "the specification produced without a toolchain differs from the one produced with it") +} + +// findWASIRuntime returns a runtime able to execute a Go wasip1 binary, plus the arguments that mount +// the host filesystem and forward the environment. Mount syntax differs per runtime. +func findWASIRuntime(t *testing.T) (string, []string) { + t.Helper() + + forward := []string{"GOROOT", "GOMODCACHE", "GOPATH", "HOME"} + + if path, err := exec.LookPath("wazero"); err == nil { + args := []string{"run", "-mount=/:/"} + for _, k := range forward { + if v := os.Getenv(k); v != "" { + args = append(args, "-env", k+"="+v) + } + } + + return path, args + } + + if path, err := exec.LookPath("wasmtime"); err == nil { + args := []string{"run", "--dir", "/::/"} + for _, k := range forward { + if v := os.Getenv(k); v != "" { + args = append(args, "--env", k+"="+v) + } + } + + return path, append(args, "--") + } + + t.Skip("no WASI runtime on PATH (install wazero or wasmtime)") + + return "", nil +} + +// buildWASIArtifact cross-compiles cmd/genspec to wasip1/wasm and returns its path. +func buildWASIArtifact(t *testing.T, tags ...string) string { + t.Helper() + + goBin, err := exec.LookPath("go") + if err != nil { + t.Skip("no go command available to build the artifact") + } + + artifact := filepath.Join(t.TempDir(), "genspec.wasm") + buildArgs := []string{"build"} + if len(tags) > 0 { + buildArgs = append(buildArgs, "-tags", strings.Join(tags, ",")) + } + buildArgs = append(buildArgs, "-o", artifact, "github.com/go-openapi/codescan/cmd/genspec") + + cmd := exec.Command(goBin, buildArgs...) //nolint:gosec // arguments are assembled above + cmd.Env = append(os.Environ(), "GOOS=wasip1", "GOARCH=wasm") + + out, err := cmd.CombinedOutput() + require.NoError(t, err, "cross-compiling the artifact failed: %s", out) + + return artifact +} + +// TestWASIArtifactIsSelfContained runs a build that carries the standard library's export data +// inside it, with nothing mounted but the project. +// +// This is the shape a browser needs: no GOROOT to ship, no toolchain, and no host filesystem beyond +// the sources the user supplied. +func TestWASIArtifactIsSelfContained(t *testing.T) { + if testing.Short() { + t.Skip("builds a wasm artifact and runs it under an interpreter") + } + t.Parallel() + + // The embedded archive is generated, not committed: without it there is nothing to test. + if _, err := os.Stat(filepath.Join("..", "exportdata", "exportdata.zip")); err != nil { + t.Skip("no embedded export data (run: go run ./hack/genexportdata -out internal/exportdata/exportdata.zip std)") + } + + runtimeName, _ := findWASIRuntime(t) + artifact := buildWASIArtifact(t, "exportdata") + + fixtures, err := filepath.Abs("../../fixtures") + require.NoError(t, err) + + // Only the fixture tree is mounted, and no GOROOT is named. Anything the scan needs from the + // standard library has to come from inside the binary. + const pattern = "./enhancements/named-basic/..." + args := append(mountOnly(runtimeName, fixtures), artifact, + "-quiet", "-loader=own", "-workdir", fixtures, "-goos", "linux", "-goarch", "amd64", pattern) + + cmd := exec.Command(runtimeName, args...) //nolint:gosec // arguments are assembled above + var stderr strings.Builder + cmd.Stderr = &stderr + + out, err := cmd.Output() + require.NoError(t, err, "self-contained run failed: %s", stderr.String()) + + var fromGuest map[string]any + require.NoError(t, json.Unmarshal(out, &fromGuest)) + + doc, err := codescan.Run(&codescan.Options{ + Packages: []string{pattern}, + WorkDir: fixtures, + ScanModels: true, + GOOS: "linux", + GOARCH: "amd64", + }) + require.NoError(t, err) + + native, err := json.Marshal(doc) + require.NoError(t, err) + var fromHost map[string]any + require.NoError(t, json.Unmarshal(native, &fromHost)) + + assert.Equal(t, fromHost, fromGuest, + "a self-contained scan differs from one with the whole toolchain available") +} + +// mountOnly builds the runtime arguments that expose exactly one directory and nothing else. +func mountOnly(runtimeName, dir string) []string { + if strings.Contains(runtimeName, "wasmtime") { + return []string{"run", "--dir", dir + "::" + dir, "--"} + } + + return []string{"run", "-mount=" + dir + ":" + dir} +} diff --git a/internal/packages/TODO.md b/internal/packages/TODO.md new file mode 100644 index 00000000..72d861fe --- /dev/null +++ b/internal/packages/TODO.md @@ -0,0 +1,3 @@ +Fred review: + +* vfs => TODO swag/fs (in plan from fredbi/core/swag/fs) diff --git a/internal/packages/aliases.go b/internal/packages/aliases.go new file mode 100644 index 00000000..1c82d920 --- /dev/null +++ b/internal/packages/aliases.go @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package packages + +import "golang.org/x/tools/go/packages" + +// The public vocabulary is aliased, not redefined, so that a caller can move between this loader and +// packages.Load without touching anything but the call itself. +type ( + // Config mirrors packages.Config. See the package doc for which fields are honoured. + Config = packages.Config + + // Package mirrors packages.Package. Fields codescan does not consume are left unset. + Package = packages.Package + + // Error mirrors packages.Error. + Error = packages.Error + + // LoadMode mirrors packages.LoadMode. It is accepted for signature compatibility; this loader + // always produces syntax and type information, since that is the only mode codescan asks for. + LoadMode = packages.LoadMode +) + +// Error kinds, re-exported for callers that classify Errors. +const ( + ListError = packages.ListError + ParseError = packages.ParseError + TypeError = packages.TypeError +) diff --git a/internal/packages/doc.go b/internal/packages/doc.go new file mode 100644 index 00000000..e183ceb6 --- /dev/null +++ b/internal/packages/doc.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package packages loads and type-checks Go packages without a Go toolchain. +// +// It is a deliberately small stand-in for golang.org/x/tools/go/packages, covering only what codescan +// consumes. The motivation is that packages.Load shells out to `go list`, so it cannot run where there +// is no toolchain and no exec — a WASI guest being the case that forced the issue, and +// "whichever Go version happens to be installed" being the case that makes it worth having anyway. +// +// The entry point mirrors the original on purpose: +// +// upstream: packages.Load(cfg, patterns...) ([]*packages.Package, error) +// here: packages.NewLoader().Load(cfg, patterns...) ([]*packages.Package, error) +// +// Config, Package, Error and LoadMode are type aliases of the upstream types, so a caller can switch +// back to packages.Load by changing the call and nothing else. +// +// # What is not implemented +// +// The upstream Config fields Context, Logf, Fset, ParseFile, Tests and Overlay are accepted and +// ignored. Overlay in particular is not supported by design: it is documented as slow, it requires +// every source file to be held in memory, and it works against demand-driven parsing. Use +// [WithFS] instead, which virtualizes the filesystem one level lower. +// +// Dir, BuildFlags and Env are honoured: they select which files a package is built from. +// +// Package fields that codescan does not consume are left unset rather than hydrated. +// +// # Build constraints +// +// File selection goes through [go/build], which resolves //go:build expressions and GOOS/GOARCH +// filename suffixes in pure Go. The loader always sets GOOS and GOARCH explicitly from the scan +// target and never inherits [build.Default]: a loader running inside a WASI guest would otherwise +// inherit GOOS=wasip1 and silently drop every _linux.go file, producing a different spec than the +// same scan run natively. +package packages diff --git a/internal/packages/exportdata.go b/internal/packages/exportdata.go new file mode 100644 index 00000000..ed0ec243 --- /dev/null +++ b/internal/packages/exportdata.go @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package packages + +import ( + "bytes" + "fmt" + "go/types" + "io" + "io/fs" + "path" + + "golang.org/x/tools/go/gcexportdata" +) + +// Reading a package from the compiler's export data. +// +// Type-checking the standard library from source is what a full scan spends nearly all its time on: +// 190 packages and 1195 files for a fixture as small as the petstore, and a WebAssembly guest pays a +// five- to six-fold compute tax on top. None of that work is discovery — the answers were already +// computed when the toolchain built those packages, and the compiler wrote them down. +// +// So read them instead. The saving is in the parsing and type-checking avoided, not in the I/O: +// filesystem syscalls account for under 2% of a full WASI scan. + +// importExported returns a package read from export data, completing anything it refers to. +func (ld *loadState) importExported(importPath string) (*types.Package, error) { + if pkg, ok := ld.exported[importPath]; ok && pkg.Complete() { + return pkg, nil + } + if ld.exportInProgress[importPath] { + // Export data has no import cycles, but a corrupt or hand-made tree could; refuse rather than + // recurse forever. + return nil, fmt.Errorf("import cycle through %q in export data", importPath) + } + ld.exportInProgress[importPath] = true + defer delete(ld.exportInProgress, importPath) + + pkg, err := ld.readExported(importPath) + if err != nil { + return nil, err + } + + // Read leaves referenced packages as incomplete placeholders. That is fine until the checker looks + // inside one — a field whose type lives in another package, say — so complete them eagerly. The + // closure is only what this package actually refers to, and reading export data is cheap. + for _, dep := range pkg.Imports() { + if dep.Complete() || ld.exportInProgress[dep.Path()] { + continue + } + if _, err := ld.importExported(dep.Path()); err != nil { + // A missing dependency degrades that one package rather than failing the import: the + // referring package is usually still usable for what codescan asks of it. + continue + } + } + + return pkg, nil +} + +func (ld *loadState) readExported(importPath string) (*types.Package, error) { + blob, err := fs.ReadFile(ld.exportFS, path.Join(importPath)+".export") + if err != nil { + return nil, fmt.Errorf("no export data for %q: %w", importPath, err) + } + + // A generated tree holds bare export sections, which gcexportdata.Read takes directly. A whole + // compiled archive is accepted too, but has to have its section located first — NewReader only + // understands the archive form and rejects the bare one. + var in io.Reader = bytes.NewReader(blob) + if bytes.HasPrefix(blob, []byte("!")) { + if in, err = gcexportdata.NewReader(in); err != nil { + return nil, fmt.Errorf("locating export data for %q: %w", importPath, err) + } + } + + pkg, err := gcexportdata.Read(in, ld.fset, ld.exported, importPath) + if err != nil { + return nil, fmt.Errorf("decoding export data for %q: %w", importPath, err) + } + + return pkg, nil +} + +// hasExportData reports whether the configured tree can serve this import path. +func (ld *loadState) hasExportData(importPath string) bool { + if ld.exportFS == nil { + return false + } + if pkg, ok := ld.exported[importPath]; ok && pkg.Complete() { + return true + } + + f, err := ld.exportFS.Open(path.Join(importPath) + ".export") + if err != nil { + return false + } + _ = f.Close() + + return true +} diff --git a/internal/packages/loader.go b/internal/packages/loader.go new file mode 100644 index 00000000..2394e4ad --- /dev/null +++ b/internal/packages/loader.go @@ -0,0 +1,359 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package packages + +import ( + "fmt" + "go/ast" + "go/build" + "go/parser" + "go/token" + "go/types" + "io/fs" + "os" + "runtime" + "strings" +) + +// Loader loads and type-checks Go packages without invoking the Go toolchain. +// +// A Loader is single-use per Load call in the sense that it caches nothing across calls; it is safe +// to keep one around and call Load repeatedly. +type Loader struct { + opts *options + vfs *vfs +} + +// NewLoader returns a Loader reading through the real filesystem unless [WithFS] says otherwise. +func NewLoader(opts ...Option) *Loader { + o := newOptions(opts) + return &Loader{opts: o, vfs: &vfs{fsys: o.fsys}} +} + +// Load resolves patterns into type-checked packages. +// +// The signature mirrors packages.Load so the two are interchangeable at the call site. mode is +// accepted through cfg and ignored: this loader always produces syntax and type information. +func (l *Loader) Load(cfg *Config, patterns ...string) ([]*Package, error) { + if cfg == nil { + cfg = &Config{} + } + env := environment(cfg) + ctx := l.buildContext(cfg, env) + + res := &resolver{vfs: l.vfs, ctx: ctx, dir: cfg.Dir, env: env, stubStdlib: l.opts.stubStdlib} + if err := res.init(); err != nil { + return nil, err + } + + dirs, err := res.resolvePatterns(patterns) + if err != nil { + return nil, err + } + + ld := &loadState{ + vfs: l.vfs, + ctx: ctx, + res: res, + fset: token.NewFileSet(), + byPath: map[string]*Package{}, + inProg: map[string]bool{}, + stubs: map[string]*types.Package{}, + stubbable: map[string]bool{}, + reported: map[string]bool{}, + + onSynthesize: l.opts.onSynthesize, + stubStdlib: l.opts.stubStdlib, + + exportFS: l.opts.exportFS, + exported: map[string]*types.Package{}, + exportInProgress: map[string]bool{}, + } + + roots := make([]*Package, 0, len(dirs)) + for _, d := range dirs { + p, err := ld.loadDir(d.dir, d.pkgPath) + if err != nil { + return nil, err + } + if p != nil { + roots = append(roots, p) + } + } + return roots, nil +} + +// buildContext derives the go/build context that decides which files each package is built from. +// +// The build target is resolved in three tiers, weakest first: the platform the loader runs on, then +// GOOS/GOARCH in Config.Env (parity with packages.Load), then an explicit [WithTarget]. The tiering +// matters because the weakest tier is the one that lies: inside a WASI guest it says wasip1, and every +// _linux.go file would silently vanish from the spec. +func (l *Loader) buildContext(cfg *Config, env map[string]string) *build.Context { + ctx := build.Default + ctx.GOOS = runtime.GOOS + ctx.GOARCH = runtime.GOARCH + ctx.CgoEnabled = false // codescan never compiles; cgo files carry no annotations we can read + ctx.Dir = cfg.Dir + + for k, v := range env { + switch k { + case "GOOS": + ctx.GOOS = v + case "GOARCH": + ctx.GOARCH = v + case "GOROOT": + ctx.GOROOT = v + case "GOPATH": + ctx.GOPATH = v + } + } + if l.opts.goos != "" { + ctx.GOOS = l.opts.goos + } + if l.opts.goarch != "" { + ctx.GOARCH = l.opts.goarch + } + if tags := buildTags(cfg.BuildFlags); len(tags) > 0 { + ctx.BuildTags = append(ctx.BuildTags, tags...) + } + + // Route every read through the vfs so a virtualized tree is honoured by constraint matching too. + ctx.OpenFile = l.vfs.open + ctx.ReadDir = l.vfs.readDir + ctx.IsDir = l.vfs.isDir + ctx.JoinPath = l.vfs.join + ctx.IsAbsPath = l.vfs.isAbs + ctx.HasSubdir = l.vfs.hasSubdir + + return &ctx +} + +// buildTags extracts -tags values from BuildFlags, accepting the three spellings the go command does. +func buildTags(flags []string) []string { + var tags []string + for i := 0; i < len(flags); i++ { + f := flags[i] + switch { + case f == "-tags" && i+1 < len(flags): + i++ + tags = append(tags, splitTags(flags[i])...) + case strings.HasPrefix(f, "-tags="): + tags = append(tags, splitTags(strings.TrimPrefix(f, "-tags="))...) + } + } + return tags +} + +func splitTags(s string) []string { + var out []string + for _, t := range strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' }) { + if t != "" { + out = append(out, t) + } + } + return out +} + +// environment renders the effective environment for a load. +// +// A nil Config.Env means the current environment, as it does for packages.Load. It is not cosmetic: +// GOROOT and GOMODCACHE are how the resolver finds the standard library and the module cache, so an +// empty environment silently reduces every external import to an unresolved stub. +func environment(cfg *Config) map[string]string { + if cfg.Env == nil { + return envMap(os.Environ()) + } + + return envMap(cfg.Env) +} + +func envMap(env []string) map[string]string { + m := make(map[string]string, len(env)) + for _, e := range env { + if k, v, ok := strings.Cut(e, "="); ok { + m[k] = v + } + } + return m +} + +// loadState carries the per-Load caches. +type loadState struct { + vfs *vfs + ctx *build.Context + res *resolver + fset *token.FileSet + + byPath map[string]*Package + inProg map[string]bool + stubs map[string]*types.Package + + // stubbable memoizes whether an import path resolves, so the synthesis pre-walk does not stat the + // same paths once per importing file. + stubbable map[string]bool + + // reported tracks which synthesized paths have already been announced, so a package imported from + // fifty files is reported once. + reported map[string]bool + + onSynthesize func(Synthesized) + stubStdlib bool + + // exportFS serves pre-computed export data, keyed by import path with a ".export" suffix. nil + // when the caller supplied none, in which case every package is read from source. + exportFS fs.FS + exported map[string]*types.Package + exportInProgress map[string]bool +} + +// loadDir parses and type-checks the package rooted at dir. +func (ld *loadState) loadDir(dir, pkgPath string) (*Package, error) { + if p, ok := ld.byPath[pkgPath]; ok { + return p, nil + } + if ld.inProg[pkgPath] { + return nil, fmt.Errorf("import cycle through %q", pkgPath) + } + ld.inProg[pkgPath] = true + defer delete(ld.inProg, pkgPath) + + bp, err := ld.ctx.ImportDir(dir, 0) + if err != nil { + var nogo *build.NoGoError + if ok := asNoGoError(err, &nogo); ok { + return nil, nil // constraint-excluded directory: not an error, just not a package + } + return nil, fmt.Errorf("inspect %s: %w", dir, err) + } + + files := make([]string, 0, len(bp.GoFiles)) + for _, f := range bp.GoFiles { + files = append(files, ld.vfs.join(dir, f)) + } + + var errs []Error + syntax := make([]*ast.File, 0, len(files)) + for _, fn := range files { + src, err := ld.vfs.readFile(fn) + if err != nil { + errs = append(errs, Error{Pos: fn, Msg: err.Error(), Kind: ParseError}) + continue + } + // Comments are the payload for codescan, never skip them. + f, err := parser.ParseFile(ld.fset, fn, src, parser.ParseComments) + if f == nil { + errs = append(errs, Error{Pos: fn, Msg: err.Error(), Kind: ParseError}) + continue + } + if err != nil { + errs = append(errs, Error{Pos: fn, Msg: err.Error(), Kind: ParseError}) + } + syntax = append(syntax, f) + } + + pkg := &Package{ + ID: pkgPath, + Name: bp.Name, + PkgPath: pkgPath, + GoFiles: files, + CompiledGoFiles: files, + Syntax: syntax, + Fset: ld.fset, + Imports: map[string]*Package{}, + } + ld.byPath[pkgPath] = pkg + + // Fabricate whatever this package selects through imports that will not resolve, so the checker + // has something to bind those selectors to. + ld.synthesizeFrom(syntax) + + info := &types.Info{ + Types: map[ast.Expr]types.TypeAndValue{}, + Defs: map[*ast.Ident]types.Object{}, + Uses: map[*ast.Ident]types.Object{}, + Implicits: map[ast.Node]types.Object{}, + Selections: map[*ast.SelectorExpr]*types.Selection{}, + Scopes: map[ast.Node]*types.Scope{}, + Instances: map[*ast.Ident]types.Instance{}, + } + conf := types.Config{ + Importer: &importer{ld: ld, from: pkg}, + // Function bodies must be checked: an annotated type may be declared inside one (see the + // swagger:response in classification/operations/responses.go), and skipping bodies leaves no + // TypesInfo.Defs entry for it, so the scanner never sees it. + IgnoreFuncBodies: false, + DisableUnusedImportCheck: true, + Error: func(err error) { + errs = append(errs, Error{Msg: err.Error(), Kind: TypeError}) + }, + } + tpkg, _ := conf.Check(pkgPath, ld.fset, syntax, info) + + pkg.Types = tpkg + pkg.TypesInfo = info + pkg.Errors = errs + pkg.IllTyped = len(errs) > 0 + return pkg, nil +} + +func asNoGoError(err error, target **build.NoGoError) bool { + e, ok := err.(*build.NoGoError) + if ok { + *target = e + } + return ok +} + +// importer resolves the imports of one package. +type importer struct { + ld *loadState + from *Package +} + +func (i *importer) Import(importPath string) (*types.Package, error) { + if importPath == "unsafe" { + return types.Unsafe, nil + } + // Pre-computed types beat reading source: the same answer, none of the work. A gap in the tree + // falls through to source, and from there to synthesis. + // + // Dependencies only. The module under scan is always read from source, because its comments are + // the annotations and export data does not carry them. + if i.ld.exportFS != nil && !i.ld.res.inMainModule(importPath) { + if pkg, err := i.ld.importExported(importPath); err == nil { + return pkg, nil + } + } + + dir, pkgPath, ok := i.ld.res.resolveImport(importPath, i.from.PkgPath) + if ok { + dep, err := i.ld.loadDir(dir, pkgPath) + if err == nil && dep != nil && dep.Types != nil { + i.from.Imports[importPath] = dep + return dep.Types, nil + } + } + // Unresolvable: hand back an empty package rather than failing the whole check. The referencing + // type resolves to "invalid", which the builders report at the point of use — a far more useful + // diagnostic than aborting the scan. + return i.ld.stub(importPath), nil +} + +func (ld *loadState) stub(importPath string) *types.Package { + if p, ok := ld.stubs[importPath]; ok { + return p + } + p := types.NewPackage(importPath, pkgNameFor(importPath)) + p.MarkComplete() + ld.stubs[importPath] = p + return p +} + +func pkgNameFor(importPath string) string { + if i := strings.LastIndex(importPath, "/"); i >= 0 { + return importPath[i+1:] + } + return importPath +} diff --git a/internal/packages/loader_test.go b/internal/packages/loader_test.go new file mode 100644 index 00000000..b6795ab0 --- /dev/null +++ b/internal/packages/loader_test.go @@ -0,0 +1,394 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package packages_test + +import ( + "bytes" + "go/ast" + "go/parser" + "go/token" + "go/types" + "os" + "path/filepath" + "runtime" + "sort" + "strings" + "testing" + "testing/fstest" + + "github.com/go-openapi/codescan/internal/packages" + "github.com/go-openapi/testify/v2/assert" + "github.com/go-openapi/testify/v2/require" + "golang.org/x/tools/go/gcexportdata" +) + +// tagTree is a module whose files are selected by build constraints in every way go/build supports: +// a //go:build expression, its negation, a conjunction, and GOOS filename suffixes. +// +// It is an fstest.MapFS rather than testdata on disk so the test says something about WithFS at the +// same time — nothing here is reachable through the os package. +func tagTree() fstest.MapFS { + return fstest.MapFS{ + "go.mod": &fstest.MapFile{Data: []byte("module example.com/tagtree\n\ngo 1.25.0\n")}, + "pkg/always.go": &fstest.MapFile{Data: []byte( + "package pkg\n\ntype Always struct{ A string }\n")}, + "pkg/tagged.go": &fstest.MapFile{Data: []byte( + "//go:build integration\n\npackage pkg\n\ntype Tagged struct{ B string }\n")}, + "pkg/negated.go": &fstest.MapFile{Data: []byte( + "//go:build !integration\n\npackage pkg\n\ntype Negated struct{ C string }\n")}, + "pkg/combo.go": &fstest.MapFile{Data: []byte( + "//go:build integration && custom\n\npackage pkg\n\ntype Combo struct{ D string }\n")}, + "pkg/impl_linux.go": &fstest.MapFile{Data: []byte( + "package pkg\n\ntype OnLinux struct{ E string }\n")}, + "pkg/impl_windows.go": &fstest.MapFile{Data: []byte( + "package pkg\n\ntype OnWindows struct{ F string }\n")}, + } +} + +// loadedFiles returns the base names of the files the loader decided the package is made of. +func loadedFiles(t *testing.T, loader *packages.Loader, cfg *packages.Config, pattern string) []string { + t.Helper() + + pkgs, err := loader.Load(cfg, pattern) + require.NoError(t, err) + require.Len(t, pkgs, 1) + + names := make([]string, 0, len(pkgs[0].GoFiles)) + for _, f := range pkgs[0].GoFiles { + names = append(names, filepath.Base(f)) + } + sort.Strings(names) + + return names +} + +func TestLoadBuildTags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flags []string + want []string + }{ + { + name: "no tags: the negation is in, the tagged files are out", + want: []string{"always.go", "impl_linux.go", "negated.go"}, + }, + { + name: "-tags integration flips the pair, conjunction still unmet", + flags: []string{"-tags", "integration"}, + want: []string{"always.go", "impl_linux.go", "tagged.go"}, + }, + { + name: "an unrelated tag changes nothing", + flags: []string{"-tags", "custom"}, + want: []string{"always.go", "impl_linux.go", "negated.go"}, + }, + { + name: "both tags satisfy the conjunction", + flags: []string{"-tags", "integration,custom"}, + want: []string{"always.go", "combo.go", "impl_linux.go", "tagged.go"}, + }, + { + name: "-tags=x= spelling is accepted too", + flags: []string{"-tags=integration"}, + want: []string{"always.go", "impl_linux.go", "tagged.go"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Pinned to linux/amd64 so the expectations describe the build constraints under test and + // not the platform the test happens to run on. + loader := packages.NewLoader(packages.WithFS(tagTree()), packages.WithTarget("linux", "amd64")) + got := loadedFiles(t, loader, &packages.Config{BuildFlags: tt.flags}, "./pkg") + + assert.Equal(t, tt.want, got) + }) + } +} + +func TestLoadTarget(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + goos string + goarch string + want []string + }{ + { + name: "linux picks the linux file", + goos: "linux", goarch: "amd64", + want: []string{"always.go", "impl_linux.go", "negated.go"}, + }, + { + name: "windows picks the windows file", + goos: "windows", goarch: "amd64", + want: []string{"always.go", "impl_windows.go", "negated.go"}, + }, + { + // The case the option exists for: a platform that matches no suffix in the tree drops both + // implementation files. Running inside a WASI guest, this is what the default would do. + name: "a platform with no matching file drops both", + goos: "wasip1", goarch: "wasm", + want: []string{"always.go", "negated.go"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + loader := packages.NewLoader(packages.WithFS(tagTree()), packages.WithTarget(tt.goos, tt.goarch)) + got := loadedFiles(t, loader, &packages.Config{}, "./pkg") + + assert.Equal(t, tt.want, got) + }) + } +} + +func TestLoadTargetPrecedence(t *testing.T) { + t.Parallel() + + t.Run("defaults to the running platform", func(t *testing.T) { + t.Parallel() + + loader := packages.NewLoader(packages.WithFS(tagTree())) + got := loadedFiles(t, loader, &packages.Config{}, "./pkg") + + // Whatever the host is, the file for the *other* platform must not be in. + if runtime.GOOS == "windows" { + assert.Contains(t, got, "impl_windows.go") + assert.NotContains(t, got, "impl_linux.go") + } else { + assert.NotContains(t, got, "impl_windows.go") + } + }) + + t.Run("Config.Env overrides the running platform", func(t *testing.T) { + t.Parallel() + + loader := packages.NewLoader(packages.WithFS(tagTree())) + got := loadedFiles(t, loader, &packages.Config{Env: []string{"GOOS=windows", "GOARCH=amd64"}}, "./pkg") + + assert.Contains(t, got, "impl_windows.go") + assert.NotContains(t, got, "impl_linux.go") + }) + + t.Run("WithTarget wins over Config.Env", func(t *testing.T) { + t.Parallel() + + loader := packages.NewLoader(packages.WithFS(tagTree()), packages.WithTarget("linux", "amd64")) + got := loadedFiles(t, loader, &packages.Config{Env: []string{"GOOS=windows", "GOARCH=amd64"}}, "./pkg") + + assert.Contains(t, got, "impl_linux.go") + assert.NotContains(t, got, "impl_windows.go") + }) + + t.Run("a half-specified target leaves the other half alone", func(t *testing.T) { + t.Parallel() + + loader := packages.NewLoader(packages.WithFS(tagTree()), packages.WithTarget("windows", "")) + got := loadedFiles(t, loader, &packages.Config{}, "./pkg") + + assert.Contains(t, got, "impl_windows.go") + }) +} + +func TestLoadRecursivePattern(t *testing.T) { + t.Parallel() + + tree := tagTree() + tree["pkg/sub/sub.go"] = &fstest.MapFile{Data: []byte("package sub\n\ntype Sub struct{ A string }\n")} + tree["pkg/testdata/skipped.go"] = &fstest.MapFile{Data: []byte("package testdata\n\ntype Skipped struct{}\n")} + + loader := packages.NewLoader(packages.WithFS(tree), packages.WithTarget("linux", "amd64")) + pkgs, err := loader.Load(&packages.Config{}, "./...") + require.NoError(t, err) + + paths := make([]string, 0, len(pkgs)) + for _, p := range pkgs { + paths = append(paths, p.PkgPath) + } + sort.Strings(paths) + + // testdata is not a package as far as the go tool is concerned, and must not be one here either. + assert.Equal(t, []string{"example.com/tagtree/pkg", "example.com/tagtree/pkg/sub"}, paths) +} + +func TestLoadResolvesIntraModuleImports(t *testing.T) { + t.Parallel() + + tree := fstest.MapFS{ + "go.mod": &fstest.MapFile{Data: []byte("module example.com/m\n\ngo 1.25.0\n")}, + "a/a.go": &fstest.MapFile{Data: []byte( + "package a\n\nimport \"example.com/m/b\"\n\ntype A struct{ B b.B }\n")}, + "b/b.go": &fstest.MapFile{Data: []byte("package b\n\ntype B struct{ Name string }\n")}, + } + + loader := packages.NewLoader(packages.WithFS(tree), packages.WithTarget("linux", "amd64")) + pkgs, err := loader.Load(&packages.Config{}, "./a") + require.NoError(t, err) + require.Len(t, pkgs, 1) + + pkg := pkgs[0] + require.NotNil(t, pkg.Types) + assert.Empty(t, pkg.Errors, "the import should resolve from source, leaving no type errors") + + // The field's type must be the real b.B, not a stub: a stub has no fields. + obj := pkg.Types.Scope().Lookup("A") + require.NotNil(t, obj) + assert.Contains(t, obj.Type().Underlying().String(), "example.com/m/b.B") +} + +// TestLoadChecksFunctionBodies guards a regression that the fixture corpus caught: codescan discovers +// annotated types declared inside function bodies, so the type-checker must not skip them. +func TestLoadChecksFunctionBodies(t *testing.T) { + t.Parallel() + + tree := fstest.MapFS{ + "go.mod": &fstest.MapFile{Data: []byte("module example.com/m\n\ngo 1.25.0\n")}, + "p/p.go": &fstest.MapFile{Data: []byte( + "package p\n\nfunc Outer() {\n\t// swagger:response inner\n\ttype Inner struct{ A string }\n\t_ = Inner{}\n}\n")}, + } + + loader := packages.NewLoader(packages.WithFS(tree), packages.WithTarget("linux", "amd64")) + pkgs, err := loader.Load(&packages.Config{}, "./p") + require.NoError(t, err) + require.Len(t, pkgs, 1) + + var found bool + for ident, obj := range pkgs[0].TypesInfo.Defs { + if ident.Name == "Inner" && obj != nil { + found = true + } + } + assert.True(t, found, "a type declared inside a function body must appear in TypesInfo.Defs") +} + +func TestLoadReportsSynthesizedImports(t *testing.T) { + t.Parallel() + + tree := fstest.MapFS{ + "go.mod": &fstest.MapFile{Data: []byte("module example.com/m\n\ngo 1.25.0\n")}, + "p/p.go": &fstest.MapFile{Data: []byte( + "package p\n\nimport (\n\t\"time\"\n\t\"example.com/nowhere/x\"\n)\n\n" + + "type T struct {\n\tA time.Time\n\tB x.Thing\n}\n")}, + } + + var got []packages.Synthesized + loader := packages.NewLoader( + packages.WithFS(tree), + packages.WithTarget("linux", "amd64"), + packages.WithStubbedStdlib(), + packages.WithOnSynthesized(func(s packages.Synthesized) { got = append(got, s) }), + ) + + _, err := loader.Load(&packages.Config{}, "./p") + require.NoError(t, err) + + byPath := make(map[string]packages.Synthesized, len(got)) + for _, s := range got { + byPath[s.Path] = s + } + + // The standard library was withheld on purpose; the other import simply does not exist. The + // caller needs to tell those apart, because only one of them is a mistake. + stdlib, ok := byPath["time"] + require.True(t, ok, "expected a report for the withheld standard library, got %v", byPath) + assert.True(t, stdlib.Deliberate) + + missing, ok := byPath["example.com/nowhere/x"] + require.True(t, ok, "expected a report for the unresolvable import, got %v", byPath) + assert.False(t, missing.Deliberate) + + assert.Equal(t, "p/p.go", missing.Pos.Filename, "the report should point at the import that caused it") + assert.Positive(t, missing.Pos.Line) +} + +// TestLoadKeepsRootedPathsUnderRecursivePatterns guards the boundary between the caller's paths and +// io/fs's own rooted namespace: a walk must hand back paths in the form it was given, or every +// token.Position the scan reports comes out unresolvable. +func TestLoadKeepsRootedPathsUnderRecursivePatterns(t *testing.T) { + t.Parallel() + + root := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(root, "go.mod"), + []byte("module example.com/m\n\ngo 1.25.0\n"), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(root, "sub"), 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(root, "sub", "s.go"), + []byte("package sub\n\ntype S struct{ A string }\n"), 0o600)) + + loader := packages.NewLoader(packages.WithFS(os.DirFS("/")), packages.WithTarget("linux", "amd64")) + pkgs, err := loader.Load(&packages.Config{Dir: root}, "./...") + require.NoError(t, err) + require.NotEmpty(t, pkgs) + + for _, p := range pkgs { + for _, f := range p.GoFiles { + assert.True(t, strings.HasPrefix(f, "/"), + "file %q lost its root; positions derived from it would not resolve", f) + } + } +} + +// TestLoadReadsFromExportData feeds the loader export data it generated itself, so the test needs no +// toolchain and no GOROOT. +// +// The package stands in for any dependency: export data applies to everything outside the module +// being scanned. +func TestLoadReadsFromExportData(t *testing.T) { + t.Parallel() + + // Type-check a stand-in for a standard-library package, then write it out the way the compiler + // would have. + fset := token.NewFileSet() + src := "package faketime\n\ntype Moment struct{ Seconds int64 }\n\nfunc (Moment) Marshal() string { return \"\" }\n" + f, err := parser.ParseFile(fset, "faketime.go", src, 0) + require.NoError(t, err) + + conf := types.Config{Importer: nil} + pkg, err := conf.Check("faketime", fset, []*ast.File{f}, nil) + require.NoError(t, err) + + var blob bytes.Buffer + require.NoError(t, gcexportdata.Write(&blob, fset, pkg)) + + exports := fstest.MapFS{"faketime.export": &fstest.MapFile{Data: blob.Bytes()}} + tree := fstest.MapFS{ + "go.mod": &fstest.MapFile{Data: []byte("module example.com/m\n\ngo 1.25.0\n")}, + "p/p.go": &fstest.MapFile{Data: []byte( + "package p\n\nimport \"faketime\"\n\ntype T struct{ M faketime.Moment }\n")}, + } + + var synthesized []string + loader := packages.NewLoader( + packages.WithFS(tree), + packages.WithTarget("linux", "amd64"), + packages.WithExportData(exports), + packages.WithOnSynthesized(func(s packages.Synthesized) { synthesized = append(synthesized, s.Path) }), + ) + + pkgs, err := loader.Load(&packages.Config{}, "./p") + require.NoError(t, err) + require.Len(t, pkgs, 1) + + assert.NotContains(t, synthesized, "faketime", + "a package served from export data must not be synthesized") + + obj := pkgs[0].Types.Scope().Lookup("T") + require.NotNil(t, obj) + + // The decisive check: the imported type carries real structure. A synthesized stand-in would have + // no fields and no methods, which is exactly the fidelity export data exists to restore. + field := obj.Type().Underlying().(*types.Struct).Field(0) + named, ok := field.Type().(*types.Named) + require.True(t, ok, "expected a named type, got %T", field.Type()) + assert.Equal(t, "faketime.Moment", named.String()) + assert.Equal(t, 1, named.Underlying().(*types.Struct).NumFields(), "fields should survive") + assert.Equal(t, 1, named.NumMethods(), "the method set should survive") +} diff --git a/internal/packages/options.go b/internal/packages/options.go new file mode 100644 index 00000000..ac8d0b04 --- /dev/null +++ b/internal/packages/options.go @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package packages + +import ( + "go/token" + "io/fs" +) + +// Option configures a [Loader]. +type Option func(*options) + +type options struct { + fsys fs.FS // nil: read through the real filesystem + goos string + goarch string + stubStdlib bool + onSynthesize func(Synthesized) + exportFS fs.FS +} + +// WithExportData serves dependencies from pre-computed export data instead of reading their source. +// +// fsys holds one file per package, named by import path with a ".export" suffix — the layout +// hack/genexportdata produces. Whole compiled archives are accepted as well as bare export sections. +// +// It applies to dependencies only. The module under scan is always read from source: its comments are +// the annotations, and export data carries none. +// +// This is the fast path with none of the fidelity loss of [WithStubbedStdlib]: the types are the ones +// the compiler computed, so fields, method sets and interface identity are all real. A full scan +// otherwise spends nearly all of its time parsing and type-checking its dependencies, and a +// WebAssembly guest pays a five- to six-fold compute tax on top of that. +// +// The data is only valid for the toolchain that produced it, since the export format is tied to the +// Go release. A package the tree does not cover falls back to source, and then to synthesis. +func WithExportData(fsys fs.FS) Option { + return func(o *options) { o.exportFS = fsys } +} + +// Synthesized reports an import whose types were fabricated rather than loaded. +type Synthesized struct { + // Path is the import path. + Path string + + // Pos is the import that triggered the synthesis — the first one seen for this path. + Pos token.Position + + // Deliberate distinguishes the standard library withheld by [WithStubbedStdlib] from an import + // that simply could not be found. The first is the caller's own choice; the second is usually a + // mounting or module-cache problem. + Deliberate bool +} + +// WithOnSynthesized registers a callback fired once per import path that had to be synthesized. +// +// Without it, the loss is invisible: a package that only mentions a synthesized type in a field +// position type-checks cleanly and simply produces a thinner spec. What surfaces otherwise is the +// downstream wreckage — a value-position use of a fabricated type reads as an error in the scanned +// code rather than as a missing dependency. +func WithOnSynthesized(fn func(Synthesized)) Option { + return func(o *options) { o.onSynthesize = fn } +} + +// WithStubbedStdlib keeps the standard library out of the package graph. +// +// Standard-library imports are then synthesized from the names selected through them — opaque types +// carrying the right package path and name — instead of being parsed and type-checked out of GOROOT. +// +// This trades fidelity for reach. What survives is everything keyed on a type's identity: codescan +// recognizes time.Time, json.RawMessage and friends by (package, name), never by shape. What is lost +// is everything structural: a synthesized type has no fields to drill into and no method set, so a +// spec that renders json.RawMessage as []byte, or that depends on a type implementing +// encoding.TextMarshaler, will come out different. +// +// The reach it buys is real: GOROOT no longer has to exist, which for a WASI guest or a browser means +// there is no standard-library source tree to ship or mount. +// +// # Not failsafe +// +// This mode trades correctness guarantees for reach, and the trade is not always visible in the +// output. What it buys: a small footprint, no Go installation, and no module cache to populate — the +// scan reads only the project tree. What it costs is that a synthesized type has no structure, so a +// spec can come out subtly thinner rather than failing loudly. Across codescan's own fixture corpus +// 133 of 138 scans are byte-identical; the rest lose a byte-array rendering, an integer format, or a +// TextMarshaler-derived string, and stdlib interfaces such as io.Reader have no identity recognizer +// to fall back on at all. +// +// Note that synthesis is not exclusive to this option: an import that cannot be resolved is +// synthesized whether or not the standard library was withheld. The option only makes it deliberate +// for the one dependency every Go program has. +// +// Prefer a full graph wherever GOROOT is available; reach for this where it is not. +func WithStubbedStdlib() Option { + return func(o *options) { o.stubStdlib = true } +} + +// WithTarget sets the GOOS/GOARCH the scanned code is built for. +// +// This selects which files each package is made of: //go:build lines and _linux.go / _amd64.go style +// filename suffixes are all resolved against it. It therefore changes the emitted spec, not just the +// speed of getting there. +// +// The default is the platform the loader itself is running on, which matches what `go list` would do +// and is right for an ordinary native scan. It is wrong wherever the loader's own platform is an +// artefact of how codescan was deployed rather than a statement about the code under scan — a WASI +// guest is the clear case, where the default would be wasip1 and every _linux.go file would silently +// disappear from the spec. Set it explicitly there. +// +// An empty goos or goarch leaves that half at its default. GOOS/GOARCH in Config.Env are honoured +// too, for parity with packages.Load; an explicit WithTarget wins over both. +func WithTarget(goos, goarch string) Option { + return func(o *options) { + if goos != "" { + o.goos = goos + } + if goarch != "" { + o.goarch = goarch + } + } +} + +// WithFS makes the loader read source through fsys instead of the real filesystem. +// +// Every path the loader is given — Config.Dir, the patterns, and the paths it derives from them — is +// then interpreted relative to the root of fsys, following [io/fs] conventions: slash-separated, no +// leading slash, no "..". A leading slash or an OS-specific separator is normalised away rather than +// rejected, so a caller can pass the same patterns it would use natively. +// +// This is the seam that makes a virtualized source tree possible: an in-memory tree in a WASI guest, +// a [testing/fstest.MapFS] in a unit test, an archive reader, or an overlay composed from several +// roots. The default (no WithFS) reads through the os package. +func WithFS(fsys fs.FS) Option { + return func(o *options) { o.fsys = fsys } +} + +func newOptions(opts []Option) *options { + o := &options{} + for _, apply := range opts { + apply(o) + } + return o +} diff --git a/internal/packages/resolve.go b/internal/packages/resolve.go new file mode 100644 index 00000000..5614eb79 --- /dev/null +++ b/internal/packages/resolve.go @@ -0,0 +1,307 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package packages + +import ( + "fmt" + "go/build" + "path" + "sort" + "strings" + + "golang.org/x/mod/modfile" + "golang.org/x/mod/module" +) + +// resolver turns patterns and import paths into directories. +// +// This is the half of `go list` we have to own: mapping "./..." or "example.com/x/y" onto a place on +// the filesystem. It is deliberately narrow — main module, its vendor directory, and GOROOT — because +// those are the trees whose layout is knowable without running the go command. +type resolver struct { + vfs *vfs + ctx *build.Context + dir string + env map[string]string + + // stubStdlib withholds the standard library from resolution: its imports are synthesized instead. + stubStdlib bool + + modRoot string // directory holding the main module's go.mod ("" if none found) + modPath string // the main module's path + vendor string // /vendor if it exists, else "" + srcRoot string // GOROOT/src + + // modDirs maps a required module path to the directory holding its source: the module cache for + // an ordinary requirement, an arbitrary directory for a `replace` target. + // + // Since Go 1.17 the main module's go.mod lists every relevant dependency, direct and indirect, so + // reading it is enough to place imports without walking the whole module graph. + modDirs map[string]string +} + +type target struct { + dir string + pkgPath string +} + +func (r *resolver) init() error { + if r.dir == "" { + r.dir = "." + } + r.srcRoot = r.vfs.join(r.ctx.GOROOT, "src") + r.findModule() + return nil +} + +// findModule walks up from Dir looking for go.mod. Without it, import paths inside the tree under +// scan cannot be mapped to directories at all. +func (r *resolver) findModule() { + dir := r.dir + for range 64 { // bounded: a filesystem loop must not hang the loader + gomod := r.vfs.join(dir, "go.mod") + if blob, err := r.vfs.readFile(gomod); err == nil { + if mp := modfile.ModulePath(blob); mp != "" { + r.modRoot, r.modPath = dir, mp + if v := r.vfs.join(dir, "vendor"); r.vfs.isDir(v) { + r.vendor = v + } + r.readRequirements(gomod, blob) + return + } + } + parent := r.vfs.join(dir, "..") + if parent == dir || dir == "." || dir == "/" { + return + } + dir = parent + } +} + +// readRequirements places every module the main go.mod names, so imports into them resolve. +// +// A vendored tree needs none of this; it matters for the ordinary case where dependencies live in the +// module cache. +func (r *resolver) readRequirements(gomod string, blob []byte) { + f, err := modfile.Parse(gomod, blob, nil) + if err != nil { + return // an unparseable go.mod still gave us the module path; degrade rather than fail + } + r.modDirs = make(map[string]string, len(f.Require)) + + cache := r.moduleCache() + if cache != "" { + for _, req := range f.Require { + if esc, err := module.EscapePath(req.Mod.Path); err == nil { + r.modDirs[req.Mod.Path] = r.vfs.join(cache, esc+"@"+req.Mod.Version) + } + } + } + // `replace` wins over the cache, and a directory target may sit anywhere. + for _, rep := range f.Replace { + switch { + case rep.New.Version == "": // filesystem replacement + dir := rep.New.Path + if !r.vfs.isAbs(dir) { + dir = r.vfs.join(r.modRoot, dir) + } + r.modDirs[rep.Old.Path] = dir + case cache != "": + if esc, err := module.EscapePath(rep.New.Path); err == nil { + r.modDirs[rep.Old.Path] = r.vfs.join(cache, esc+"@"+rep.New.Version) + } + } + } +} + +// moduleCache locates the module cache from the environment, falling back to GOPATH/pkg/mod. +func (r *resolver) moduleCache() string { + if c := r.env["GOMODCACHE"]; c != "" { + return c + } + gopath := r.env["GOPATH"] + if gopath == "" { + gopath = r.ctx.GOPATH + } + if gopath == "" { + return "" + } + // GOPATH may be a list; the module cache lives under the first entry. + if i := strings.IndexAny(gopath, ":;"); i >= 0 { + gopath = gopath[:i] + } + return r.vfs.join(gopath, "pkg", "mod") +} + +// resolvePatterns expands the caller's patterns into concrete package directories. +// +// Supported: "./dir", "./dir/...", "dir", "all"-free import paths, and bare "...". Anything the go +// command supports beyond that (query syntax, "std", test patterns) is out of scope. +func (r *resolver) resolvePatterns(patterns []string) ([]target, error) { + if len(patterns) == 0 { + patterns = []string{"."} + } + seen := map[string]bool{} + var out []target + + for _, pat := range patterns { + recursive := strings.HasSuffix(pat, "...") + base := strings.TrimSuffix(strings.TrimSuffix(pat, "..."), "/") + if base == "" { + base = "." + } + + dir, pkgPath, ok := r.locate(base) + if !ok { + return nil, fmt.Errorf("cannot resolve pattern %q relative to %q", pat, r.dir) + } + + if !recursive { + if !seen[dir] && r.hasGoFiles(dir) { + seen[dir] = true + out = append(out, target{dir: dir, pkgPath: pkgPath}) + } + continue + } + + err := r.vfs.walkDirs(dir, func(d string) error { + if seen[d] || !r.hasGoFiles(d) { + return nil + } + seen[d] = true + rel, _ := r.vfs.hasSubdir(dir, d) + out = append(out, target{dir: d, pkgPath: joinPkgPath(pkgPath, rel)}) + return nil + }) + if err != nil { + return nil, err + } + } + sort.Slice(out, func(i, j int) bool { return out[i].pkgPath < out[j].pkgPath }) + return out, nil +} + +// locate maps one non-recursive pattern onto (dir, import path). +func (r *resolver) locate(pat string) (dir, pkgPath string, ok bool) { + if pat == "." || strings.HasPrefix(pat, "./") || strings.HasPrefix(pat, "../") || r.vfs.isAbs(pat) { + dir = r.vfs.join(r.dir, strings.TrimPrefix(pat, "./")) + if r.vfs.isAbs(pat) { + dir = pat + } + if !r.vfs.isDir(dir) { + return "", "", false + } + return dir, r.pkgPathFor(dir), true + } + // A bare import path. + if d, p, found := r.resolveImport(pat, ""); found { + return d, p, true + } + // Fall back to treating it as a directory relative to Dir. + dir = r.vfs.join(r.dir, pat) + if r.vfs.isDir(dir) { + return dir, r.pkgPathFor(dir), true + } + return "", "", false +} + +// pkgPathFor derives an import path for a directory inside the main module. +func (r *resolver) pkgPathFor(dir string) string { + if r.modRoot == "" { + return dir // no module: the directory is the best identity available + } + rel, ok := r.vfs.hasSubdir(r.modRoot, dir) + if !ok { + return dir + } + return joinPkgPath(r.modPath, rel) +} + +// resolveImport maps an import path onto a directory. +func (r *resolver) resolveImport(importPath, _ string) (dir, pkgPath string, ok bool) { + // Standard library: GOROOT/src is laid out by import path. + if !strings.Contains(firstSegment(importPath), ".") { + if r.stubStdlib { + return "", "", false + } + d := r.vfs.join(r.srcRoot, importPath) + if r.vfs.isDir(d) { + return d, importPath, true + } + } + // Inside the main module. + if r.modPath != "" && (importPath == r.modPath || strings.HasPrefix(importPath, r.modPath+"/")) { + rel := strings.TrimPrefix(strings.TrimPrefix(importPath, r.modPath), "/") + d := r.vfs.join(r.modRoot, rel) + if r.vfs.isDir(d) { + return d, importPath, true + } + } + // A required module, placed from go.mod. + for modPath, modDir := range r.modDirs { + if importPath != modPath && !strings.HasPrefix(importPath, modPath+"/") { + continue + } + rel := strings.TrimPrefix(strings.TrimPrefix(importPath, modPath), "/") + if d := r.vfs.join(modDir, rel); r.vfs.isDir(d) { + return d, importPath, true + } + } + // Vendored dependency. + if r.vendor != "" { + d := r.vfs.join(r.vendor, importPath) + if r.vfs.isDir(d) { + return d, importPath, true + } + } + return "", "", false +} + +func (r *resolver) hasGoFiles(dir string) bool { + infos, err := r.vfs.readDir(dir) + if err != nil { + return false + } + for _, i := range infos { + if !i.IsDir() && strings.HasSuffix(i.Name(), ".go") { + return true + } + } + return false +} + +func joinPkgPath(base, rel string) string { + if rel == "" || rel == "." { + return base + } + if base == "" { + return rel + } + return path.Join(base, rel) +} + +// inMainModule reports whether an import path names a package of the module being scanned. +// +// It decides where a package's types may come from. The code under scan must always be read from +// source: its comments are the annotations, and export data carries none. Everything else is a +// dependency, whose types are all that is wanted from it. +func (r *resolver) inMainModule(importPath string) bool { + if r.modPath == "" { + return false + } + + return importPath == r.modPath || strings.HasPrefix(importPath, r.modPath+"/") +} + +// isStdlibPath reports whether an import path names a standard-library package: its first segment +// carries no dot, so it can never be a module path. +func isStdlibPath(importPath string) bool { + return !strings.Contains(firstSegment(importPath), ".") +} + +func firstSegment(p string) string { + before, _, _ := strings.Cut(p, "/") + + return before +} diff --git a/internal/packages/synthesize.go b/internal/packages/synthesize.go new file mode 100644 index 00000000..d2151936 --- /dev/null +++ b/internal/packages/synthesize.go @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package packages + +import ( + "go/ast" + "go/token" + "go/types" + "strconv" +) + +// Synthesizing a package from usage. +// +// When an import cannot be resolved to source — because nothing on the filesystem provides it, or +// because [WithStubbedStdlib] deliberately withholds it — the type-checker still needs something to +// resolve `pkg.Name` against. A package fabricated from the names actually selected through it is +// enough for codescan's name-keyed recognizers, which ask (package, type name) and never look at the +// type's shape. It is not enough for anything structural: a synthesized type has no fields to walk +// and no methods, so drilling into it, or asking whether it implements an interface, both fail. + +// willStub reports whether an import path will be synthesized rather than loaded. +func (ld *loadState) willStub(path string) bool { + if path == "unsafe" { + return false + } + if known, ok := ld.stubbable[path]; ok { + return known + } + if ld.hasExportData(path) { + ld.stubbable[path] = false + + return false + } + + _, _, resolved := ld.res.resolveImport(path, "") + ld.stubbable[path] = !resolved + + return !resolved +} + +// synthesizeFrom records the names a package selects through its unresolvable imports, so that the +// synthesized packages hold them before this package is type-checked against them. +// +// Names are collected per importing package, which is why this runs before each Check rather than +// once up front: the set of packages is discovered lazily, as imports are followed. +func (ld *loadState) synthesizeFrom(files []*ast.File) { + for _, f := range files { + aliases := ld.stubbedImports(f) + if len(aliases) == 0 { + continue + } + + ast.Inspect(f, func(n ast.Node) bool { + sel, ok := n.(*ast.SelectorExpr) + if !ok { + return true + } + ident, ok := sel.X.(*ast.Ident) + if !ok { + return true + } + if path, ok := aliases[ident.Name]; ok { + ld.addSynthesizedName(path, sel.Sel.Name) + } + + return true + }) + } +} + +// stubbedImports maps the local name of each to-be-synthesized import onto its path. +func (ld *loadState) stubbedImports(f *ast.File) map[string]string { + var aliases map[string]string + for _, spec := range f.Imports { + path, err := strconv.Unquote(spec.Path.Value) + if err != nil || !ld.willStub(path) { + continue + } + + ld.reportSynthesized(path, spec.Path.Pos()) + + name := pkgNameFor(path) + if spec.Name != nil { + name = spec.Name.Name + } + if name == "_" || name == "." { + continue // no qualified selector can name this import + } + + if aliases == nil { + aliases = make(map[string]string, len(f.Imports)) + } + aliases[name] = path + } + + return aliases +} + +// reportSynthesized announces an import path the loader had to fabricate, once per path. +// +// It fires on the import rather than on first use, so that an import whose names are never selected +// — and which therefore contributes no fabricated type at all — is still reported. +func (ld *loadState) reportSynthesized(path string, pos token.Pos) { + if ld.onSynthesize == nil || ld.reported[path] { + return + } + ld.reported[path] = true + + ld.onSynthesize(Synthesized{ + Path: path, + Pos: ld.fset.Position(pos), + Deliberate: ld.stubStdlib && isStdlibPath(path), + }) +} + +// addSynthesizedName adds one opaque defined type to a synthesized package. +// +// Only exported names are worth fabricating: an unexported one could never be referenced from +// another package, so seeing it means the selector was something else. +func (ld *loadState) addSynthesizedName(path, name string) { + if !ast.IsExported(name) { + return + } + + pkg := ld.stub(path) + if pkg.Scope().Lookup(name) != nil { + return + } + + tn := types.NewTypeName(token.NoPos, pkg, name, nil) + types.NewNamed(tn, types.NewStruct(nil, nil), nil) + pkg.Scope().Insert(tn) +} diff --git a/internal/packages/vfs.go b/internal/packages/vfs.go new file mode 100644 index 00000000..c4f82842 --- /dev/null +++ b/internal/packages/vfs.go @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package packages + +import ( + "io" + "io/fs" + "os" + "path" + "path/filepath" + "strings" +) + +// vfs is the loader's single point of filesystem contact. +// +// It exists so that every read — build-constraint matching inside go/build, directory walking during +// pattern resolution, and source reading before parsing — goes through one place that can be pointed +// at either the real filesystem or an fs.FS. +type vfs struct { + fsys fs.FS // nil: real filesystem +} + +func (v *vfs) virtual() bool { return v.fsys != nil } + +// clean maps a caller-supplied path onto the convention of the active backend. +// +// io/fs requires slash-separated, unrooted paths, while callers naturally write OS paths and +// sometimes absolute ones. Normalising here (rather than rejecting) keeps the same patterns working +// against both backends. +func (v *vfs) clean(p string) string { + if !v.virtual() { + return p + } + p = filepath.ToSlash(p) + p = strings.TrimPrefix(p, "./") + p = strings.TrimLeft(p, "/") + if p == "" { + return "." + } + return path.Clean(p) +} + +func (v *vfs) open(p string) (io.ReadCloser, error) { + if !v.virtual() { + return os.Open(p) //nolint:gosec // the loader reads whatever source tree the caller pointed it at + } + return v.fsys.Open(v.clean(p)) +} + +func (v *vfs) readFile(p string) ([]byte, error) { + if !v.virtual() { + return os.ReadFile(p) //nolint:gosec // as above + } + return fs.ReadFile(v.fsys, v.clean(p)) +} + +// readDir returns fs.FileInfo values because that is what go/build's ReadDir hook requires. +func (v *vfs) readDir(dir string) ([]fs.FileInfo, error) { + var entries []fs.DirEntry + var err error + if !v.virtual() { + entries, err = os.ReadDir(dir) + } else { + entries, err = fs.ReadDir(v.fsys, v.clean(dir)) + } + if err != nil { + return nil, err + } + infos := make([]fs.FileInfo, 0, len(entries)) + for _, e := range entries { + info, err := e.Info() + if err != nil { + continue // a vanished entry is not worth failing the whole directory over + } + infos = append(infos, info) + } + return infos, nil +} + +func (v *vfs) isDir(p string) bool { + if !v.virtual() { + info, err := os.Stat(p) + return err == nil && info.IsDir() + } + info, err := fs.Stat(v.fsys, v.clean(p)) + return err == nil && info.IsDir() +} + +func (v *vfs) join(elem ...string) string { + if !v.virtual() { + return filepath.Join(elem...) + } + return path.Join(elem...) +} + +func (v *vfs) isAbs(p string) bool { + if !v.virtual() { + return filepath.IsAbs(p) + } + return false // every path in an fs.FS is rooted at the FS itself +} + +// hasSubdir reports whether dir is within root, and if so its relative path. +func (v *vfs) hasSubdir(root, dir string) (string, bool) { + if !v.virtual() { + rel, err := filepath.Rel(root, dir) + if err != nil || strings.HasPrefix(rel, "..") { + return "", false + } + return filepath.ToSlash(rel), true + } + root, dir = v.clean(root), v.clean(dir) + if root == "." { + return dir, true + } + if dir == root { + return ".", true + } + if !strings.HasPrefix(dir, root+"/") { + return "", false + } + return strings.TrimPrefix(dir, root+"/"), true +} + +// walkDirs yields every directory at or below root, in lexical order. +func (v *vfs) walkDirs(root string, yield func(string) error) error { + if !v.virtual() { + return filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil || !d.IsDir() { + return nil //nolint:nilerr // an unreadable subtree is skipped, not fatal + } + if skipDir(d.Name()) && p != root { + return filepath.SkipDir + } + return yield(p) + }) + } + // io/fs walks in its own rooted namespace, so every result has to be mapped back onto the form the + // caller uses. Yielding the fs-internal path instead would leak into file names, and from there + // into every token.Position the scan reports — a position an editor cannot resolve. + inner := v.clean(root) + + return fs.WalkDir(v.fsys, inner, func(p string, d fs.DirEntry, err error) error { + if err != nil || !d.IsDir() { + return nil //nolint:nilerr // as above + } + if skipDir(d.Name()) && p != inner { + return fs.SkipDir + } + + return yield(rebase(root, inner, p)) + }) +} + +// rebase maps a path produced by walking under inner back onto the caller's root. +func rebase(root, inner, p string) string { + if p == inner { + return root + } + + rel := strings.TrimPrefix(p, inner+"/") + if inner == "." { + rel = p + } + + return strings.TrimSuffix(root, "/") + "/" + rel +} + +// skipDir reports directories the go tool itself never treats as packages. +func skipDir(name string) bool { + return name == "testdata" || name == "vendor" || + strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") +} diff --git a/internal/parsers/grammar/diagnostic.go b/internal/parsers/grammar/diagnostic.go index ebb16682..c09bc6a1 100644 --- a/internal/parsers/grammar/diagnostic.go +++ b/internal/parsers/grammar/diagnostic.go @@ -201,6 +201,19 @@ const ( // without ScanModels can be traced to the family that pulled it in. CodeDiscoveredSubtype Code = "scan.discovered-subtype" + // CodeSynthesizedImport fires when an import could not be loaded from source and its types were + // fabricated from the names the scanned code selects through it. + // + // A synthesized type carries the right package path and name, so recognition by identity still + // works (time.Time remains a date-time), but it has no fields and no methods — so drilling into it, + // or asking whether it implements an interface, silently yields less than a real scan would. + // + // Warning when the import was simply not found, since that is usually a mounting or module-cache + // problem the caller wants to fix. Informational (Hint) when the caller withheld the standard + // library on purpose via StubStdlib: the loss is then intended, but still worth seeing. + // Carries the position of the import that triggered it, once per import path. + CodeSynthesizedImport Code = "scan.synthesized-import" + // CodeOmitUnresolved fires when a `swagger:omit` target names no field of the embedded type it is // applied to — a typo, or a field renamed upstream. // diff --git a/internal/scanner/load_golist.go b/internal/scanner/load_golist.go new file mode 100644 index 00000000..8bc2ab61 --- /dev/null +++ b/internal/scanner/load_golist.go @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +//go:build !wasm + +package scanner + +import ( + "os" + + "golang.org/x/tools/go/packages" +) + +func loadWithGoPackages(opts *Options) ([]*packages.Package, error) { + cfg := &packages.Config{ + Dir: opts.WorkDir, + Mode: pkgLoadMode, + Tests: false, + } + if opts.BuildTags != "" { + cfg.BuildFlags = []string{"-tags", opts.BuildTags} + } + // go list reads the target from the environment, so an explicit Options target has to be pushed + // there. Env replaces the environment wholesale when set, hence the append to os.Environ(). + if env := targetEnv(opts); len(env) > 0 { + cfg.Env = append(os.Environ(), env...) + } + + return packages.Load(cfg, opts.Packages...) +} diff --git a/internal/scanner/load_wasm.go b/internal/scanner/load_wasm.go new file mode 100644 index 00000000..e67adfbe --- /dev/null +++ b/internal/scanner/load_wasm.go @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: Copyright 2015-2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +//go:build wasm + +package scanner + +import ( + "errors" + "fmt" + + "golang.org/x/tools/go/packages" +) + +// ErrNoToolchainLoader reports that the `go list` loader cannot exist in this build. +var ErrNoToolchainLoader = errors.New("the go/packages loader is unavailable in a WebAssembly build") + +// loadWithGoPackages is absent from WebAssembly builds. +// +// packages.Load resolves a package graph by running `go list`, and WebAssembly has no process model, so this path +// could only ever fail at runtime. Excluding it also keeps os/exec and the go command plumbing out of the +// artifact entirely, which is why the build is tagged rather than merely guarded. +func loadWithGoPackages(_ *Options) ([]*packages.Package, error) { + return nil, fmt.Errorf("%w: set Options.FS to select the toolchain-free loader", ErrNoToolchainLoader) +} diff --git a/internal/scanner/options.go b/internal/scanner/options.go index ab84c17c..a77ec727 100644 --- a/internal/scanner/options.go +++ b/internal/scanner/options.go @@ -4,6 +4,8 @@ package scanner import ( + "io/fs" + "github.com/go-openapi/codescan/internal/parsers/grammar" "github.com/go-openapi/spec" ) @@ -19,11 +21,71 @@ import ( // [§descwithref](./README.md#descwithref) and [§diagnostics](./README.md#diagnostics) for the two // fields with non-trivial semantics (DescWithRef and OnDiagnostic). type Options struct { - Packages []string - InputSpec *spec.Swagger - ScanModels bool - WorkDir string - BuildTags string + Packages []string + InputSpec *spec.Swagger + ScanModels bool + WorkDir string + BuildTags string + + // GOOS and GOARCH select the platform the scanned code is built for. + // + // They decide which files each package is made of — //go:build lines and _linux.go / _amd64.go + // style filename suffixes resolve against them — so they change the emitted spec, in the same way + // BuildTags does. + // + // Empty (the default) means the platform codescan itself is running on, which is what the go + // command would assume. Set them to scan code for another platform, or wherever codescan's own + // platform is an accident of deployment rather than a statement about the code under scan. + GOOS string + GOARCH string + + // FS makes the scan read its source through a virtual filesystem instead of the real one. + // + // Packages, WorkDir and every path derived from them are then interpreted relative to the root of + // FS, following io/fs conventions. This is what lets codescan scan a tree that was never written + // to disk: an in-memory tree in a WASI guest, an uploaded archive, a testing/fstest.MapFS. + // + // Setting it also selects the loader. The default package loader shells out to `go list`, which + // can only see the real filesystem, so a non-nil FS switches the scan to codescan's own loader + // (internal/packages) — which needs no toolchain at all. Leaving FS nil keeps the historic + // go/packages behaviour. + FS fs.FS + + // StubStdlib keeps the standard library out of the package graph, synthesizing its types from the + // names the scanned code selects through them rather than reading GOROOT. + // + // It applies only when FS selects codescan's own loader; the go/packages path ignores it. + // + // The trade is fidelity for reach. Recognition by type identity is unaffected — time.Time, + // json.RawMessage and the rest are matched on (package, name). Anything structural is lost: a + // synthesized type has no fields and no method set, so json.RawMessage no longer renders as a byte + // array, time.Duration no longer as an integer, and a type is no longer seen to implement + // encoding.TextMarshaler. + // + // What it buys is not needing GOROOT at all, and a far smaller graph — which is what makes a scan + // viable in a WASI guest or a browser, where the standard library source would otherwise have to be + // shipped or mounted. + // + // It is not failsafe, and the failure mode is quiet: a spec comes out subtly thinner rather than + // erroring. Across codescan's own fixture corpus 133 of 138 scans are byte-identical; the rest lose + // a byte-array rendering, an integer format, or a TextMarshaler-derived string, and stdlib + // interfaces such as io.Reader have no identity recognizer to fall back on at all. Prefer a full + // graph wherever GOROOT is available. + StubStdlib bool + + // ExportData serves DEPENDENCIES from pre-computed export data instead of reading their source, + // when FS selects codescan's own loader. + // + // It holds one file per package, named by import path with a ".export" suffix. Unlike StubStdlib + // this costs no fidelity — the types are the ones the compiler computed, so fields, method sets + // and interface identity are all real — while avoiding the parsing and type-checking that + // dominate a full scan. + // + // The module under scan is never read this way: its comments are the annotations, and export data + // carries none. The data is valid only for the toolchain that produced it, and a package it does + // not cover falls back to source, and then to synthesis. + ExportData fs.FS + ExcludeDeps bool Include []string Exclude []string diff --git a/internal/scanner/scan_context.go b/internal/scanner/scan_context.go index 53e173d5..101fa97f 100644 --- a/internal/scanner/scan_context.go +++ b/internal/scanner/scan_context.go @@ -14,6 +14,7 @@ import ( "slices" "strings" + ownpackages "github.com/go-openapi/codescan/internal/packages" "github.com/go-openapi/codescan/internal/parsers" "github.com/go-openapi/codescan/internal/parsers/grammar" "github.com/go-openapi/swag/mangling" @@ -93,17 +94,85 @@ type ScanCtx struct { mangler *mangling.NameMangler } -func NewScanCtx(opts *Options) (*ScanCtx, error) { - cfg := &packages.Config{ - Dir: opts.WorkDir, - Mode: pkgLoadMode, - Tests: false, +// LoadPackages resolves a scan's patterns into loaded, type-checked packages. +// +// EXPERIMENTAL (wasi-build spike). It is a variable so an alternative loader can stand in. +var LoadPackages = loadPackages + +// loadPackages picks a loader from the options. +// +// Options.FS is the discriminator rather than a flag of its own: packages.Load reaches the filesystem +// through `go list`, so it can only ever see the real one. Asking for a virtual filesystem is +// therefore already a statement about which loader has to run. +func loadPackages(opts *Options) ([]*packages.Package, error) { + if opts.FS != nil { + return loadWithOwnLoader(opts) + } + + return loadWithGoPackages(opts) +} + +// loadWithOwnLoader loads through internal/packages, which needs no toolchain and no exec. +func loadWithOwnLoader(opts *Options) ([]*packages.Package, error) { + loaderOpts := []ownpackages.Option{ + ownpackages.WithFS(opts.FS), + ownpackages.WithTarget(opts.GOOS, opts.GOARCH), + ownpackages.WithOnSynthesized(synthesisReporter(opts)), + } + if opts.StubStdlib { + loaderOpts = append(loaderOpts, ownpackages.WithStubbedStdlib()) } + if opts.ExportData != nil { + loaderOpts = append(loaderOpts, ownpackages.WithExportData(opts.ExportData)) + } + loader := ownpackages.NewLoader(loaderOpts...) + + cfg := &packages.Config{Dir: opts.WorkDir} if opts.BuildTags != "" { cfg.BuildFlags = []string{"-tags", opts.BuildTags} } - pkgs, err := packages.Load(cfg, opts.Packages...) + return loader.Load(cfg, opts.Packages...) +} + +// synthesisReporter turns the loader's synthesized-import notices into scan diagnostics. +// +// This is the only place the fidelity loss becomes visible. A synthesized type used in a field +// position type-checks perfectly well and simply yields a thinner spec; what reaches the caller +// otherwise is the downstream wreckage of a value-position use, which reads as an error in the +// scanned code rather than as a dependency that was never there. +func synthesisReporter(opts *Options) func(ownpackages.Synthesized) { + if opts.OnDiagnostic == nil { + return nil + } + + return func(s ownpackages.Synthesized) { + ctor, why := grammar.Warnf, "could not be resolved" + if s.Deliberate { + ctor, why = grammar.Hintf, "was withheld" + } + + opts.OnDiagnostic(ctor(s.Pos, grammar.CodeSynthesizedImport, + "import %q %s: its types are synthesized from usage, so they carry no fields and no methods", + s.Path, why)) + } +} + +// targetEnv renders Options.GOOS / Options.GOARCH as environment assignments. Empty fields mean +// "whatever the host is" and contribute nothing. +func targetEnv(opts *Options) []string { + var env []string + if opts.GOOS != "" { + env = append(env, "GOOS="+opts.GOOS) + } + if opts.GOARCH != "" { + env = append(env, "GOARCH="+opts.GOARCH) + } + return env +} + +func NewScanCtx(opts *Options) (*ScanCtx, error) { + pkgs, err := LoadPackages(opts) if err != nil { return nil, err }