diff --git a/docs/plugins/marketplace.md b/docs/plugins/marketplace.md index 7df03706..6a88b4a8 100644 --- a/docs/plugins/marketplace.md +++ b/docs/plugins/marketplace.md @@ -32,13 +32,13 @@ overrides: | Field | Comes from | |-------|------------| | `schema` | The format version (`kiosk.SchemaVersion`). A reader that knows an older schema refuses the file and asks you to update `summer`. | -| `id` | What `ID()` in `plugin.go` returns. | +| `id` | What `ID()` in `plugin.go` returns: a string literal or a constant (of the same package, of another package of the module, or of a required module). The command reads the source and never runs it; any other expression, such as a function call or a variable, is an error naming `ID()`. | | `module` | The `module` line of the nearest `go.mod`: what `go get` takes. | | `package` | The plugin's import path: what `summer.yaml` lists. It differs from `module` for a plugin in a subdirectory. | | `name` | The README's H1, or the `--name` override. | | `description` | The first paragraph after the README's H1 (the one-sentence summary of the standard README structure), or the `--description` override. | | `framework` | The version `go.mod` requires the framework (`git.golem15.com/golem15/summercms`) at: the oldest framework release the plugin works with. | -| `requires` | Each plugin ID `Requires()` returns, with the version `go.mod` requires its module at. The value is empty for a plugin in the same module, and for one whose module is not a `go.mod` require. Left out when `Requires()` returns none. | +| `requires` | Each plugin ID `Requires()` returns (literals or constants, as for `id`), with the version `go.mod` requires its module at. A requirement at `v0.0.0` is an error, because nobody can install that version; a pseudo-version is accepted with a warning. The value is empty for a plugin in the same module, and for one whose module is not a `go.mod` require. Left out when `Requires()` returns none. | | `overrides` | The `--name` and `--description` values, kept across regenerations. Left out when neither is set. | Versions are minimums in Go's sense (`v0.6.0` means "v0.6.0 or newer"), never ranges: Go's minimal version selection picks the version, and a major version change moves the module path. @@ -72,3 +72,18 @@ summer plugin:manifest --check ``` Run it before tagging a release, so the tag carries a manifest that matches its code. + +## Testing the manifest in your plugin + +Add one test to the plugin, so `go test` fails as soon as `plugin.yaml` no longer matches the code. It imports `git.golem15.com/golem15/summercms/modules/kiosk` and passes the plugin directory, which is `"."` in a plugin's own package (the framework's copy below checks a fixture): + +```go src=modules/kiosk/checkt_test.go#TestPluginManifest +// TestPluginManifest is the test a plugin repository adds next to its +// plugin.go, with "." as the directory. Here it guards the committed +// manifest of the literal fixture. +func TestPluginManifest(t *testing.T) { + kiosk.CheckT(t, "testdata/literal") +} +``` + +`kiosk.CheckT` runs the same check as `summer plugin:manifest --check` and names the command to rerun. A nested plugin gets its own test in its own directory. diff --git a/internal/build/build_test.go b/internal/build/build_test.go index f316257d..a68ed58d 100644 --- a/internal/build/build_test.go +++ b/internal/build/build_test.go @@ -1322,6 +1322,39 @@ func assertWinterPluginShape(t *testing.T, pluginDir string) { } } +// TestAddPluginConstID: plugin:add registers a plugin whose ID() returns a +// package-level constant (research F1), and a literal-ID plugin as before. +func TestAddPluginConstID(t *testing.T) { + dir := copyHelloApp(t) + ctx := t.Context() + plugins := map[string]string{ + "consts": "package consts\n\nconst PluginID = \"acme.consts\"\n\ntype Plugin struct{}\n\nfunc (*Plugin) ID() string { return PluginID }\n\nfunc (*Plugin) Requires() []string { return nil }\n", + "lit": "package lit\n\ntype Plugin struct{}\n\nfunc (*Plugin) ID() string { return \"acme.lit\" }\n\nfunc (*Plugin) Requires() []string { return nil }\n", + } + for _, name := range []string{"consts", "lit"} { + pluginDir := filepath.Join(dir, "plugins", "acme", name) + if err := os.MkdirAll(pluginDir, 0o755); err != nil { + t.Fatal(err) + } + write(t, filepath.Join(pluginDir, "go.mod"), "module example.test/acme/"+name+"\n\ngo 1.27.0\n") + write(t, filepath.Join(pluginDir, "plugin.go"), plugins[name]) + if err := AddPlugin(ctx, dir, filepath.Join("plugins", "acme", name)); err != nil { + t.Fatalf("plugin:add %s: %v", name, err) + } + } + m, err := LoadManifest(filepath.Join(dir, "summer.yaml")) + if err != nil { + t.Fatal(err) + } + got := map[string]string{} + for _, p := range m.Plugins { + got[p.ID] = p.Module + } + if got["acme.consts"] != "example.test/acme/consts" || got["acme.lit"] != "example.test/acme/lit" { + t.Fatalf("summer.yaml plugins = %+v", m.Plugins) + } +} + func copyHelloApp(t *testing.T) string { t.Helper() src, err := filepath.Abs(filepath.Join("..", "..", "examples", "hello")) diff --git a/internal/build/scaffold.go b/internal/build/scaffold.go index 07342f48..deab25a7 100644 --- a/internal/build/scaffold.go +++ b/internal/build/scaffold.go @@ -5,19 +5,16 @@ import ( "context" "embed" "fmt" - "go/ast" "go/format" - "go/parser" - "go/token" "go/version" "os" "os/exec" "path/filepath" - "strconv" "strings" "text/template" "git.golem15.com/golem15/summercms/modules/bonfire" + "git.golem15.com/golem15/summercms/modules/kiosk" ) //go:embed stubs/*.tmpl @@ -357,38 +354,19 @@ func frameworkReplaceFor(pluginDir, appDir string) (string, error) { return filepath.ToSlash(rel), nil } +// pluginIDFromGo reads the plugin ID from the package of the plugin.go at +// path with kiosk.ReadIdentity: a string literal or a constant, resolved +// without running any code. func pluginIDFromGo(path string) (string, error) { - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, path, nil, 0) - if err != nil { + if _, err := os.Stat(path); err != nil { return "", fmt.Errorf("build: parse %s: %w", path, err) } - var id string - ast.Inspect(file, func(n ast.Node) bool { - fn, ok := n.(*ast.FuncDecl) - if !ok || fn.Name == nil || fn.Name.Name != "ID" || fn.Recv == nil || fn.Body == nil { - return true - } - for _, stmt := range fn.Body.List { - ret, ok := stmt.(*ast.ReturnStmt) - if !ok || len(ret.Results) != 1 { - continue - } - lit, ok := ret.Results[0].(*ast.BasicLit) - if !ok || lit.Kind != token.STRING { - continue - } - s, err := strconv.Unquote(lit.Value) - if err == nil { - id = s - } - } - return true - }) - if id == "" { - return "", fmt.Errorf("build: %s does not return a plugin id", path) + id, err := kiosk.ReadIdentity(context.Background(), filepath.Dir(path)) + if id.ID != "" { + // A Requires() that kiosk cannot read does not hide the ID. + return id.ID, nil } - return id, nil + return "", fmt.Errorf("build: %s does not return a plugin id: %w", path, err) } func editAppModule(ctx context.Context, appDir, pluginDir, modPath string) error { diff --git a/modules/kiosk/README.md b/modules/kiosk/README.md index d126ffd2..bc86ec97 100644 --- a/modules/kiosk/README.md +++ b/modules/kiosk/README.md @@ -15,10 +15,11 @@ WinterCMS keeps this data in a plugin's `Plugin.php` `pluginDetails()` and in `c ## Features - One `plugin.yaml` per plugin package directory, so a repository with a nested plugin carries two files. +- `kiosk.ReadIdentity` reads `ID()` and `Requires()` statically with `go/parser`: a string literal, a constant of the same package, of another package of the module, or of a package in a module `go.mod` requires. It never builds or runs plugin code, and `summer plugin:add` reads plugin IDs with it too. - A schema version (`kiosk.SchemaVersion`) and a fixed header line (`kiosk.Header`); a manifest with a newer schema is refused with a hint to update summer. -- Go-style minimums: `framework` is the `go.mod` require of `kiosk.FrameworkModule`, and each required plugin gets the version its module is required at. +- Go-style minimums: `framework` is the `go.mod` require of `kiosk.FrameworkModule`, and each required plugin gets the version its module is required at. A requirement at `v0.0.0` is an error (not a released version); a pseudo-version is accepted with a warning. - Name and description overrides (`--name`, `--description`) stored in an `overrides:` block that regeneration keeps. -- `kiosk.Check` regenerates in memory and returns a `kiosk.StaleError` when the committed file is missing or differs, which `summer plugin:manifest --check` turns into exit status 1. +- `kiosk.Check` regenerates in memory and returns a `kiosk.StaleError` when the committed file is missing or differs, which `summer plugin:manifest --check` turns into exit status 1, and `kiosk.CheckT` turns into a failing test in the plugin's own `go test`. ## Usage @@ -41,6 +42,14 @@ if err := kiosk.Check(ctx, "plugins/acme/blog"); err != nil { } ``` +A plugin repository keeps its manifest current with one test: + +```go +func TestPluginManifest(t *testing.T) { + kiosk.CheckT(t, ".") +} +``` + ## API reference | Identifier | Description | @@ -60,6 +69,12 @@ if err := kiosk.Check(ctx, "plugins/acme/blog"); err != nil { | `kiosk.Generate` | `kiosk.Derive` plus `kiosk.Marshal`: the bytes `kiosk.Write` would write. | | `kiosk.Write` | Generates and writes `/plugin.yaml` and returns its path. | | `kiosk.Check` | Returns a `kiosk.StaleError` when `/plugin.yaml` is missing or differs from a fresh generation. | +| `kiosk.StaleError.Error` | Names the path, and for a stale file the first differing line. | +| `kiosk.TB` | The part of `testing.TB` that `kiosk.CheckT` calls (`Helper`, `Fatalf`); kiosk does not import `testing`. | +| `kiosk.CheckT` | Fails the test with the `kiosk.Check` error and the command to rerun when the manifest is missing or stale. | +| `kiosk.Validate` | Checks a manifest: schema, ID and required IDs, module and package paths (no leading `-`), name, description, and `vX.Y.Z` or pseudo-version minimums. `kiosk.Parse` and `kiosk.Generate` call it. | +| `kiosk.Identity` | The plugin ID and the required plugin IDs. | +| `kiosk.ReadIdentity` | Reads what `ID()` and `Requires()` return from the sources of a directory, resolving constants across packages and modules without running code. Any other expression is an error naming the method. | | `kiosk.StaleError` | `Path`, `Missing`, and the generated (`Want`) and committed (`Got`) bytes; its message names the first differing line. | ## CLI commands @@ -72,8 +87,8 @@ if err := kiosk.Check(ctx, "plugins/acme/blog"); err != nil { - SummerCMS modules: none. - Third-party: `github.com/goccy/go-yaml` (manifest YAML), `github.com/yuin/goldmark` (README headings and paragraphs). -- Standard library: `bytes`, `context`, `encoding/json`, `errors`, `fmt`, `go/ast`, `go/parser`, `go/token`, `os`, `os/exec`, `path/filepath`, `regexp`, `sort`, `strconv`, `strings`. -- Tools: the `go` command (`go mod edit -json`), run without a shell. +- Standard library: `bytes`, `context`, `encoding/json`, `errors`, `fmt`, `go/ast`, `go/parser`, `go/token`, `go/types` (expression printing only), `os`, `os/exec`, `path/filepath`, `regexp`, `slices`, `sort`, `strconv`, `strings`. +- Tools: the `go` command (`go mod edit -json`, `go list -m -json`, and `go mod download -json` for a required module not in the module cache), run without a shell and with workspaces off. ## Testing @@ -81,4 +96,4 @@ if err := kiosk.Check(ctx, "plugins/acme/blog"); err != nil { go test ./modules/kiosk/... ``` -`TestManifestSmoke` copies the fixture plugins in `testdata/` to a temporary directory and generates, checks and edits their manifests. The tests run the local `go` command but need no network and no database. +`TestManifestSmoke` copies the fixture plugins in `testdata/` to a temporary directory and generates, checks and edits their manifests: a literal-ID plugin, a plugin whose identity is spread over constants of three packages and two modules, a nested plugin, the error cases, overrides, `kiosk.CheckT` and `kiosk.Validate`. Every fixture module replaces the framework with the stub in `testdata/framework`, so the tests run the local `go` command but need no network and no database. diff --git a/modules/kiosk/check.go b/modules/kiosk/check.go index 9dddd4d9..da4e6e7b 100644 --- a/modules/kiosk/check.go +++ b/modules/kiosk/check.go @@ -16,6 +16,9 @@ func Generate(ctx context.Context, dir string, opts DeriveOptions) ([]byte, []st if err != nil { return nil, warnings, err } + if err := Validate(m); err != nil { + return nil, warnings, err + } out, err := Marshal(m) if err != nil { return nil, warnings, err @@ -89,3 +92,21 @@ func Check(ctx context.Context, dir string) error { } return nil } + +// TB is the part of testing.TB that CheckT uses; *testing.T satisfies it. +type TB interface { + Helper() + Fatalf(format string, args ...any) +} + +// CheckT fails the test when dir's plugin.yaml is missing or stale. A +// plugin repository calls it from one test, so go test catches a manifest +// that no longer matches the code: +// +// func TestPluginManifest(t *testing.T) { kiosk.CheckT(t, ".") } +func CheckT(t TB, dir string) { + t.Helper() + if err := Check(context.Background(), dir); err != nil { + t.Fatalf("%v\nregenerate it with: summer plugin:manifest %s", err, dir) + } +} diff --git a/modules/kiosk/checkt_test.go b/modules/kiosk/checkt_test.go new file mode 100644 index 00000000..f70fda40 --- /dev/null +++ b/modules/kiosk/checkt_test.go @@ -0,0 +1,14 @@ +package kiosk_test + +import ( + "testing" + + "git.golem15.com/golem15/summercms/modules/kiosk" +) + +// TestPluginManifest is the test a plugin repository adds next to its +// plugin.go, with "." as the directory. Here it guards the committed +// manifest of the literal fixture. +func TestPluginManifest(t *testing.T) { + kiosk.CheckT(t, "testdata/literal") +} diff --git a/modules/kiosk/derive.go b/modules/kiosk/derive.go index faf623e0..798595bd 100644 --- a/modules/kiosk/derive.go +++ b/modules/kiosk/derive.go @@ -7,13 +7,10 @@ import ( "errors" "fmt" "go/ast" - "go/parser" - "go/token" "os" "os/exec" "path/filepath" "regexp" - "strconv" "strings" "github.com/yuin/goldmark" @@ -97,17 +94,19 @@ func Derive(ctx context.Context, dir string, opts DeriveOptions) (Manifest, []st } m.Framework = fw - ident, err := readLiteralIdentity(abs) + r := newResolver(ctx, abs) + r.modLoaded, r.root, r.mod = true, root, mod + ident, err := r.identity(abs) if err != nil { return Manifest{}, nil, err } m.ID = ident.ID - if len(ident.Requires) > 0 { - m.Requires = Requires{} - for _, id := range ident.Requires { - m.Requires[id] = "" - } + reqs, w, err := r.minimums(ident.Requires) + warnings = append(warnings, w...) + if err != nil { + return Manifest{}, warnings, err } + m.Requires = reqs existing, err := readExisting(abs) if err != nil { @@ -289,73 +288,6 @@ func plainText(n gast.Node, src []byte) string { return strings.Join(strings.Fields(b.String()), " ") } -// identity is what Task 2's reader returns: the literals of ID() and -// Requires(). -type identity struct { - ID string - Requires []string -} - -// readLiteralIdentity reads the string literals returned by ID() and -// Requires() in dir/plugin.go. -func readLiteralIdentity(dir string) (identity, error) { - path := filepath.Join(dir, "plugin.go") - fset := token.NewFileSet() - file, err := parser.ParseFile(fset, path, nil, 0) - if err != nil { - return identity{}, fmt.Errorf("kiosk: parse %s: %w", path, err) - } - var id identity - var idErr, reqErr error - foundID := false - for _, decl := range file.Decls { - fn, ok := decl.(*ast.FuncDecl) - if !ok || fn.Recv == nil || fn.Body == nil { - continue - } - ret := lastReturn(fn) - switch fn.Name.Name { - case "ID": - foundID = true - lit, ok := ret.(*ast.BasicLit) - if !ok || lit.Kind != token.STRING { - idErr = fmt.Errorf("kiosk: %s: ID() must return a string literal", path) - continue - } - id.ID, _ = strconv.Unquote(lit.Value) - case "Requires": - switch e := ret.(type) { - case *ast.Ident: - if e.Name != "nil" { - reqErr = fmt.Errorf("kiosk: %s: Requires() must return nil or a []string literal", path) - } - case *ast.CompositeLit: - for _, el := range e.Elts { - lit, ok := el.(*ast.BasicLit) - if !ok || lit.Kind != token.STRING { - reqErr = fmt.Errorf("kiosk: %s: Requires() must return string literals", path) - break - } - s, _ := strconv.Unquote(lit.Value) - id.Requires = append(id.Requires, s) - } - default: - reqErr = fmt.Errorf("kiosk: %s: Requires() must return nil or a []string literal", path) - } - } - } - if !foundID { - return identity{}, fmt.Errorf("kiosk: %s has no ID() method", path) - } - if idErr != nil { - return identity{}, idErr - } - if reqErr != nil { - return identity{}, reqErr - } - return id, nil -} - // lastReturn is the single result of fn's last top-level return, or nil. func lastReturn(fn *ast.FuncDecl) ast.Expr { var res ast.Expr diff --git a/modules/kiosk/identity.go b/modules/kiosk/identity.go new file mode 100644 index 00000000..c758824c --- /dev/null +++ b/modules/kiosk/identity.go @@ -0,0 +1,621 @@ +package kiosk + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "go/types" + "os" + "path/filepath" + "regexp" + "slices" + "strconv" + "strings" +) + +// Identity is what a plugin's ID() and Requires() return. +type Identity struct { + ID string + Requires []string +} + +// ReadIdentity reads the plugin ID and the required plugin IDs from the +// Go sources in dir without building or running them. ID() and Requires() +// may return string literals or constants: a const of the same package, of +// another package of the same module, or of a package in a module go.mod +// requires (located with `go list -m -json`). Requires() may also return +// nil or an empty slice. Any other expression is an error naming ID() or +// Requires(). When only Requires() fails, the returned Identity still +// carries the ID. +func ReadIdentity(ctx context.Context, dir string) (Identity, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return Identity{}, fmt.Errorf("kiosk: resolve %s: %w", dir, err) + } + r := newResolver(ctx, abs) + return r.identity(abs) +} + +// resolver evaluates string constants across packages and modules. Module +// facts are loaded on first use, so a plugin whose ID is a literal costs +// no go command. +type resolver struct { + ctx context.Context + start string + fset *token.FileSet + pkgs map[string]*pkgSrc + active map[string]bool + + modLoaded bool + modErr error + root string + mod goMod + modDirs map[string]listedModule +} + +// pkgSrc is one parsed package directory. +type pkgSrc struct { + name string + files map[string]*ast.File + order []string + consts map[string]constDef +} + +type constDef struct { + expr ast.Expr + file *ast.File +} + +// listedModule is the part of `go list -m -json` output the resolver reads. +type listedModule struct { + Path string + Version string + Dir string + GoMod string + Error *struct{ Err string } +} + +func newResolver(ctx context.Context, start string) *resolver { + return &resolver{ + ctx: ctx, + start: start, + fset: token.NewFileSet(), + pkgs: map[string]*pkgSrc{}, + active: map[string]bool{}, + modDirs: map[string]listedModule{}, + } +} + +func (r *resolver) identity(dir string) (Identity, error) { + pkg, err := r.load(dir) + if err != nil { + return Identity{}, err + } + idFn, idFile, idPath := pkg.method("ID", "") + if idFn == nil { + return Identity{}, fmt.Errorf("kiosk: %s has no ID() method", dir) + } + var id Identity + expr := lastReturn(idFn) + if expr == nil { + return Identity{}, fmt.Errorf("kiosk: %s: ID() must return a string literal or constant", idPath) + } + id.ID, err = r.str(dir, idFile, expr) + if err != nil { + return Identity{}, fmt.Errorf("kiosk: %s: ID() must return a string literal or constant: %w", idPath, err) + } + if id.ID == "" { + return Identity{}, fmt.Errorf("kiosk: %s: ID() returns an empty string", idPath) + } + reqFn, reqFile, reqPath := pkg.method("Requires", recvType(idFn)) + if reqFn == nil { + return id, nil + } + reqs, err := r.requires(dir, reqFile, lastReturn(reqFn)) + if err != nil { + return id, fmt.Errorf("kiosk: %s: Requires() must return nil or a []string of string literals or constants: %w", reqPath, err) + } + id.Requires = reqs + return id, nil +} + +func (r *resolver) requires(dir string, file *ast.File, expr ast.Expr) ([]string, error) { + switch e := expr.(type) { + case nil: + return nil, errors.New("no single-value return") + case *ast.Ident: + if e.Name == "nil" { + return nil, nil + } + case *ast.CompositeLit: + if at, ok := e.Type.(*ast.ArrayType); !ok || at.Len != nil || types.ExprString(at.Elt) != "string" { + break + } + out := make([]string, 0, len(e.Elts)) + for _, el := range e.Elts { + s, err := r.str(dir, file, el) + if err != nil { + return nil, err + } + out = append(out, s) + } + return out, nil + } + return nil, fmt.Errorf("%s is not a []string literal", types.ExprString(expr)) +} + +// str evaluates expr, found in file of package dir, to a string constant. +func (r *resolver) str(dir string, file *ast.File, expr ast.Expr) (string, error) { + switch e := expr.(type) { + case *ast.ParenExpr: + return r.str(dir, file, e.X) + case *ast.BasicLit: + if e.Kind == token.STRING { + return strconv.Unquote(e.Value) + } + case *ast.Ident: + return r.constant(dir, e.Name) + case *ast.SelectorExpr: + if x, ok := e.X.(*ast.Ident); ok { + pkgDir, err := r.importDir(dir, file, x.Name) + if err != nil { + return "", err + } + return r.constant(pkgDir, e.Sel.Name) + } + case *ast.BinaryExpr: + if e.Op == token.ADD { + a, err := r.str(dir, file, e.X) + if err != nil { + return "", err + } + b, err := r.str(dir, file, e.Y) + if err != nil { + return "", err + } + return a + b, nil + } + } + return "", fmt.Errorf("%s is not a string constant", types.ExprString(expr)) +} + +// constant resolves the package-level const name of package dir. +func (r *resolver) constant(dir, name string) (string, error) { + key := dir + "\x00" + name + if r.active[key] { + return "", fmt.Errorf("constant %s refers to itself", name) + } + r.active[key] = true + defer delete(r.active, key) + pkg, err := r.load(dir) + if err != nil { + return "", err + } + def, ok := pkg.consts[name] + if !ok { + return "", fmt.Errorf("%s is not a constant of package %s", name, pkg.name) + } + if def.expr == nil { + return "", fmt.Errorf("constant %s has no explicit value", name) + } + return r.str(dir, def.file, def.expr) +} + +// load parses the non-test Go files of dir once. +func (r *resolver) load(dir string) (*pkgSrc, error) { + if p, ok := r.pkgs[dir]; ok { + return p, nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("kiosk: read %s: %w", dir, err) + } + p := &pkgSrc{files: map[string]*ast.File{}, consts: map[string]constDef{}} + for _, e := range entries { + n := e.Name() + if e.IsDir() || !strings.HasSuffix(n, ".go") || strings.HasSuffix(n, "_test.go") { + continue + } + path := filepath.Join(dir, n) + f, err := parser.ParseFile(r.fset, path, nil, parser.SkipObjectResolution) + if err != nil { + return nil, fmt.Errorf("kiosk: parse %s: %w", path, err) + } + if p.name == "" { + p.name = f.Name.Name + } + p.files[path] = f + p.order = append(p.order, path) + for _, decl := range f.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.CONST { + continue + } + for _, spec := range gd.Specs { + vs := spec.(*ast.ValueSpec) + for i, name := range vs.Names { + def := constDef{file: f} + if i < len(vs.Values) { + def.expr = vs.Values[i] + } + p.consts[name.Name] = def + } + } + } + } + if len(p.order) == 0 { + return nil, fmt.Errorf("kiosk: %s has no Go files", dir) + } + // plugin.go first, then the other files in name order. + slices.SortStableFunc(p.order, func(a, b string) int { + ap, bp := filepath.Base(a) == "plugin.go", filepath.Base(b) == "plugin.go" + switch { + case ap && !bp: + return -1 + case bp && !ap: + return 1 + } + return strings.Compare(a, b) + }) + r.pkgs[dir] = p + return p, nil +} + +// method finds a method with a body named name, on the receiver type recv +// when recv is not empty, preferring plugin.go. +func (p *pkgSrc) method(name, recv string) (*ast.FuncDecl, *ast.File, string) { + for _, path := range p.order { + f := p.files[path] + for _, decl := range f.Decls { + fn, ok := decl.(*ast.FuncDecl) + if !ok || fn.Recv == nil || fn.Body == nil || fn.Name.Name != name { + continue + } + if recv != "" && recvType(fn) != recv { + continue + } + return fn, f, path + } + } + return nil, nil, "" +} + +// recvType is the receiver's type name without pointer or type parameters. +func recvType(fn *ast.FuncDecl) string { + if fn.Recv == nil || len(fn.Recv.List) == 0 { + return "" + } + t := fn.Recv.List[0].Type + if s, ok := t.(*ast.StarExpr); ok { + t = s.X + } + switch x := t.(type) { + case *ast.IndexExpr: + t = x.X + case *ast.IndexListExpr: + t = x.X + } + if id, ok := t.(*ast.Ident); ok { + return id.Name + } + return "" +} + +var majorSuffix = regexp.MustCompile(`^v[0-9]+$`) + +// importDir finds the directory of the package file imports as pkgName. +func (r *resolver) importDir(dir string, file *ast.File, pkgName string) (string, error) { + var unnamed []string + for _, imp := range file.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + continue + } + if imp.Name != nil { + if imp.Name.Name == pkgName { + return r.dirForImport(path) + } + continue + } + unnamed = append(unnamed, path) + } + // An unnamed import is usually named after its last path element. + for _, path := range unnamed { + if lastElem(path) == pkgName { + if d, err := r.dirForImport(path); err == nil { + if p, err := r.load(d); err == nil && p.name == pkgName { + return d, nil + } + } + } + } + // Otherwise read the package clause of every non-standard import. + for _, path := range unnamed { + first, _, _ := strings.Cut(path, "/") + if !strings.Contains(first, ".") || lastElem(path) == pkgName { + continue + } + d, err := r.dirForImport(path) + if err != nil { + continue + } + if p, err := r.load(d); err == nil && p.name == pkgName { + return d, nil + } + } + return "", fmt.Errorf("%s: no import provides package %s", r.fset.Position(file.Package).Filename, pkgName) +} + +func lastElem(path string) string { + parts := strings.Split(path, "/") + last := parts[len(parts)-1] + if majorSuffix.MatchString(last) && len(parts) > 1 { + last = parts[len(parts)-2] + } + return strings.TrimPrefix(last, "go-") +} + +// loadModule reads the module that contains the start directory. +func (r *resolver) loadModule() error { + if r.modLoaded { + return r.modErr + } + r.modLoaded = true + r.root, r.modErr = moduleRoot(r.start) + if r.modErr == nil { + r.mod, r.modErr = readGoMod(r.ctx, r.root) + } + return r.modErr +} + +// dirForImport maps an import path to a directory: inside this module, or +// inside a module go.mod requires. +func (r *resolver) dirForImport(path string) (string, error) { + if err := r.loadModule(); err != nil { + return "", err + } + if rest, ok := cutModule(path, r.mod.Module.Path); ok { + return filepath.Join(r.root, filepath.FromSlash(rest)), nil + } + best := "" + for _, req := range r.mod.Require { + if _, ok := cutModule(path, req.Path); ok && len(req.Path) > len(best) { + best = req.Path + } + } + if best == "" { + return "", fmt.Errorf("import %s is neither in module %s nor in a module its go.mod requires", path, r.mod.Module.Path) + } + mods, err := r.modules(best) + if err != nil { + return "", err + } + m := mods[0] + if m.Dir == "" { + return "", fmt.Errorf("module %s has no source directory", best) + } + rest, _ := cutModule(path, best) + return filepath.Join(m.Dir, filepath.FromSlash(rest)), nil +} + +// cutModule reports whether path is mod or inside it, and the rest. +func cutModule(path, mod string) (string, bool) { + if path == mod { + return "", true + } + if rest, ok := strings.CutPrefix(path, mod+"/"); ok { + return rest, true + } + return "", false +} + +// modules lists the given required modules with `go list -m -e -json` +// and downloads the ones whose source is not in the module cache yet. +func (r *resolver) modules(paths ...string) ([]listedModule, error) { + res, err := r.list(paths) + if err != nil { + return nil, err + } + return res, r.download(res) +} + +// list runs one `go list -m -e -json` for the paths not listed yet. +func (r *resolver) list(paths []string) ([]listedModule, error) { + if err := r.loadModule(); err != nil { + return nil, err + } + var missing []string + for _, p := range paths { + if _, ok := r.modDirs[p]; !ok { + missing = append(missing, p) + } + } + if len(missing) > 0 { + args := append([]string{"list", "-m", "-e", "-json", "--"}, missing...) + out, err := runGo(r.ctx, r.root, args...) + if err != nil { + return nil, err + } + dec := json.NewDecoder(bytes.NewReader(out)) + for dec.More() { + var m listedModule + if err := dec.Decode(&m); err != nil { + return nil, fmt.Errorf("kiosk: go list -m: %w", err) + } + r.modDirs[m.Path] = m + } + } + res := make([]listedModule, 0, len(paths)) + for _, p := range paths { + m := r.modDirs[p] + if m.Path == "" { + m.Path = p + } + res = append(res, m) + } + return res, nil +} + +// download fills Dir for listed modules whose source is not in the module +// cache, with one `go mod download -json` run. +func (r *resolver) download(mods []listedModule) error { + var args []string + for _, m := range mods { + if m.Dir == "" && m.Error == nil && m.Version != "" { + args = append(args, m.Path+"@"+m.Version) + } + } + if len(args) == 0 { + return nil + } + out, err := runGo(r.ctx, r.root, append([]string{"mod", "download", "-json", "--"}, args...)...) + if err != nil { + return err + } + dec := json.NewDecoder(bytes.NewReader(out)) + for dec.More() { + var d listedModule + if err := dec.Decode(&d); err != nil { + return fmt.Errorf("kiosk: go mod download: %w", err) + } + if m, ok := r.modDirs[d.Path]; ok && d.Dir != "" { + m.Dir = d.Dir + r.modDirs[d.Path] = m + } + } + for i := range mods { + mods[i] = r.modDirs[mods[i].Path] + } + return nil +} + +// minimums maps each required plugin ID to its minimum version. A plugin +// of the same module gets "", one found in a go.mod-required module gets +// that require's version, and one found nowhere gets "". +func (r *resolver) minimums(ids []string) (Requires, []string, error) { + if len(ids) == 0 { + return nil, nil, nil + } + if err := r.loadModule(); err != nil { + return nil, nil, err + } + own := map[string]bool{} + for _, d := range pluginDirs(r.root) { + if id, _ := r.identity(d); id.ID != "" { + own[id.ID] = true + } + } + provider := map[string]string{} + pending := 0 + for _, id := range ids { + if !own[id] { + pending++ + } + } + var warnings []string + if pending > 0 { + var paths []string + for _, req := range r.mod.Require { + if req.Path != FrameworkModule { + paths = append(paths, req.Path) + } + } + listed, err := r.list(paths) + if err != nil { + return nil, nil, err + } + // Only a module whose go.mod requires the framework can hold a plugin. + var candidates []listedModule + for _, m := range listed { + if m.Error == nil && requiresFramework(m) { + candidates = append(candidates, m) + } + } + if err := r.download(candidates); err != nil { + warnings = append(warnings, fmt.Sprintf("required plugins not matched to modules: %v", err)) + } + for _, m := range candidates { + m = r.modDirs[m.Path] + if m.Dir == "" { + continue + } + for _, d := range pluginDirs(m.Dir) { + if id, _ := ReadIdentity(r.ctx, d); id.ID != "" { + if _, seen := provider[id.ID]; !seen { + provider[id.ID] = m.Path + } + } + } + } + } + out := Requires{} + for _, id := range ids { + if own[id] { + out[id] = "" + continue + } + modPath, ok := provider[id] + if !ok { + out[id] = "" + continue + } + v, _ := r.mod.version(modPath) + switch { + case v == "v0.0.0": + return nil, warnings, fmt.Errorf("kiosk: %s requires %s at v0.0.0 (module of %s), which is not a released version; require a tag", filepath.Join(r.root, "go.mod"), modPath, id) + case isPseudoVersion(v): + warnings = append(warnings, fmt.Sprintf("%s minimum %s is a pseudo-version; require a tag of %s before publishing", id, v, modPath)) + } + out[id] = v + } + return out, warnings, nil +} + +// requiresFramework reports whether the module's go.mod names the +// framework module. +func requiresFramework(m listedModule) bool { + path := m.GoMod + if path == "" && m.Dir != "" { + path = filepath.Join(m.Dir, "go.mod") + } + if path == "" { + return false + } + data, err := os.ReadFile(path) + return err == nil && bytes.Contains(data, []byte(FrameworkModule)) +} + +// pluginDirs lists the directories under root that hold a plugin.go, +// skipping hidden, testdata, vendor and node_modules directories and +// nested modules. +func pluginDirs(root string) []string { + var dirs []string + _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + if path == root { + return nil + } + n := d.Name() + if strings.HasPrefix(n, ".") || strings.HasPrefix(n, "_") || n == "testdata" || n == "vendor" || n == "node_modules" { + return filepath.SkipDir + } + if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil { + return filepath.SkipDir + } + return nil + } + if d.Name() == "plugin.go" { + dirs = append(dirs, filepath.Dir(path)) + } + return nil + }) + return dirs +} diff --git a/modules/kiosk/manifest.go b/modules/kiosk/manifest.go index 783b2570..3d6d72b9 100644 --- a/modules/kiosk/manifest.go +++ b/modules/kiosk/manifest.go @@ -3,7 +3,9 @@ package kiosk import ( "bytes" "fmt" + "regexp" "sort" + "strings" "github.com/goccy/go-yaml" ) @@ -86,5 +88,76 @@ func Parse(data []byte) (Manifest, error) { if m.Schema > SchemaVersion { return Manifest{}, fmt.Errorf("kiosk: %s has schema %d, this summer reads up to %d; update summer", FileName, m.Schema, SchemaVersion) } + if err := Validate(m); err != nil { + return Manifest{}, err + } return m, nil } + +var ( + pluginIDRe = regexp.MustCompile(`^[a-z][a-z0-9]*\.[a-z][a-z0-9]*$`) + releaseVersion = regexp.MustCompile(`^v\d+\.\d+\.\d+$`) +) + +// Validate checks a manifest: the schema is between 1 and SchemaVersion, +// the ID and every required ID are lowercase vendor.plugin, module and +// package are plain module paths (package inside module), name and +// description are set, and the framework and every non-empty plugin +// minimum is a vX.Y.Z release or a Go pseudo-version. +func Validate(m Manifest) error { + if m.Schema < 1 || m.Schema > SchemaVersion { + return fmt.Errorf("kiosk: schema %d is not between 1 and %d", m.Schema, SchemaVersion) + } + if !pluginIDRe.MatchString(m.ID) { + return fmt.Errorf("kiosk: id %q must be lowercase vendor.plugin", m.ID) + } + if err := validPath(m.Module); err != nil { + return fmt.Errorf("kiosk: module: %w", err) + } + if err := validPath(m.Package); err != nil { + return fmt.Errorf("kiosk: package: %w", err) + } + if m.Package != m.Module && !strings.HasPrefix(m.Package, m.Module+"/") { + return fmt.Errorf("kiosk: package %q is not inside module %q", m.Package, m.Module) + } + if strings.TrimSpace(m.Name) == "" { + return fmt.Errorf("kiosk: name is empty") + } + if strings.TrimSpace(m.Description) == "" { + return fmt.Errorf("kiosk: description is empty") + } + if !releaseOrPseudo(m.Framework) { + return fmt.Errorf("kiosk: framework %q must be a vX.Y.Z release", m.Framework) + } + for id, v := range m.Requires { + if !pluginIDRe.MatchString(id) { + return fmt.Errorf("kiosk: required id %q must be lowercase vendor.plugin", id) + } + if v != "" && !releaseOrPseudo(v) { + return fmt.Errorf("kiosk: minimum %q of %s must be a vX.Y.Z release", v, id) + } + } + return nil +} + +func releaseOrPseudo(v string) bool { + return releaseVersion.MatchString(v) || isPseudoVersion(v) +} + +// validPath accepts a slash-separated module or import path. +func validPath(p string) error { + switch { + case p == "": + return fmt.Errorf("is empty") + case strings.HasPrefix(p, "-"): + return fmt.Errorf("%q must not start with -", p) + case strings.ContainsAny(p, " \t\r\n\\\"'`$;&|*?<>()[]{}@"): + return fmt.Errorf("%q contains invalid characters", p) + } + for _, seg := range strings.Split(p, "/") { + if seg == "" || seg == "." || seg == ".." { + return fmt.Errorf("%q has an invalid path segment", p) + } + } + return nil +} diff --git a/modules/kiosk/manifest_smoke_test.go b/modules/kiosk/manifest_smoke_test.go index 9fc0d4f3..eb4703a6 100644 --- a/modules/kiosk/manifest_smoke_test.go +++ b/modules/kiosk/manifest_smoke_test.go @@ -3,6 +3,7 @@ package kiosk import ( "context" "errors" + "fmt" "io/fs" "os" "path/filepath" @@ -123,4 +124,240 @@ func TestManifestSmoke(t *testing.T) { t.Error("a missing StaleError carries no Want bytes") } }) + + t.Run("consts", func(t *testing.T) { + base := copyTestdata(t, "framework", "other", "consts") + dir := filepath.Join(base, "consts") + id, err := ReadIdentity(ctx, dir) + if err != nil { + t.Fatalf("ReadIdentity: %v", err) + } + if id.ID != "acme.consts" || strings.Join(id.Requires, ",") != "acme.translate,acme.other" { + t.Fatalf("identity = %+v", id) + } + m, warnings, err := Derive(ctx, dir, DeriveOptions{}) + if err != nil { + t.Fatalf("Derive: %v", err) + } + if len(warnings) != 0 { + t.Errorf("warnings = %v", warnings) + } + if m.Requires["acme.other"] != "v0.2.0" { + t.Errorf("acme.other minimum = %q, want the go.mod require v0.2.0", m.Requires["acme.other"]) + } + if v, ok := m.Requires["acme.translate"]; !ok || v != "" { + t.Errorf("acme.translate minimum = %q (present %v), want empty", v, ok) + } + if m.Package != "example.test/sm-consts-plugin" || m.Framework != "v0.5.3" { + t.Errorf("manifest = %+v", m) + } + }) + + t.Run("nested", func(t *testing.T) { + base := copyTestdata(t, "framework", "other", "consts") + m, _, err := Derive(ctx, filepath.Join(base, "consts", "nested"), DeriveOptions{}) + if err != nil { + t.Fatalf("Derive: %v", err) + } + if m.ID != "acme.constsnested" || m.Module != "example.test/sm-consts-plugin" || m.Package != "example.test/sm-consts-plugin/nested" { + t.Errorf("manifest = %+v", m) + } + if v, ok := m.Requires["acme.consts"]; !ok || v != "" || len(m.Requires) != 1 { + t.Errorf("requires = %v, want acme.consts with the empty minimum", m.Requires) + } + }) + + t.Run("errors", func(t *testing.T) { + cases := []struct { + name, src, want string + }{ + {"id call", `func (*Plugin) ID() string { return id() } +func id() string { return "acme.x" } +func (*Plugin) Requires() []string { return nil }`, "ID()"}, + {"id var", `var pluginID = "acme.x" +func (*Plugin) ID() string { return pluginID } +func (*Plugin) Requires() []string { return nil }`, "ID()"}, + {"id concat", `func suffix() string { return "x" } +func (*Plugin) ID() string { return "acme." + suffix() } +func (*Plugin) Requires() []string { return nil }`, "ID()"}, + {"requires var", `var deps = []string{"acme.y"} +func (*Plugin) ID() string { return "acme.x" } +func (*Plugin) Requires() []string { return deps }`, "Requires()"}, + {"requires call", `func (*Plugin) ID() string { return "acme.x" } +func (*Plugin) Requires() []string { return append([]string(nil), "acme.y") }`, "Requires()"}, + {"requires element", `var dep = "acme.y" +func (*Plugin) ID() string { return "acme.x" } +func (*Plugin) Requires() []string { return []string{dep} }`, "Requires()"}, + } + for _, tc := range cases { + dir := t.TempDir() + src := "package p\n\ntype Plugin struct{}\n\n" + tc.src + "\n" + if err := os.WriteFile(filepath.Join(dir, "plugin.go"), []byte(src), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.test/p\n\ngo 1.27.0\n"), 0o644); err != nil { + t.Fatal(err) + } + _, err := ReadIdentity(ctx, dir) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s: err = %v, want one naming %s", tc.name, err, tc.want) + } + } + + // A plugin requirement at v0.0.0 is refused; a pseudo-version warns. + base := copyTestdata(t, "framework", "other", "consts") + dir := filepath.Join(base, "consts") + gomod := filepath.Join(dir, "go.mod") + orig, _ := os.ReadFile(gomod) + setOther := func(v string) { + t.Helper() + data := strings.Replace(string(orig), "example.test/sm-other-plugin v0.2.0", "example.test/sm-other-plugin "+v, 1) + if err := os.WriteFile(gomod, []byte(data), 0o644); err != nil { + t.Fatal(err) + } + } + setOther("v0.0.0") + if _, _, err := Derive(ctx, dir, DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "not a released version") { + t.Errorf("v0.0.0 requirement: err = %v", err) + } + pseudo := "v0.2.1-0.20261029101500-abcdefabcdef" + setOther(pseudo) + m, warnings, err := Derive(ctx, dir, DeriveOptions{}) + if err != nil { + t.Fatalf("pseudo-version requirement: %v", err) + } + if m.Requires["acme.other"] != pseudo || len(warnings) != 1 || !strings.Contains(warnings[0], "pseudo-version") { + t.Errorf("pseudo-version: requires = %v, warnings = %v", m.Requires, warnings) + } + + // The framework require is mandatory and must be released. + lit := filepath.Join(copyTestdata(t, "literal"), "literal") + litMod := filepath.Join(lit, "go.mod") + if err := os.WriteFile(litMod, []byte("module example.test/sm-acme-plugin\n\ngo 1.27.0\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := Derive(ctx, lit, DeriveOptions{}); err == nil || !strings.Contains(err.Error(), FrameworkModule) { + t.Errorf("missing framework require: err = %v", err) + } + if err := os.WriteFile(litMod, []byte("module example.test/sm-acme-plugin\n\ngo 1.27.0\n\nrequire "+FrameworkModule+" v0.0.0\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := Derive(ctx, lit, DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "not a released version") { + t.Errorf("framework v0.0.0: err = %v", err) + } + }) + + t.Run("overrides", func(t *testing.T) { + dir := filepath.Join(copyTestdata(t, "literal"), "literal") + readme := filepath.Join(dir, "README.md") + src, _ := os.ReadFile(readme) + if err := os.WriteFile(readme, []byte(strings.Replace(string(src), "# Blog", "# acme.blog", 1)), 0o644); err != nil { + t.Fatal(err) + } + if _, _, err := Write(ctx, dir, DeriveOptions{}); err == nil || !strings.Contains(err.Error(), "--name") { + t.Fatalf("H1 equal to the ID: err = %v, want one naming --name", err) + } + name, desc := "Acme Blog", "A blog." + if _, _, err := Write(ctx, dir, DeriveOptions{Name: &name, Description: &desc}); err != nil { + t.Fatalf("Write with overrides: %v", err) + } + if _, _, err := Write(ctx, dir, DeriveOptions{}); err != nil { + t.Fatalf("regeneration without flags: %v", err) + } + raw, _ := os.ReadFile(filepath.Join(dir, FileName)) + m, err := Parse(raw) + if err != nil { + t.Fatal(err) + } + if m.Name != name || m.Description != desc || m.Overrides == nil || m.Overrides.Name != name || m.Overrides.Description != desc { + t.Fatalf("after regeneration = %+v (overrides %+v)", m, m.Overrides) + } + if err := Check(ctx, dir); err != nil { + t.Fatalf("Check keeps the overrides: %v", err) + } + empty := "" + if _, _, err := Write(ctx, dir, DeriveOptions{Description: &empty}); err != nil { + t.Fatalf("clearing the description: %v", err) + } + raw, _ = os.ReadFile(filepath.Join(dir, FileName)) + if m, _ = Parse(raw); m.Description != "Posts and categories for an acme application, with a feed." || m.Overrides.Description != "" || m.Overrides.Name != name { + t.Fatalf("after clearing the description = %+v (overrides %+v)", m, m.Overrides) + } + if _, _, err := Write(ctx, dir, DeriveOptions{Name: &empty}); err == nil || !strings.Contains(err.Error(), "--name") { + t.Fatalf("clearing the name with H1 == ID: err = %v, want one naming --name", err) + } + }) + + t.Run("checkt", func(t *testing.T) { + dir := filepath.Join(copyTestdata(t, "literal"), "literal") + if _, _, err := Write(ctx, dir, DeriveOptions{}); err != nil { + t.Fatal(err) + } + var fresh fakeTB + CheckT(&fresh, dir) + if len(fresh.fatals) != 0 { + t.Fatalf("CheckT on a fresh manifest failed: %v", fresh.fatals) + } + readme := filepath.Join(dir, "README.md") + src, _ := os.ReadFile(readme) + if err := os.WriteFile(readme, []byte(strings.Replace(string(src), "# Blog", "# Weblog", 1)), 0o644); err != nil { + t.Fatal(err) + } + var stale fakeTB + CheckT(&stale, dir) + if len(stale.fatals) != 1 || !strings.Contains(stale.fatals[0], filepath.Join(dir, FileName)) || !strings.Contains(stale.fatals[0], "summer plugin:manifest") { + t.Fatalf("CheckT on a stale manifest: %q", stale.fatals) + } + if stale.helpers == 0 { + t.Error("CheckT did not call Helper") + } + }) + + t.Run("validate", func(t *testing.T) { + good := Manifest{Schema: 1, ID: "acme.blog", Module: "example.test/m", Package: "example.test/m/sub", + Name: "Blog", Description: "A blog.", Framework: "v0.6.0", Requires: Requires{"acme.x": "", "acme.y": "v0.1.0"}} + if err := Validate(good); err != nil { + t.Fatalf("Validate(good) = %v", err) + } + bad := map[string]func(*Manifest){ + "id": func(m *Manifest) { m.ID = "Acme.Blog" }, + "module dash": func(m *Manifest) { m.Module = "-example.test/m"; m.Package = m.Module }, + "package outside": func(m *Manifest) { m.Package = "example.test/other" }, + "framework": func(m *Manifest) { m.Framework = "0.6.0" }, + "framework rc": func(m *Manifest) { m.Framework = "v0.6.0-rc1" }, + "schema": func(m *Manifest) { m.Schema = SchemaVersion + 1 }, + "schema zero": func(m *Manifest) { m.Schema = 0 }, + "required id": func(m *Manifest) { m.Requires = Requires{"bad": ""} }, + "minimum": func(m *Manifest) { m.Requires = Requires{"acme.x": "latest"} }, + "name": func(m *Manifest) { m.Name = " " }, + } + for name, mutate := range bad { + m := good + m.Requires = Requires{} + mutate(&m) + if err := Validate(m); err == nil { + t.Errorf("Validate accepted a bad %s: %+v", name, m) + } + } + raw, err := Marshal(good) + if err != nil { + t.Fatal(err) + } + newer := strings.Replace(string(raw), "schema: 1", "schema: 2", 1) + if _, err := Parse([]byte(newer)); err == nil || !strings.Contains(err.Error(), "update summer") { + t.Errorf("Parse of a newer schema: err = %v", err) + } + }) +} + +// fakeTB records CheckT's calls. +type fakeTB struct { + helpers int + fatals []string +} + +func (f *fakeTB) Helper() { f.helpers++ } + +func (f *fakeTB) Fatalf(format string, args ...any) { + f.fatals = append(f.fatals, fmt.Sprintf(format, args...)) } diff --git a/modules/kiosk/testdata/consts/README.md b/modules/kiosk/testdata/consts/README.md new file mode 100644 index 00000000..597b6f0b --- /dev/null +++ b/modules/kiosk/testdata/consts/README.md @@ -0,0 +1,3 @@ +# Consts + +A fixture plugin whose identity is spread over constants. diff --git a/modules/kiosk/testdata/consts/go.mod b/modules/kiosk/testdata/consts/go.mod new file mode 100644 index 00000000..c08c01e8 --- /dev/null +++ b/modules/kiosk/testdata/consts/go.mod @@ -0,0 +1,12 @@ +module example.test/sm-consts-plugin + +go 1.27.0 + +require ( + example.test/sm-other-plugin v0.2.0 + git.golem15.com/golem15/summercms v0.5.3 +) + +replace git.golem15.com/golem15/summercms => ../framework + +replace example.test/sm-other-plugin => ../other diff --git a/modules/kiosk/testdata/consts/ids/ids.go b/modules/kiosk/testdata/consts/ids/ids.go new file mode 100644 index 00000000..443bc339 --- /dev/null +++ b/modules/kiosk/testdata/consts/ids/ids.go @@ -0,0 +1,5 @@ +// Package ids holds plugin ID constants for the consts fixture. +package ids + +// Translate is a plugin ID no module of the fixture provides. +const Translate = "acme.translate" diff --git a/modules/kiosk/testdata/consts/nested/README.md b/modules/kiosk/testdata/consts/nested/README.md new file mode 100644 index 00000000..76ad12f7 --- /dev/null +++ b/modules/kiosk/testdata/consts/nested/README.md @@ -0,0 +1,3 @@ +# Nested consts + +The nested fixture plugin of the consts module. diff --git a/modules/kiosk/testdata/consts/nested/plugin.go b/modules/kiosk/testdata/consts/nested/plugin.go new file mode 100644 index 00000000..676dea35 --- /dev/null +++ b/modules/kiosk/testdata/consts/nested/plugin.go @@ -0,0 +1,14 @@ +// Package nested is a test fixture: a second plugin in a subdirectory of +// the consts module, requiring the root plugin. It is never built. +package nested + +import root "example.test/sm-consts-plugin" + +// Plugin is the fixture plugin. +type Plugin struct{} + +// ID returns the plugin ID. +func (*Plugin) ID() string { return "acme.constsnested" } + +// Requires the root plugin of the same module. +func (*Plugin) Requires() []string { return []string{root.PluginID} } diff --git a/modules/kiosk/testdata/consts/plugin.go b/modules/kiosk/testdata/consts/plugin.go new file mode 100644 index 00000000..6eab6ade --- /dev/null +++ b/modules/kiosk/testdata/consts/plugin.go @@ -0,0 +1,24 @@ +// Package consts is a test fixture: ID() and Requires() return constants +// of the same package, of another package in the module and of a package +// in another module. kiosk reads it with go/parser; it is never built. +package consts + +import ( + "example.test/sm-consts-plugin/ids" + "example.test/sm-other-plugin" +) + +// PluginID is the plugin ID, built from two constants. +const PluginID = vendor + ".consts" + +// Plugin is the fixture plugin. +type Plugin struct{} + +// ID returns PluginID. +func (*Plugin) ID() string { return PluginID } + +// Requires a plugin whose module is not required (acme.translate) and the +// plugin of the other module. +func (*Plugin) Requires() []string { + return []string{ids.Translate, other.PluginID} +} diff --git a/modules/kiosk/testdata/consts/vendor.go b/modules/kiosk/testdata/consts/vendor.go new file mode 100644 index 00000000..c83cae2c --- /dev/null +++ b/modules/kiosk/testdata/consts/vendor.go @@ -0,0 +1,3 @@ +package consts + +const vendor = "acme" diff --git a/modules/kiosk/testdata/framework/go.mod b/modules/kiosk/testdata/framework/go.mod new file mode 100644 index 00000000..3d84e593 --- /dev/null +++ b/modules/kiosk/testdata/framework/go.mod @@ -0,0 +1,5 @@ +// A stub of the framework module: fixture go.mod files replace the +// framework with this directory, so go list runs offline. +module git.golem15.com/golem15/summercms + +go 1.27.0 diff --git a/modules/kiosk/testdata/literal/plugin.yaml b/modules/kiosk/testdata/literal/plugin.yaml new file mode 100644 index 00000000..aa13f348 --- /dev/null +++ b/modules/kiosk/testdata/literal/plugin.yaml @@ -0,0 +1,8 @@ +# Generated by summer plugin:manifest. Do not edit; rerun the command. +schema: 1 +id: acme.blog +module: example.test/sm-acme-plugin +package: example.test/sm-acme-plugin +name: Blog +description: Posts and categories for an acme application, with a feed. +framework: v0.5.3 diff --git a/modules/kiosk/testdata/other/README.md b/modules/kiosk/testdata/other/README.md new file mode 100644 index 00000000..2759a1c5 --- /dev/null +++ b/modules/kiosk/testdata/other/README.md @@ -0,0 +1,3 @@ +# Other + +A plugin in another module that the consts fixture requires. diff --git a/modules/kiosk/testdata/other/go.mod b/modules/kiosk/testdata/other/go.mod new file mode 100644 index 00000000..6172b213 --- /dev/null +++ b/modules/kiosk/testdata/other/go.mod @@ -0,0 +1,7 @@ +module example.test/sm-other-plugin + +go 1.27.0 + +require git.golem15.com/golem15/summercms v0.5.3 + +replace git.golem15.com/golem15/summercms => ../framework diff --git a/modules/kiosk/testdata/other/plugin.go b/modules/kiosk/testdata/other/plugin.go new file mode 100644 index 00000000..989e6c04 --- /dev/null +++ b/modules/kiosk/testdata/other/plugin.go @@ -0,0 +1,15 @@ +// Package other is a test fixture: a plugin in its own module that the +// consts fixture requires. It is never built. +package other + +// PluginID is the plugin ID. +const PluginID = "acme.other" + +// Plugin is the fixture plugin. +type Plugin struct{} + +// ID returns PluginID. +func (*Plugin) ID() string { return PluginID } + +// Requires returns no plugins. +func (*Plugin) Requires() []string { return []string{} }