feat(kiosk): resolve constant plugin IDs, overrides and the CheckT helper
- kiosk.ReadIdentity: ID()/Requires() as literals or constants across packages and modules, read with go/parser, never run - plugin minimums from go.mod requires; v0.0.0 is an error, a pseudo-version warns - kiosk.Validate, kiosk.CheckT (no testing import) - plugin:add reads IDs through kiosk.ReadIdentity, so const-ID plugins register
This commit is contained in:
@@ -32,13 +32,13 @@ overrides:
|
|||||||
| Field | Comes from |
|
| 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`. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -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 {
|
func copyHelloApp(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
src, err := filepath.Abs(filepath.Join("..", "..", "examples", "hello"))
|
src, err := filepath.Abs(filepath.Join("..", "..", "examples", "hello"))
|
||||||
|
|||||||
@@ -5,19 +5,16 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"go/ast"
|
|
||||||
"go/format"
|
"go/format"
|
||||||
"go/parser"
|
|
||||||
"go/token"
|
|
||||||
"go/version"
|
"go/version"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||||
|
"git.golem15.com/golem15/summercms/modules/kiosk"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed stubs/*.tmpl
|
//go:embed stubs/*.tmpl
|
||||||
@@ -357,38 +354,19 @@ func frameworkReplaceFor(pluginDir, appDir string) (string, error) {
|
|||||||
return filepath.ToSlash(rel), nil
|
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) {
|
func pluginIDFromGo(path string) (string, error) {
|
||||||
fset := token.NewFileSet()
|
if _, err := os.Stat(path); err != nil {
|
||||||
file, err := parser.ParseFile(fset, path, nil, 0)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("build: parse %s: %w", path, err)
|
return "", fmt.Errorf("build: parse %s: %w", path, err)
|
||||||
}
|
}
|
||||||
var id string
|
id, err := kiosk.ReadIdentity(context.Background(), filepath.Dir(path))
|
||||||
ast.Inspect(file, func(n ast.Node) bool {
|
if id.ID != "" {
|
||||||
fn, ok := n.(*ast.FuncDecl)
|
// A Requires() that kiosk cannot read does not hide the ID.
|
||||||
if !ok || fn.Name == nil || fn.Name.Name != "ID" || fn.Recv == nil || fn.Body == nil {
|
return id.ID, 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)
|
|
||||||
}
|
}
|
||||||
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 {
|
func editAppModule(ctx context.Context, appDir, pluginDir, modPath string) error {
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ WinterCMS keeps this data in a plugin's `Plugin.php` `pluginDetails()` and in `c
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- One `plugin.yaml` per plugin package directory, so a repository with a nested plugin carries two files.
|
- 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.
|
- 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.
|
- 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
|
## 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
|
## API reference
|
||||||
|
|
||||||
| Identifier | Description |
|
| 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.Generate` | `kiosk.Derive` plus `kiosk.Marshal`: the bytes `kiosk.Write` would write. |
|
||||||
| `kiosk.Write` | Generates and writes `<dir>/plugin.yaml` and returns its path. |
|
| `kiosk.Write` | Generates and writes `<dir>/plugin.yaml` and returns its path. |
|
||||||
| `kiosk.Check` | Returns a `kiosk.StaleError` when `<dir>/plugin.yaml` is missing or differs from a fresh generation. |
|
| `kiosk.Check` | Returns a `kiosk.StaleError` when `<dir>/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. |
|
| `kiosk.StaleError` | `Path`, `Missing`, and the generated (`Want`) and committed (`Got`) bytes; its message names the first differing line. |
|
||||||
|
|
||||||
## CLI commands
|
## CLI commands
|
||||||
@@ -72,8 +87,8 @@ if err := kiosk.Check(ctx, "plugins/acme/blog"); err != nil {
|
|||||||
|
|
||||||
- SummerCMS modules: none.
|
- SummerCMS modules: none.
|
||||||
- Third-party: `github.com/goccy/go-yaml` (manifest YAML), `github.com/yuin/goldmark` (README headings and paragraphs).
|
- 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`.
|
- 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`), run without a shell.
|
- 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
|
## Testing
|
||||||
|
|
||||||
@@ -81,4 +96,4 @@ if err := kiosk.Check(ctx, "plugins/acme/blog"); err != nil {
|
|||||||
go test ./modules/kiosk/...
|
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.
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ func Generate(ctx context.Context, dir string, opts DeriveOptions) ([]byte, []st
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, warnings, err
|
return nil, warnings, err
|
||||||
}
|
}
|
||||||
|
if err := Validate(m); err != nil {
|
||||||
|
return nil, warnings, err
|
||||||
|
}
|
||||||
out, err := Marshal(m)
|
out, err := Marshal(m)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, warnings, err
|
return nil, warnings, err
|
||||||
@@ -89,3 +92,21 @@ func Check(ctx context.Context, dir string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
14
modules/kiosk/checkt_test.go
Normal file
14
modules/kiosk/checkt_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
@@ -7,13 +7,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"go/ast"
|
"go/ast"
|
||||||
"go/parser"
|
|
||||||
"go/token"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/yuin/goldmark"
|
"github.com/yuin/goldmark"
|
||||||
@@ -97,17 +94,19 @@ func Derive(ctx context.Context, dir string, opts DeriveOptions) (Manifest, []st
|
|||||||
}
|
}
|
||||||
m.Framework = fw
|
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 {
|
if err != nil {
|
||||||
return Manifest{}, nil, err
|
return Manifest{}, nil, err
|
||||||
}
|
}
|
||||||
m.ID = ident.ID
|
m.ID = ident.ID
|
||||||
if len(ident.Requires) > 0 {
|
reqs, w, err := r.minimums(ident.Requires)
|
||||||
m.Requires = Requires{}
|
warnings = append(warnings, w...)
|
||||||
for _, id := range ident.Requires {
|
if err != nil {
|
||||||
m.Requires[id] = ""
|
return Manifest{}, warnings, err
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
m.Requires = reqs
|
||||||
|
|
||||||
existing, err := readExisting(abs)
|
existing, err := readExisting(abs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -289,73 +288,6 @@ func plainText(n gast.Node, src []byte) string {
|
|||||||
return strings.Join(strings.Fields(b.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.
|
// lastReturn is the single result of fn's last top-level return, or nil.
|
||||||
func lastReturn(fn *ast.FuncDecl) ast.Expr {
|
func lastReturn(fn *ast.FuncDecl) ast.Expr {
|
||||||
var res ast.Expr
|
var res ast.Expr
|
||||||
|
|||||||
621
modules/kiosk/identity.go
Normal file
621
modules/kiosk/identity.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -3,7 +3,9 @@ package kiosk
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/goccy/go-yaml"
|
"github.com/goccy/go-yaml"
|
||||||
)
|
)
|
||||||
@@ -86,5 +88,76 @@ func Parse(data []byte) (Manifest, error) {
|
|||||||
if m.Schema > SchemaVersion {
|
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)
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package kiosk
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -123,4 +124,240 @@ func TestManifestSmoke(t *testing.T) {
|
|||||||
t.Error("a missing StaleError carries no Want bytes")
|
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...))
|
||||||
}
|
}
|
||||||
|
|||||||
3
modules/kiosk/testdata/consts/README.md
vendored
Normal file
3
modules/kiosk/testdata/consts/README.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Consts
|
||||||
|
|
||||||
|
A fixture plugin whose identity is spread over constants.
|
||||||
12
modules/kiosk/testdata/consts/go.mod
vendored
Normal file
12
modules/kiosk/testdata/consts/go.mod
vendored
Normal file
@@ -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
|
||||||
5
modules/kiosk/testdata/consts/ids/ids.go
vendored
Normal file
5
modules/kiosk/testdata/consts/ids/ids.go
vendored
Normal file
@@ -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"
|
||||||
3
modules/kiosk/testdata/consts/nested/README.md
vendored
Normal file
3
modules/kiosk/testdata/consts/nested/README.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Nested consts
|
||||||
|
|
||||||
|
The nested fixture plugin of the consts module.
|
||||||
14
modules/kiosk/testdata/consts/nested/plugin.go
vendored
Normal file
14
modules/kiosk/testdata/consts/nested/plugin.go
vendored
Normal file
@@ -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} }
|
||||||
24
modules/kiosk/testdata/consts/plugin.go
vendored
Normal file
24
modules/kiosk/testdata/consts/plugin.go
vendored
Normal file
@@ -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}
|
||||||
|
}
|
||||||
3
modules/kiosk/testdata/consts/vendor.go
vendored
Normal file
3
modules/kiosk/testdata/consts/vendor.go
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package consts
|
||||||
|
|
||||||
|
const vendor = "acme"
|
||||||
5
modules/kiosk/testdata/framework/go.mod
vendored
Normal file
5
modules/kiosk/testdata/framework/go.mod
vendored
Normal file
@@ -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
|
||||||
8
modules/kiosk/testdata/literal/plugin.yaml
vendored
Normal file
8
modules/kiosk/testdata/literal/plugin.yaml
vendored
Normal file
@@ -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
|
||||||
3
modules/kiosk/testdata/other/README.md
vendored
Normal file
3
modules/kiosk/testdata/other/README.md
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Other
|
||||||
|
|
||||||
|
A plugin in another module that the consts fixture requires.
|
||||||
7
modules/kiosk/testdata/other/go.mod
vendored
Normal file
7
modules/kiosk/testdata/other/go.mod
vendored
Normal file
@@ -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
|
||||||
15
modules/kiosk/testdata/other/plugin.go
vendored
Normal file
15
modules/kiosk/testdata/other/plugin.go
vendored
Normal file
@@ -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{} }
|
||||||
Reference in New Issue
Block a user