From 727de5f11014ebabf9bcd098d7491d964b14265f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 17 Oct 2026 01:44:46 -0400 Subject: [PATCH] Add archtest: tiers, file length, comments, component signatures Co-Authored-By: Claude Opus 5.5 (1M context) --- AGENTS.md | 5 +- internal/archtest/arch_test.go | 156 ++++++++++++++++++++++++++++++++ internal/archtest/rules_test.go | 156 ++++++++++++++++++++++++++++++++ 3 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 internal/archtest/arch_test.go create mode 100644 internal/archtest/rules_test.go diff --git a/AGENTS.md b/AGENTS.md index 3961ed5..a3018df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - 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. -- 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 diff --git a/internal/archtest/arch_test.go b/internal/archtest/arch_test.go new file mode 100644 index 0000000..8b0ac92 --- /dev/null +++ b/internal/archtest/arch_test.go @@ -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) + } + } +} diff --git a/internal/archtest/rules_test.go b/internal/archtest/rules_test.go new file mode 100644 index 0000000..0c573c8 --- /dev/null +++ b/internal/archtest/rules_test.go @@ -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() +}