Files
2026-09-26 16:32:40 +03:00

384 lines
10 KiB
Go

package main
import (
"bytes"
"errors"
"fmt"
"go/ast"
"go/parser"
"go/token"
"go/types"
"io/fs"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)
type rule struct {
pkg string
allow []string
external []string
}
var rules = []rule{
{"core/...", []string{"core/..."}, nil},
{"schema", []string{"core/..."}, nil},
{"format/...", []string{"core/...", "schema"}, nil},
{"platform", []string{"core/...", "platform/input"}, nil},
{"platform/input", []string{"core/..."}, nil},
{"platform/sdl", []string{"core/...", "platform", "platform/input"}, nil},
{"platform/headless", []string{"core/...", "platform", "platform/input"}, nil},
{"gpu", []string{"core/..."}, nil},
{"gpu/null", []string{"core/...", "gpu"}, nil},
{"gpu/gl33", []string{"core/...", "gpu", "gpu/gl33/..."}, nil},
{"gpu/gl33/gl", nil, nil},
{"gpu/gl33/internal/...", []string{"core/..."}, nil},
{"vfs", []string{"core/...", "vfs/osfs"}, nil},
{"vfs/osfs", []string{"core/..."}, nil},
{"engine", []string{"core/...", "platform/...", "gpu/...", "vfs/...", "engine/res"}, nil},
{"engine/res", []string{"core/...", "vfs"}, nil},
{"engine/bootstrap", []string{"core/...", "project"}, nil},
{"render", []string{"core/...", "gpu", "vfs", "render/..."}, nil},
{"render/module", []string{"core/...", "gpu", "engine", "render"}, nil},
{"render/...", []string{"core/...", "gpu", "vfs", "render/..."}, nil},
{"asset", []string{"core/...", "gpu", "format/...", "render/texture"}, nil},
{"asset/loader", []string{"core/...", "gpu", "vfs", "asset", "render/texture"}, nil},
{"asset/pipeline", []string{"core/...", "asset", "format/...", "project", "vfs", "render/texture"}, nil},
{"asset/importers/...", []string{"core/...", "gpu", "asset", "asset/pipeline", "format/...", "render/texture"}, nil},
{"project", []string{"core/...", "format/..."}, nil},
{"scene", []string{"core/...", "schema", "asset", "render/..."}, nil},
{"scene/components", []string{"core/...", "schema", "asset", "gpu", "render/...", "scene"}, nil},
{"scene/sceneio", []string{"core/...", "schema", "asset", "format/...", "scene"}, nil},
{"scene/systems", []string{"core/...", "schema", "asset", "gpu", "engine", "render", "render/...", "scene", "scene/components"}, nil},
{"ui/...", []string{"core/...", "gpu", "platform/input", "render", "render/...", "schema", "scene", "asset", "format/...", "ui/..."}, nil},
{"editor/...", []string{"core/...", "platform/input", "gpu", "render", "render/...", "ui/...", "schema", "scene", "scene/...", "asset", "asset/...", "format/...", "project", "engine", "editor/..."}, nil},
{"sdk", []string{"core/...", "engine", "schema", "scene", "asset", "asset/...", "render", "render/module"}, nil},
{"app/run", []string{"..."}, nil},
{"demos/...", []string{"..."}, nil},
{"cmd/...", []string{"..."}, nil},
{"tests/...", []string{"..."}, nil},
{"tools/...", []string{"..."}, nil},
{"examples/...", []string{"sdk"}, nil},
}
var osFileFuncs = map[string]bool{
"Open": true, "OpenFile": true, "Create": true, "CreateTemp": true,
"ReadFile": true, "WriteFile": true, "ReadDir": true, "MkdirAll": true,
"Mkdir": true, "Remove": true, "RemoveAll": true, "Rename": true, "Stat": true,
}
var fsAllowed = []string{
"vfs/...",
"platform/...",
"tools/...",
"cmd/...",
"project",
"asset/pipeline",
"engine/bootstrap",
"app/run",
"demos/asset-hot-reload",
}
var denied = []struct {
pkg string
deny []string
}{
{"app/run", []string{"editor/..."}},
{"runtime/...", []string{"editor/..."}},
{"sdk", []string{"editor/..."}},
}
var cgoAllowed = []string{"platform/sdl", "gpu/gl33/gl"}
var serviceTypes = map[string]bool{
"*engine.Engine": true, "engine.Engine": true, "*engine.Builder": true,
"gpu.Device": true, "platform.Platform": true, "platform.Window": true,
"*log.Logger": true, "vfs.FS": true, "*vfs.VFS": true, "*input.State": true,
}
var servicePkgs = map[string]bool{
"engine": true, "gpu": true, "gl33": true, "null": true, "platform": true,
"sdl": true, "headless": true, "log": true, "vfs": true,
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
violations, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, "depcheck:", err)
os.Exit(2)
}
for _, v := range violations {
fmt.Println(v)
}
if len(violations) > 0 {
fmt.Fprintf(os.Stderr, "depcheck: %d violation(s)\n", len(violations))
os.Exit(1)
}
fmt.Println("depcheck: ok")
}
func Check(root string) ([]string, error) {
module, err := modulePath(root)
if err != nil {
return nil, err
}
pkgs, err := listPackages(root)
if err != nil {
return nil, err
}
var out []string
for _, p := range pkgs {
out = append(out, checkImports(module, p)...)
}
src, err := checkSources(root)
if err != nil {
return nil, err
}
out = append(out, src...)
sort.Strings(out)
return out, nil
}
type pkgInfo struct {
path string
imports []string
}
func modulePath(root string) (string, error) {
data, err := os.ReadFile(filepath.Join(root, "go.mod"))
if err != nil {
return "", err
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if rest, ok := strings.CutPrefix(line, "module "); ok {
return strings.Trim(strings.TrimSpace(rest), `"`), nil
}
}
return "", errors.New("module directive not found in go.mod")
}
func listPackages(root string) ([]pkgInfo, error) {
cmd := exec.Command("go", "list", "-e", "-f", "{{.ImportPath}}\t{{join .Imports \" \"}}", "./...")
cmd.Dir = root
var stderr bytes.Buffer
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("go list: %w: %s", err, stderr.String())
}
var pkgs []pkgInfo
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 2)
p := pkgInfo{path: parts[0]}
if len(parts) == 2 && parts[1] != "" {
p.imports = strings.Fields(parts[1])
}
pkgs = append(pkgs, p)
}
return pkgs, nil
}
func relPkg(module, path string) (string, bool) {
if path == module {
return "", true
}
if rest, ok := strings.CutPrefix(path, module+"/"); ok {
return rest, true
}
return "", false
}
func match(pattern, p string) bool {
if pattern == "..." {
return true
}
if base, ok := strings.CutSuffix(pattern, "/..."); ok {
return p == base || strings.HasPrefix(p, base+"/")
}
return p == pattern
}
func allowed(patterns []string, p string) bool {
for _, pat := range patterns {
if match(pat, p) {
return true
}
}
return false
}
func ruleFor(p string) (rule, bool) {
best, bestScore := rule{}, -1
for _, r := range rules {
if !match(r.pkg, p) {
continue
}
score := len(strings.TrimSuffix(r.pkg, "/...")) * 2
if !strings.HasSuffix(r.pkg, "/...") {
score++
}
if score > bestScore {
best, bestScore = r, score
}
}
return best, bestScore >= 0
}
func isStdlib(path string) bool {
first, _, _ := strings.Cut(path, "/")
return !strings.Contains(first, ".")
}
func checkImports(module string, p pkgInfo) []string {
local, ok := relPkg(module, p.path)
if !ok || local == "" {
return nil
}
r, ok := ruleFor(local)
if !ok {
return []string{fmt.Sprintf("%s: no dependency rule for package", local)}
}
var out []string
for _, imp := range p.imports {
if imp == "C" {
continue
}
if l, ok := relPkg(module, imp); ok {
if !allowed(r.allow, l) {
out = append(out, fmt.Sprintf("%s: forbidden import %s (rule %s)", local, l, r.pkg))
}
for _, d := range denied {
if match(d.pkg, local) && allowed(d.deny, l) {
out = append(out, fmt.Sprintf("%s: denied import %s", local, l))
}
}
continue
}
if isStdlib(imp) {
continue
}
if !allowed(r.external, imp) {
out = append(out, fmt.Sprintf("%s: forbidden external import %s (rule %s)", local, imp, r.pkg))
}
}
return out
}
func checkSources(root string) ([]string, error) {
var out []string
fset := token.NewFileSet()
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
name := d.Name()
if d.IsDir() {
if path != root && (strings.HasPrefix(name, ".") || name == "third_party" || name == "testdata" || name == "node_modules") {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
dir := filepath.ToSlash(filepath.Dir(rel))
f, err := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution)
if err != nil {
return err
}
out = append(out, inspectFile(fset, f, rel, dir)...)
return nil
})
return out, err
}
func inspectFile(fset *token.FileSet, f *ast.File, file, dir string) []string {
var out []string
osName := ""
for _, imp := range f.Imports {
p := strings.Trim(imp.Path.Value, `"`)
if p == "C" && !allowed(cgoAllowed, dir) {
out = append(out, fmt.Sprintf("%s: cgo is only allowed in %v", file, cgoAllowed))
}
if p == "os" {
osName = "os"
if imp.Name != nil {
osName = imp.Name.Name
}
}
}
if osName != "" && !allowed(fsAllowed, dir) {
ast.Inspect(f, func(n ast.Node) bool {
sel, ok := n.(*ast.SelectorExpr)
if !ok {
return true
}
id, ok := sel.X.(*ast.Ident)
if ok && id.Name == osName && osFileFuncs[sel.Sel.Name] {
out = append(out, fmt.Sprintf("%s: os.%s outside vfs/platform", fset.Position(sel.Pos()), sel.Sel.Name))
}
return true
})
}
for _, decl := range f.Decls {
gd, ok := decl.(*ast.GenDecl)
if !ok || gd.Tok != token.VAR {
continue
}
for _, spec := range gd.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok {
continue
}
if vs.Type != nil && serviceTypes[types.ExprString(vs.Type)] {
out = append(out, fmt.Sprintf("%s: global service variable %s", fset.Position(vs.Pos()), types.ExprString(vs.Type)))
continue
}
for _, v := range vs.Values {
if isServiceValue(v) {
out = append(out, fmt.Sprintf("%s: global service variable", fset.Position(vs.Pos())))
break
}
}
}
}
return out
}
func isServiceValue(e ast.Expr) bool {
if u, ok := e.(*ast.UnaryExpr); ok {
e = u.X
}
switch x := e.(type) {
case *ast.CallExpr:
sel, ok := x.Fun.(*ast.SelectorExpr)
if !ok {
return false
}
id, ok := sel.X.(*ast.Ident)
return ok && servicePkgs[id.Name] && strings.HasPrefix(sel.Sel.Name, "New")
case *ast.CompositeLit:
if x.Type == nil {
return false
}
t := types.ExprString(x.Type)
return serviceTypes[t] || serviceTypes["*"+t]
}
return false
}