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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user