From 3eb600a56521f4820e53e7acb7513986f315a06e Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 20:37:01 +0200 Subject: [PATCH 1/4] feat(scanner): load and type-check packages without the Go toolchain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages.Load resolves a package graph by running `go list`. That binds a scan to an installed toolchain, and rules out any environment without one — a WebAssembly guest, where there is no process model at all, being the case that forced the issue. internal/packages does the same job in pure Go: patterns to directories, build-constraint selection through go/build, parse, type-check. Its entry point mirrors the original and its vocabulary is aliased to it, so a caller moves between the two by changing the call and nothing else. Across the fixture corpus the two agree byte for byte. Three things a caller could not previously say, each of which changes the emitted spec rather than merely how it is produced: - GOOS/GOARCH, because the platform a scan is built for was silently the platform codescan was running on. Inside a guest that is wasip1, which drops every _linux.go file without a word. - FS, a filesystem to read the sources from, which is what lets a scan cover a tree that was never written to disk. It also selects the loader: `go list` can only ever see the real filesystem, so asking for a virtual one already says which loader has to run. - StubStdlib, which synthesizes the standard library from the names the scanned code selects through it rather than reading GOROOT. Far smaller and faster, and not failsafe: a synthesized type has no fields and no method set, so json.RawMessage stops rendering as a byte array and a type is no longer seen to implement encoding.TextMarshaler. Synthesis is not silent. Every import fabricated this way raises a scan.synthesized-import diagnostic on the import that caused it — a hint when it was asked for, a warning when the import merely could not be found, which is usually a mounting mistake. Without it the loss surfaces only as the wreckage of a value-position use, reading as an error in the scanned code rather than as a dependency that was never there. Function bodies are type-checked despite codescan reading only declarations: an annotated type may be declared inside one, and skipping bodies leaves no entry for it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .../internal/ux/model_options_test.go | 1 + cmd/genspec-tui/main.go | 6 + cmd/genspec-tui/main_test.go | 4 + go.mod | 2 +- internal/integration/virtual_fs_test.go | 221 ++++++++++ internal/packages/aliases.go | 30 ++ internal/packages/doc.go | 37 ++ internal/packages/exportdata.go | 102 +++++ internal/packages/loader.go | 359 ++++++++++++++++ internal/packages/loader_test.go | 394 ++++++++++++++++++ internal/packages/options.go | 144 +++++++ internal/packages/resolve.go | 307 ++++++++++++++ internal/packages/synthesize.go | 134 ++++++ internal/packages/vfs.go | 174 ++++++++ internal/parsers/grammar/diagnostic.go | 13 + internal/scanner/load_golist.go | 30 ++ internal/scanner/load_wasm.go | 25 ++ internal/scanner/options.go | 72 +++- internal/scanner/scan_context.go | 81 +++- 19 files changed, 2124 insertions(+), 12 deletions(-) create mode 100644 internal/integration/virtual_fs_test.go create mode 100644 internal/packages/aliases.go create mode 100644 internal/packages/doc.go create mode 100644 internal/packages/exportdata.go create mode 100644 internal/packages/loader.go create mode 100644 internal/packages/loader_test.go create mode 100644 internal/packages/options.go create mode 100644 internal/packages/resolve.go create mode 100644 internal/packages/synthesize.go create mode 100644 internal/packages/vfs.go create mode 100644 internal/scanner/load_golist.go create mode 100644 internal/scanner/load_wasm.go 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/go.mod b/go.mod index 4e25bf7b..16b54414 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,5 @@ 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 golang.org/x/sync v0.22.0 // indirect ) 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/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 } From 23e338f0ad906eb9e787d93cd5af1f67ed4882fa Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 20:37:29 +0200 Subject: [PATCH 2/4] feat(genspec): add a headless spec generator that builds for WASI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit genspec writes a specification to standard output and depends on nothing beyond the library, so it cross-compiles to wasip1/wasm and runs under any WASI runtime with no toolchain present and no subprocess. -loader picks how packages are resolved, defaulting to whichever is possible: on wasm the go command can never run, so the choice makes itself. Every boolean scan option has a flag, named after its field, and a test fails when one arrives without either a flag or a recorded reason not to have one. Coverage is decided by which field an entry writes to rather than by deriving a name from the field, since no mechanical rendering knows that JSONify is one word and HTTPServer is two. Reading dependencies from the export data the compiler already produced, instead of parsing and type-checking them, is where nearly all of a scan's time goes: on the petstore under wasmtime, 7.3 s and 681 MB becomes 1.0 s and 138 MB, with GOROOT never mounted. It costs no fidelity, the types being the compiler's own — and unlike withholding the standard library entirely it keeps method sets and interface identity. genexportdata produces the data, natively, because generating it needs the toolchain the consumer does not have; a module of its own states what a published bundle covers. The exportdata build tag carries the result inside the binary, so an artifact needs nothing mounted but the sources being scanned. A package whose meaning lives in comments cannot go in. strfmt declares its formats with swagger:strfmt, and export data holds types, not comments, so it would come back structurally intact and semantically empty with nothing erroring. Those are detected, skipped and named at generation time. The go/packages loader is excluded from WebAssembly builds by tag rather than guarded at runtime, so the go command plumbing is never linked. What the artifact asks of a host drops to twenty-one WASI functions, every one of which reads, and a test pins that: it also rejects a second import module, and any import that is not a function, since an imported memory would mean SharedArrayBuffer and the headers that rule out static hosting. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- .gitignore | 5 + README.md | 17 ++ cmd/genspec/README.md | 181 ++++++++++++++++ cmd/genspec/exec_native.go | 9 + cmd/genspec/exec_wasm.go | 12 ++ cmd/genspec/exportdata.go | 47 ++++ cmd/genspec/main.go | 225 +++++++++++++++++++ cmd/genspec/options.go | 129 +++++++++++ cmd/genspec/options_test.go | 110 ++++++++++ hack/genexportdata/bundle/deps.go | 18 ++ hack/genexportdata/bundle/go.mod | 39 ++++ hack/genexportdata/bundle/go.sum | 65 ++++++ hack/genexportdata/main.go | 249 ++++++++++++++++++++++ internal/exportdata/absent.go | 11 + internal/exportdata/doc.go | 18 ++ internal/exportdata/embedded.go | 41 ++++ internal/integration/wasi_imports_test.go | 246 +++++++++++++++++++++ internal/integration/wasi_test.go | 196 +++++++++++++++++ 18 files changed, 1618 insertions(+) create mode 100644 cmd/genspec/README.md create mode 100644 cmd/genspec/exec_native.go create mode 100644 cmd/genspec/exec_wasm.go create mode 100644 cmd/genspec/exportdata.go create mode 100644 cmd/genspec/main.go create mode 100644 cmd/genspec/options.go create mode 100644 cmd/genspec/options_test.go create mode 100644 hack/genexportdata/bundle/deps.go create mode 100644 hack/genexportdata/bundle/go.mod create mode 100644 hack/genexportdata/bundle/go.sum create mode 100644 hack/genexportdata/main.go create mode 100644 internal/exportdata/absent.go create mode 100644 internal/exportdata/doc.go create mode 100644 internal/exportdata/embedded.go create mode 100644 internal/integration/wasi_imports_test.go create mode 100644 internal/integration/wasi_test.go 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/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/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..7b805ff9 --- /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.4 // 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..b37f39e0 --- /dev/null +++ b/hack/genexportdata/bundle/go.sum @@ -0,0 +1,65 @@ +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.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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/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} +} From 5893347d703738b9402ff4d0db84c6efdd16bf0d Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 2 Aug 2026 20:37:29 +0200 Subject: [PATCH 3/4] feat(playground): scan Go source in the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Svelte front-end around the wasip1 artifact: pick a module, watch the specification it produces. No server, no toolchain, and the files never leave the browser. hack/browser is the conformance probe that established this is possible at all, kept because the properties it checks are easy to lose. Under @bjorn3/browser_wasi_shim a 200-package tree yields 6000 definitions in 3.5 s; compiling the module costs 22 ms once and instantiating 4-5 ms per run, flat in the size of the workload. poll_oneoff was the risk worth checking, the shim implementing it by spinning rather than yielding — it never fires, Go reaching it only through the netpoller and only once the scheduler runs out of work. The scan runs in a worker, and each one gets 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. Opening a module replaces the tree rather than merging into it — otherwise the previous one stays behind and the next scan sees two modules at once — and re-roots on the outermost go.mod, since a directory pick may sit above the module or below it. Test files are skipped and vendor is kept, being the only way a third-party import resolves with no module cache to read. The standard library is synthesized rather than read, which is fast and needs no asset but loses structural detail. Pointing it at published export data instead is one argument, once it is settled which packages that data covers. The front-end is a prototype: no highlighting, no cross-references, no chrome. It lags the terminal UI, which has had a great deal more polish. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Frederic BIDON --- hack/browser/.gitignore | 2 + hack/browser/README.md | 54 + hack/browser/fixture.js | 21 + hack/browser/index.html | 15 + hack/browser/node-probe.js | 99 + hack/browser/package-lock.json | 20 + hack/browser/package.json | 13 + hack/browser/probe.js | 31 + hack/browser/worker.js | 84 + hack/doc-site/genspec-wasi/.gitignore | 5 + hack/doc-site/genspec-wasi/README.md | 80 + hack/doc-site/genspec-wasi/index.html | 12 + hack/doc-site/genspec-wasi/package-lock.json | 2012 +++++++++++++++++ hack/doc-site/genspec-wasi/package.json | 28 + hack/doc-site/genspec-wasi/src/App.svelte | 20 + hack/doc-site/genspec-wasi/src/app.css | 31 + .../src/components/FilePicker.svelte | 67 + .../src/components/OptionsPanel.svelte | 48 + .../src/components/SourcePane.svelte | 31 + .../src/components/SpecPane.svelte | 46 + .../src/components/Toolbar.svelte | 32 + .../genspec-wasi/src/lib/flags.test.ts | 43 + hack/doc-site/genspec-wasi/src/lib/flags.ts | 34 + hack/doc-site/genspec-wasi/src/lib/sample.ts | 64 + .../genspec-wasi/src/lib/store.svelte.ts | 93 + .../genspec-wasi/src/lib/tree.test.ts | 39 + hack/doc-site/genspec-wasi/src/lib/tree.ts | 22 + hack/doc-site/genspec-wasi/src/lib/types.ts | 45 + hack/doc-site/genspec-wasi/src/main.ts | 5 + .../genspec-wasi/src/worker/scan-worker.ts | 95 + hack/doc-site/genspec-wasi/svelte.config.js | 3 + hack/doc-site/genspec-wasi/tsconfig.json | 16 + hack/doc-site/genspec-wasi/vite.config.ts | 15 + 33 files changed, 3225 insertions(+) create mode 100644 hack/browser/.gitignore create mode 100644 hack/browser/README.md create mode 100644 hack/browser/fixture.js create mode 100644 hack/browser/index.html create mode 100644 hack/browser/node-probe.js create mode 100644 hack/browser/package-lock.json create mode 100644 hack/browser/package.json create mode 100644 hack/browser/probe.js create mode 100644 hack/browser/worker.js create mode 100644 hack/doc-site/genspec-wasi/.gitignore create mode 100644 hack/doc-site/genspec-wasi/README.md create mode 100644 hack/doc-site/genspec-wasi/index.html create mode 100644 hack/doc-site/genspec-wasi/package-lock.json create mode 100644 hack/doc-site/genspec-wasi/package.json create mode 100644 hack/doc-site/genspec-wasi/src/App.svelte create mode 100644 hack/doc-site/genspec-wasi/src/app.css create mode 100644 hack/doc-site/genspec-wasi/src/components/FilePicker.svelte create mode 100644 hack/doc-site/genspec-wasi/src/components/OptionsPanel.svelte create mode 100644 hack/doc-site/genspec-wasi/src/components/SourcePane.svelte create mode 100644 hack/doc-site/genspec-wasi/src/components/SpecPane.svelte create mode 100644 hack/doc-site/genspec-wasi/src/components/Toolbar.svelte create mode 100644 hack/doc-site/genspec-wasi/src/lib/flags.test.ts create mode 100644 hack/doc-site/genspec-wasi/src/lib/flags.ts create mode 100644 hack/doc-site/genspec-wasi/src/lib/sample.ts create mode 100644 hack/doc-site/genspec-wasi/src/lib/store.svelte.ts create mode 100644 hack/doc-site/genspec-wasi/src/lib/tree.test.ts create mode 100644 hack/doc-site/genspec-wasi/src/lib/tree.ts create mode 100644 hack/doc-site/genspec-wasi/src/lib/types.ts create mode 100644 hack/doc-site/genspec-wasi/src/main.ts create mode 100644 hack/doc-site/genspec-wasi/src/worker/scan-worker.ts create mode 100644 hack/doc-site/genspec-wasi/svelte.config.js create mode 100644 hack/doc-site/genspec-wasi/tsconfig.json create mode 100644 hack/doc-site/genspec-wasi/vite.config.ts 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' }, +}); From c29817d9876886df6789809f32849191c6607ceb Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Mon, 3 Aug 2026 12:19:21 +0200 Subject: [PATCH 4/4] chore: updated go.work and all go.{mod,sum} Signed-off-by: Frederic BIDON --- cmd/genspec-tui/go.mod | 1 + cmd/genspec-tui/go.sum | 5 +++-- docs/examples/go.sum | 3 +-- fixtures/go.mod | 12 +++++++++++- fixtures/go.sum | 22 ++++++++++++++++++++-- go.mod | 1 + go.sum | 3 +-- go.work | 1 + hack/genexportdata/bundle/go.mod | 2 +- hack/genexportdata/bundle/go.sum | 5 +---- internal/packages/TODO.md | 3 +++ 11 files changed, 44 insertions(+), 14 deletions(-) create mode 100644 internal/packages/TODO.md 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/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 16b54414..728aff51 100644 --- a/go.mod +++ b/go.mod @@ -23,5 +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 + 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/genexportdata/bundle/go.mod b/hack/genexportdata/bundle/go.mod index 7b805ff9..70e42b1f 100644 --- a/hack/genexportdata/bundle/go.mod +++ b/hack/genexportdata/bundle/go.mod @@ -32,7 +32,7 @@ require ( 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.4 // 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 index b37f39e0..6800e9fe 100644 --- a/hack/genexportdata/bundle/go.sum +++ b/hack/genexportdata/bundle/go.sum @@ -53,13 +53,10 @@ 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.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +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= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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)