272 lines
7.9 KiB
Go
272 lines
7.9 KiB
Go
// Package archtest checks the rules of AGENTS.md on every package.
|
|
package archtest
|
|
|
|
import (
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const module = "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/"
|
|
|
|
var tiers = map[string]int{
|
|
"pixel": 0, "icon": 1,
|
|
"layout": 2, "text": 2, "display": 2,
|
|
"input": 3, "feedback": 3,
|
|
"navigation": 4, "data": 4, "overlay": 4, "datetime": 4, "chart": 4, "fx": 4,
|
|
"game": 5, "chat": 5, "media": 5, "code": 5, "account": 5, "files": 5,
|
|
"project": 5, "shop": 5, "agent": 5, "desk": 5, "tabletop": 5,
|
|
"gallery": 6, "cmd/gallery": 7, "cmd/web": 7,
|
|
}
|
|
|
|
// Styled ui widgets drawing outside the pixel style.
|
|
var bannedFuncs = set("Text Textf RichText Button PrimaryButton Link MenuButton Toggle ToggleGroup Segmented Toolbar " +
|
|
"Checkbox CheckboxGroup Switch Radio Select Slider StepSlider RangeSlider Stepper Rating TextInput TextArea " +
|
|
"NumberInput SearchField Combobox Autocomplete TokenField EditableText FindBar Calendar DateInput TimeInput " +
|
|
"ColorPicker ColorWell Table Tree TreeItem Accordion AccordionItem Collapsible Form Field Fieldset Tabs " +
|
|
"Sidebar SidebarItem SidebarSection Breadcrumbs BackButton ForwardButton Modal AlertDialog Popover Progress " +
|
|
"Spinner Meter Badge Image Icon Avatar Divider Split SplitVertical")
|
|
|
|
// Element, Context and Painter methods that blend, ease or draw.
|
|
var bannedMethods = set("Background Border BorderColor BorderWidth BorderStyle Radius Shadow Gradient " +
|
|
"LinearGradient Opacity Stripes Tooltip Menu ContextMenu Transition Animate AnimateWith Loop Toast " +
|
|
"ToastAction AnimationFrame After FillPath StrokePath RichText Glyphs FillGradient Stroke StrokeDashed " +
|
|
"StrokePathGradient FillPathGradient Material Icon Image Selected")
|
|
|
|
func set(s string) map[string]bool {
|
|
out := map[string]bool{}
|
|
for _, f := range strings.Fields(s) {
|
|
out[f] = true
|
|
}
|
|
return out
|
|
}
|
|
|
|
type goFile struct {
|
|
path, pkg string
|
|
ast *ast.File
|
|
src []byte
|
|
}
|
|
|
|
// files parses every Go file of the module.
|
|
func files(t *testing.T) []goFile {
|
|
var out []goFile
|
|
err := filepath.WalkDir("../..", func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() && p != "../.." && (strings.HasPrefix(d.Name(), ".") || d.Name() == "node_modules") {
|
|
return filepath.SkipDir
|
|
}
|
|
if d.IsDir() || !strings.HasSuffix(p, ".go") {
|
|
return nil
|
|
}
|
|
src, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f, err := parser.ParseFile(token.NewFileSet(), p, src, parser.ParseComments)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel, _ := filepath.Rel("../..", filepath.Dir(p))
|
|
out = append(out, goFile{p, filepath.ToSlash(rel), f, src})
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(out) < 20 {
|
|
t.Fatalf("found %d Go files; the walk is broken", len(out))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestImportsPointDown(t *testing.T) {
|
|
for _, f := range files(t) {
|
|
if strings.HasSuffix(f.path, "_test.go") || strings.HasPrefix(f.pkg, "tools/") || strings.HasPrefix(f.pkg, "internal/") {
|
|
continue
|
|
}
|
|
tier, ok := tiers[f.pkg]
|
|
if !ok {
|
|
t.Errorf("%s: package %q has no tier in AGENTS.md", f.path, f.pkg)
|
|
continue
|
|
}
|
|
for _, im := range f.ast.Imports {
|
|
path, _ := strconv.Unquote(im.Path.Value)
|
|
if !strings.HasPrefix(path, module) {
|
|
continue
|
|
}
|
|
to := strings.TrimPrefix(path, module)
|
|
if got, ok := tiers[to]; !ok || got >= tier {
|
|
t.Errorf("%s: %s (tier %d) imports %s (tier %d)", f.path, f.pkg, tier, to, got)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestNoBannedCalls(t *testing.T) {
|
|
for _, f := range files(t) {
|
|
// cmd/web drives a Tester and builds no views.
|
|
if strings.HasSuffix(f.path, "_test.go") || f.pkg == "pixel" || f.pkg == "cmd/web" || strings.HasPrefix(f.pkg, "tools/") || strings.HasPrefix(f.pkg, "internal/") {
|
|
continue
|
|
}
|
|
imported := map[string]string{}
|
|
for _, im := range f.ast.Imports {
|
|
path, _ := strconv.Unquote(im.Path.Value)
|
|
name := path[strings.LastIndex(path, "/")+1:]
|
|
if im.Name != nil {
|
|
name = im.Name.Name
|
|
}
|
|
imported[name] = path
|
|
}
|
|
painters := map[*ast.Ident]bool{}
|
|
var stack []ast.Node
|
|
ast.Inspect(f.ast, func(n ast.Node) bool {
|
|
if n == nil {
|
|
stack = stack[:len(stack)-1]
|
|
return true
|
|
}
|
|
var parent ast.Node
|
|
if len(stack) > 0 {
|
|
parent = stack[len(stack)-1]
|
|
}
|
|
stack = append(stack, n)
|
|
switch n := n.(type) {
|
|
case *ast.Field:
|
|
if star, ok := n.Type.(*ast.StarExpr); ok && isUI(star.X, "Painter", imported) {
|
|
for _, name := range n.Names {
|
|
painters[name] = true
|
|
}
|
|
}
|
|
case *ast.Ident:
|
|
if n.Obj != nil && n.Obj.Kind == ast.Var && !painters[n] && isPainter(n, painters) && !intoCanvas(n, parent) {
|
|
t.Errorf("%s: painter %s used outside k.Canvas; only pixel draws", f.path, n.Name)
|
|
}
|
|
case *ast.CallExpr:
|
|
sel, ok := n.Fun.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
pkg := false
|
|
if id, ok := sel.X.(*ast.Ident); ok {
|
|
_, pkg = imported[id.Name]
|
|
}
|
|
if pkg && isUI(sel.X, "", imported) && bannedFuncs[sel.Sel.Name] || !pkg && bannedMethods[sel.Sel.Name] {
|
|
t.Errorf("%s: banned call %s", f.path, sel.Sel.Name)
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
}
|
|
}
|
|
|
|
// isPainter reports whether id refers to a *ui.Painter parameter.
|
|
func isPainter(id *ast.Ident, painters map[*ast.Ident]bool) bool {
|
|
for p := range painters {
|
|
if id.Obj == p.Obj {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// intoCanvas reports whether id is a Canvas call argument.
|
|
func intoCanvas(id *ast.Ident, parent ast.Node) bool {
|
|
call, ok := parent.(*ast.CallExpr)
|
|
if !ok {
|
|
return false
|
|
}
|
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
return ok && sel.Sel.Name == "Canvas" && slices.Contains(call.Args, ast.Expr(id))
|
|
}
|
|
|
|
// isUI reports whether x names package ui, or ui.name.
|
|
func isUI(x ast.Expr, name string, imported map[string]string) bool {
|
|
if sel, ok := x.(*ast.SelectorExpr); ok && name != "" {
|
|
return sel.Sel.Name == name && isUI(sel.X, "", imported)
|
|
}
|
|
id, ok := x.(*ast.Ident)
|
|
return ok && name == "" && imported[id.Name] == "github.com/egoist/mygo/ui"
|
|
}
|
|
|
|
var directive = regexp.MustCompile(`^//(go:|line )`)
|
|
|
|
func TestCommentsAndFileLength(t *testing.T) {
|
|
for _, f := range files(t) {
|
|
if n := strings.Count(string(f.src), "\n"); n >= 500 {
|
|
t.Errorf("%s: %d lines, keep files under 500", f.path, n)
|
|
}
|
|
for _, g := range f.ast.Comments {
|
|
lines := 0
|
|
for _, c := range g.List {
|
|
if directive.MatchString(c.Text) || c.Text == "//" {
|
|
continue
|
|
}
|
|
lines++
|
|
if words := len(strings.Fields(strings.TrimPrefix(c.Text, "//"))); words > 10 || strings.HasPrefix(c.Text, "/*") {
|
|
t.Errorf("%s: comment of %d words: %s", f.path, words, c.Text)
|
|
}
|
|
}
|
|
if lines > 1 {
|
|
t.Errorf("%s: comment of %d lines near %q", f.path, lines, g.List[0].Text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestWebScripts applies the comment and length rules to scripts.
|
|
func TestWebScripts(t *testing.T) {
|
|
marks := map[string]*regexp.Regexp{".js": regexp.MustCompile(`^\s*//\s?(.*)$`), ".sh": regexp.MustCompile(`^\s*#\s(.*)$`)}
|
|
seen := 0
|
|
err := filepath.WalkDir("../../web", func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() && slices.Contains([]string{"node_modules", "dist", "test-results", "playwright-report"}, d.Name()) {
|
|
return filepath.SkipDir
|
|
}
|
|
mark := marks[filepath.Ext(p)]
|
|
if d.IsDir() || mark == nil {
|
|
return nil
|
|
}
|
|
seen++
|
|
src, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
lines := strings.Split(string(src), "\n")
|
|
if len(lines) > 500 {
|
|
t.Errorf("%s: %d lines, keep files under 500", p, len(lines))
|
|
}
|
|
run := 0
|
|
for i, l := range lines {
|
|
m := mark.FindStringSubmatch(l)
|
|
if m == nil {
|
|
run = 0
|
|
continue
|
|
}
|
|
if run++; run > 1 {
|
|
t.Errorf("%s:%d: comment of %d lines", p, i+1, run)
|
|
}
|
|
if words := len(strings.Fields(m[1])); words > 10 {
|
|
t.Errorf("%s:%d: comment of %d words: %s", p, i+1, words, strings.TrimSpace(l))
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if seen < 4 {
|
|
t.Fatalf("found %d web scripts; the walk is broken", seen)
|
|
}
|
|
}
|