oficina / estilo Public

Clone
git clone https://amadan.net/oficina/estilo

Plain git — no account needed to clone.

Download

Download this file

1// Package lint holds repo-wide gates that are about the whole tree
2// rather than any one package -- starting with the prefix rule.
3package lint
4
5import (
6 "fmt"
7 "io/fs"
8 "os"
9 "path/filepath"
10 "regexp"
11 "runtime"
12 "sort"
13 "testing"
14)
15
16// rstSelector matches a CSS class selector (.rst-foo) or attribute
17// selector ([rst-foo], including the bare-attribute grammar's
18// [rst-foo~="variant"] form) that uses the rst- prefix.
19//
20// It deliberately does NOT match rastrillo's custom properties
21// (--rst-accent and friends): those start with two hyphens, never a
22// "." or a "[", and are the token contract estilo's theme legitimately
23// inherits (theme/estilo.css) rather than a component prefix. See
24// AGENTS.md's Vocabulary section and rastrillo's own design-system
25// guidance ("Use your own prefix. rst- is the framework's, and a
26// future version may claim a name you took.").
27var rstSelector = regexp.MustCompile(`\.rst-[A-Za-z0-9_-]+|\[\s*rst-[A-Za-z0-9_-]+`)
28
29// TestNoRstPrefixInEstiloCSS is the prefix rule's tripwire. Estilo has
30// no components yet, so today this test passes vacuously -- it exists
31// so the FIRST component built here trips it if written with the
32// framework's rst- prefix instead of estilo's own estilo- prefix.
33//
34// It scans every .css file in the repository (not just a components
35// directory that doesn't exist yet) for a class or attribute selector
36// using rst-. That prefix is rastrillo's: AGENTS.md rules that
37// estilo's own components take estilo-*, never rst-*, because rst- is
38// the framework's namespace and a future rastrillo release may define
39// a class or attribute of that name that collides with one estilo
40// invented.
41//
42// What this does NOT catch: Go template files (*.html) that write
43// rst-* attributes directly without a matching CSS rule, uses of rst-
44// prefixed selectors in comments or documentation (this file's own
45// header included -- .css files only), or a component that avoids
46// both prefixes and picks a third one. It is a tripwire on estilo's
47// own CSS, not a full grammar checker.
48// notEstilosCSS names the CSS in this repository that is not estilo's own
49// but rastrillo's, carried byte for byte: its rst- selectors are rastrillo
50// styling rastrillo's markup, which is exactly what the prefix rule
51// permits. Each is pinned to its source by a provenance test, so it can't
52// quietly become estilo's own CSS with rastrillo's names.
53var notEstilosCSS = map[string]bool{
54 "busy/busy.css": true, // rastrillo ui/tokens.css; busy/provenance.json
55}
56
57func TestNoRstPrefixInEstiloCSS(t *testing.T) {
58 root := repoRoot(t)
59
60 type hit struct {
61 path string
62 match string
63 }
64 var hits []hit
65
66 err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
67 if err != nil {
68 return err
69 }
70 if d.IsDir() {
71 if d.Name() == ".git" {
72 return filepath.SkipDir
73 }
74 return nil
75 }
76 if filepath.Ext(path) != ".css" {
77 return nil
78 }
79 data, err := os.ReadFile(path)
80 if err != nil {
81 return fmt.Errorf("reading %s: %w", path, err)
82 }
83 rel, err := filepath.Rel(root, path)
84 if err != nil {
85 rel = path
86 }
87 if notEstilosCSS[filepath.ToSlash(rel)] {
88 return nil
89 }
90 for _, m := range rstSelector.FindAll(data, -1) {
91 hits = append(hits, hit{path: rel, match: string(m)})
92 }
93 return nil
94 })
95 if err != nil {
96 t.Fatalf("walking %s: %v", root, err)
97 }
98
99 if len(hits) == 0 {
100 return
101 }
102 sort.Slice(hits, func(i, j int) bool {
103 if hits[i].path != hits[j].path {
104 return hits[i].path < hits[j].path
105 }
106 return hits[i].match < hits[j].match
107 })
108 msg := "found rst-prefixed selectors in estilo's own CSS -- that namespace is rastrillo's " +
109 "(AGENTS.md, Vocabulary). Estilo's own components take estilo-*:\n"
110 for _, h := range hits {
111 msg += fmt.Sprintf(" %s: %s\n", h.path, h.match)
112 }
113 t.Fatal(msg)
114}
115
116// repoRoot finds the module root by walking up from this test file's
117// own location until it finds go.mod. Tests run with the package
118// directory as their working directory, not the repo root, so this
119// walks the source path instead of relying on os.Getwd.
120func repoRoot(t *testing.T) string {
121 t.Helper()
122 _, file, _, ok := runtime.Caller(0)
123 if !ok {
124 t.Fatal("runtime.Caller(0) failed; cannot locate this test file")
125 }
126 dir := filepath.Dir(file)
127 for {
128 if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
129 return dir
130 }
131 parent := filepath.Dir(dir)
132 if parent == dir {
133 t.Fatalf("walked up to %s without finding go.mod", dir)
134 }
135 dir = parent
136 }
137}
138