Add archtest: tiers, file length, comments, component signatures
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
a9096b8aa2
commit
727de5f110
3 files changed
+316
-1
No files matched your search
@@ -63,7 +63,10 @@ health, home, marketing, content, desktop, geo · 6 gallery · 7 cmd/gallery
|
|||||||
- Fail fast: panic on programmer errors. Never swallow an error.
|
- Fail fast: panic on programmer errors. Never swallow an error.
|
||||||
- Composites log state changes with `kit.Log`. Never log typed text or secrets: log counts.
|
- Composites log state changes with `kit.Log`. Never log typed text or secrets: log counts.
|
||||||
- No element or parts stored across builds; no goroutine touches elements. `go tool mygo vet ./...` passes.
|
- No element or parts stored across builds; no goroutine touches elements. `go tool mygo vet ./...` passes.
|
||||||
- Files under 500 lines. Comments one line, ten words at most, only when needed. No dead code, no versioned APIs.
|
- Files under 500 lines (lock files exempt). Comments one line, ten words at most, only when needed. No dead
|
||||||
|
code, no versioned APIs.
|
||||||
|
- A category file holds one exported component, named in snake case (`KPIGrid` in `kpi_grid.go`), or private
|
||||||
|
helpers only.
|
||||||
|
|
||||||
## Gallery entry
|
## Gallery entry
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package archtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
const root = "../.."
|
||||||
|
|
||||||
|
var skipDirs = map[string]bool{".git": true, "node_modules": true, "dist": true, "icon/svg": true}
|
||||||
|
|
||||||
|
// Generated lock files are exempt.
|
||||||
|
var exempt = map[string]bool{"go.sum": true, "package-lock.json": true}
|
||||||
|
|
||||||
|
func walk(t *testing.T, visit func(rel string, src []byte)) {
|
||||||
|
t.Helper()
|
||||||
|
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(root, p)
|
||||||
|
rel = filepath.ToSlash(rel)
|
||||||
|
if d.IsDir() {
|
||||||
|
if skipDirs[rel] || skipDirs[d.Name()] {
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
src, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
visit(rel, src)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFileLength(t *testing.T) {
|
||||||
|
walk(t, func(rel string, src []byte) {
|
||||||
|
if exempt[filepath.Base(rel)] || !utf8.Valid(src) || bytes.IndexByte(src, 0) >= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := bytes.Count(src, []byte("\n"))
|
||||||
|
if len(src) > 0 && src[len(src)-1] != '\n' {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
if n >= 500 {
|
||||||
|
t.Errorf("%s: %d lines, want under 500", rel, n)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoRules(t *testing.T) {
|
||||||
|
fset, files := token.NewFileSet(), 0
|
||||||
|
walk(t, func(rel string, src []byte) {
|
||||||
|
if filepath.Ext(rel) != ".go" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
files++
|
||||||
|
f, err := parser.ParseFile(fset, rel, src, parser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bad := checkComments(fset, f)
|
||||||
|
if dir := filepath.ToSlash(filepath.Dir(rel)); dir != "internal/archtest" {
|
||||||
|
bad = append(bad, checkImports(dir, f)...)
|
||||||
|
if isCategory(dir) && !strings.HasSuffix(rel, "_test.go") {
|
||||||
|
bad = append(bad, checkComponents(rel, f)...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, b := range bad {
|
||||||
|
t.Error(b)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if files == 0 {
|
||||||
|
t.Fatal("no Go files found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parse(t *testing.T, src string) (*token.FileSet, *ast.File) {
|
||||||
|
t.Helper()
|
||||||
|
fset := token.NewFileSet()
|
||||||
|
f, err := parser.ParseFile(fset, "x.go", src, parser.ParseComments)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return fset, f
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRulesCatch(t *testing.T) {
|
||||||
|
const head = "package button\nimport \"github.com/egoist/mygo/ui\"\n"
|
||||||
|
cases := []struct {
|
||||||
|
name, file, src string
|
||||||
|
rule func(*token.FileSet, *ast.File, string) []string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"same tier", "", "package button\nimport _ \"" + module + "/display\"\n", imports("button"), 1},
|
||||||
|
{"upward", "", "package button\nimport _ \"" + module + "/form\"\n", imports("button"), 1},
|
||||||
|
{"unknown pkg", "", "package x\n", imports("x"), 1},
|
||||||
|
{"downward", "", "package button\nimport _ \"" + module + "/kit\"\n", imports("button"), 0},
|
||||||
|
{"long comment", "", "package x\n// one two three four five six seven eight nine ten eleven\n", comments, 1},
|
||||||
|
{"two lines", "", "package x\n// one\n// two\n", comments, 1},
|
||||||
|
{"block", "", "package x\n/* one\ntwo */\n", comments, 1},
|
||||||
|
{"directive", "", "package x\n// Files.\n//go:generate echo\n", comments, 0},
|
||||||
|
{"good", "button/icon_button.go", head + "func IconButton(c *ui.Context, o IconButtonOptions) ui.Element\n" +
|
||||||
|
"type IconButtonOptions struct{}\n", components, 0},
|
||||||
|
{"result", "button/split.go", head + "func Split(c *ui.Context) SplitResult\ntype SplitResult struct{}\n",
|
||||||
|
components, 0},
|
||||||
|
{"two", "button/a.go", head + "func A(c *ui.Context) ui.Element\nfunc B(c *ui.Context) ui.Element\n",
|
||||||
|
components, 1},
|
||||||
|
{"file name", "button/b.go", head + "func KPIGrid(c *ui.Context) ui.Element\n", components, 1},
|
||||||
|
{"no context", "button/a.go", head + "func A() ui.Element\n", components, 1},
|
||||||
|
{"variadic", "button/a.go", head + "func A(c *ui.Context, o ...AOptions) ui.Element\n", components, 1},
|
||||||
|
{"options not last", "button/a.go", head + "func A(c *ui.Context, o AOptions, n int) ui.Element\n",
|
||||||
|
components, 1},
|
||||||
|
{"returns", "button/a.go", head + "func A(c *ui.Context) int\n", components, 1},
|
||||||
|
{"ctx name", "button/a.go", head + "func A(ctx *ui.Context) ui.Element\n", components, 1},
|
||||||
|
{"grouped options", "button/a.go", head + "func A(c *ui.Context, x, y AOptions) ui.Element\n",
|
||||||
|
components, 1},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
fset, f := parse(t, tc.src)
|
||||||
|
if got := tc.rule(fset, f, tc.file); len(got) != tc.want {
|
||||||
|
t.Errorf("%s: %d findings %q, want %d", tc.name, len(got), got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func imports(dir string) func(*token.FileSet, *ast.File, string) []string {
|
||||||
|
return func(_ *token.FileSet, f *ast.File, _ string) []string { return checkImports(dir, f) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func comments(fset *token.FileSet, f *ast.File, _ string) []string { return checkComments(fset, f) }
|
||||||
|
|
||||||
|
func components(_ *token.FileSet, f *ast.File, file string) []string { return checkComponents(file, f) }
|
||||||
|
|
||||||
|
func TestSnake(t *testing.T) {
|
||||||
|
for in, want := range map[string]string{"Stack": "stack", "KPIGrid": "kpi_grid", "Base64Tool": "base64_tool",
|
||||||
|
"VDivider": "v_divider", "IDCard": "id_card", "PiPBox": "pi_p_box", "KPI": "kpi"} {
|
||||||
|
if got := snake(in); got != want {
|
||||||
|
t.Errorf("snake(%s) = %s, want %s", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// Package archtest checks the project's import and file rules.
|
||||||
|
package archtest
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"go/ast"
|
||||||
|
"go/token"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
const module = "git.zacharyzhang.com/ZacharyZhang-NY/CrychicUI"
|
||||||
|
|
||||||
|
var tiers = map[string]int{"sound": 0, "icon": 0, "kit": 1, "gallery": 6, "cmd/gallery": 7}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
for t, pkgs := range map[int]string{
|
||||||
|
2: "layout typography button display",
|
||||||
|
3: "input choice feedback motion fx",
|
||||||
|
4: "form datetime color navigation overlay data chart media",
|
||||||
|
5: "chat agent code files shop finance dashboard schedule project mail social account devtools game " +
|
||||||
|
"learn health home marketing content desktop geo",
|
||||||
|
} {
|
||||||
|
for _, p := range strings.Fields(pkgs) {
|
||||||
|
tiers[p] = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCategory(dir string) bool { t, ok := tiers[dir]; return ok && t >= 2 && t <= 5 }
|
||||||
|
|
||||||
|
// checkImports enforces downward imports between module packages.
|
||||||
|
func checkImports(dir string, f *ast.File) []string {
|
||||||
|
from, ok := tiers[dir]
|
||||||
|
if !ok {
|
||||||
|
return []string{fmt.Sprintf("%s: package not in a tier", dir)}
|
||||||
|
}
|
||||||
|
var bad []string
|
||||||
|
for _, im := range f.Imports {
|
||||||
|
p, _ := strconv.Unquote(im.Path.Value)
|
||||||
|
rel, ok := strings.CutPrefix(p, module+"/")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
to, known := tiers[rel]
|
||||||
|
if !known || to >= from {
|
||||||
|
bad = append(bad, fmt.Sprintf("%s (tier %d) imports %s", dir, from, rel))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bad
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkComments allows one-line comments of ten words at most.
|
||||||
|
func checkComments(fset *token.FileSet, f *ast.File) []string {
|
||||||
|
var bad []string
|
||||||
|
for _, g := range f.Comments {
|
||||||
|
lines := 0
|
||||||
|
for _, c := range g.List {
|
||||||
|
if strings.HasPrefix(c.Text, "//go:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
at := fset.Position(c.Pos())
|
||||||
|
lines++
|
||||||
|
body := strings.TrimPrefix(strings.TrimPrefix(c.Text, "//"), "/*")
|
||||||
|
if strings.Contains(c.Text, "\n") {
|
||||||
|
bad = append(bad, fmt.Sprintf("%s: comment spans lines", at))
|
||||||
|
}
|
||||||
|
if n := len(strings.Fields(strings.TrimSuffix(body, "*/"))); n > 10 {
|
||||||
|
bad = append(bad, fmt.Sprintf("%s: comment has %d words", at, n))
|
||||||
|
}
|
||||||
|
if lines == 2 {
|
||||||
|
bad = append(bad, fmt.Sprintf("%s: comment spans lines", at))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bad
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkComponents enforces one component per file and its signature.
|
||||||
|
func checkComponents(file string, f *ast.File) []string {
|
||||||
|
var bad []string
|
||||||
|
var names []string
|
||||||
|
for _, d := range f.Decls {
|
||||||
|
fn, ok := d.(*ast.FuncDecl)
|
||||||
|
if !ok || fn.Recv != nil || !fn.Name.IsExported() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
names = append(names, fn.Name.Name)
|
||||||
|
if err := signature(fn); err != "" {
|
||||||
|
bad = append(bad, fmt.Sprintf("%s: %s %s", file, fn.Name.Name, err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(names) > 1:
|
||||||
|
bad = append(bad, fmt.Sprintf("%s: %d components %v", file, len(names), names))
|
||||||
|
case len(names) == 1 && path.Base(file) != snake(names[0])+".go":
|
||||||
|
bad = append(bad, fmt.Sprintf("%s: holds %s, want %s.go", file, names[0], snake(names[0])))
|
||||||
|
}
|
||||||
|
return bad
|
||||||
|
}
|
||||||
|
|
||||||
|
func signature(fn *ast.FuncDecl) string {
|
||||||
|
name, ps := fn.Name.Name, fn.Type.Params.List
|
||||||
|
if len(ps) == 0 || typeString(ps[0].Type) != "*ui.Context" || len(ps[0].Names) != 1 ||
|
||||||
|
ps[0].Names[0].Name != "c" {
|
||||||
|
return "must take c *ui.Context first"
|
||||||
|
}
|
||||||
|
for i, p := range ps {
|
||||||
|
ts := typeString(p.Type)
|
||||||
|
if _, ok := p.Type.(*ast.Ellipsis); ok {
|
||||||
|
return "must not be variadic"
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(ts, "Options") && (ts != name+"Options" || i != len(ps)-1 || len(p.Names) > 1) {
|
||||||
|
return "options must be a final " + name + "Options"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rs := fn.Type.Results
|
||||||
|
if rs == nil || rs.NumFields() != 1 {
|
||||||
|
return "must return one value"
|
||||||
|
}
|
||||||
|
if r := typeString(rs.List[0].Type); r != "ui.Element" && r != name+"Result" {
|
||||||
|
return "must return ui.Element or " + name + "Result"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func typeString(e ast.Expr) string {
|
||||||
|
switch t := e.(type) {
|
||||||
|
case *ast.Ident:
|
||||||
|
return t.Name
|
||||||
|
case *ast.StarExpr:
|
||||||
|
return "*" + typeString(t.X)
|
||||||
|
case *ast.SelectorExpr:
|
||||||
|
return typeString(t.X) + "." + t.Sel.Name
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%T", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// snake turns KPIGrid into kpi_grid.
|
||||||
|
func snake(s string) string {
|
||||||
|
r := []rune(s)
|
||||||
|
var b strings.Builder
|
||||||
|
for i, c := range r {
|
||||||
|
if i > 0 && unicode.IsUpper(c) {
|
||||||
|
prev := r[i-1]
|
||||||
|
endsAcronym := unicode.IsUpper(prev) && i+1 < len(r) && unicode.IsLower(r[i+1])
|
||||||
|
if unicode.IsLower(prev) || unicode.IsDigit(prev) || endsAcronym {
|
||||||
|
b.WriteByte('_')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.WriteRune(unicode.ToLower(c))
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
Reference in new issue
Block a user