internal/lint: tripwire on rst-prefixed selectors in estilo's own CSS
AGENTS.md's Vocabulary section and rastrillo's own design-system guidance both rule the same way: estilo's own components take estilo-*, never rst-*, because rst- is the framework's namespace and a future rastrillo release may define a class or attribute of that name that collides with one estilo invented. There are no components yet, so this test passes vacuously today — it exists so the FIRST one built here trips it if written with the framework's prefix instead of estilo's own. It scans every .css file in the repo for a .rst-* class selector or an [rst-*] attribute selector (the bare-attribute grammar's compiled form), and deliberately does not match --rst-* custom properties, which are the token contract theme/estilo.css legitimately inherits rather than a component prefix. Doesn't catch: rst-* attributes written directly in a template with no matching CSS rule, or a third prefix that's neither estilo- nor rst-. It's a tripwire on estilo's CSS, not a full grammar checker. Verified it actually fires: a scratch .rst-widget rule failed the test with the exact file and selector, removing it passed again.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
1 file changed,
+125
−0
internal/lint/prefix_test.go+125 −0
Intent
diff --git a/internal/lint/prefix_test.go b/internal/lint/prefix_test.go| new file mode 100644 |
| index 0000000..f5c4484 |
| --- /dev/null |
| +++ b/internal/lint/prefix_test.go |
| @@ -0,0 +1,125 @@ |
| +// Package lint holds repo-wide gates that are about the whole tree |
| +// rather than any one package -- starting with the prefix rule. |
| +package lint |
| + |
| +import ( |
| + "fmt" |
| + "io/fs" |
| + "os" |
| + "path/filepath" |
| + "regexp" |
| + "runtime" |
| + "sort" |
| + "testing" |
| +) |
| + |
| +// rstSelector matches a CSS class selector (.rst-foo) or attribute |
| +// selector ([rst-foo], including the bare-attribute grammar's |
| +// [rst-foo~="variant"] form) that uses the rst- prefix. |
| +// |
| +// It deliberately does NOT match rastrillo's custom properties |
| +// (--rst-accent and friends): those start with two hyphens, never a |
| +// "." or a "[", and are the token contract estilo's theme legitimately |
| +// inherits (theme/estilo.css) rather than a component prefix. See |
| +// AGENTS.md's Vocabulary section and rastrillo's own design-system |
| +// guidance ("Use your own prefix. rst- is the framework's, and a |
| +// future version may claim a name you took."). |
| +var rstSelector = regexp.MustCompile(`\.rst-[A-Za-z0-9_-]+|\[\s*rst-[A-Za-z0-9_-]+`) |
| + |
| +// TestNoRstPrefixInEstiloCSS is the prefix rule's tripwire. Estilo has |
| +// no components yet, so today this test passes vacuously -- it exists |
| +// so the FIRST component built here trips it if written with the |
| +// framework's rst- prefix instead of estilo's own estilo- prefix. |
| +// |
| +// It scans every .css file in the repository (not just a components |
| +// directory that doesn't exist yet) for a class or attribute selector |
| +// using rst-. That prefix is rastrillo's: AGENTS.md rules that |
| +// estilo's own components take estilo-*, never rst-*, because rst- is |
| +// the framework's namespace and a future rastrillo release may define |
| +// a class or attribute of that name that collides with one estilo |
| +// invented. |
| +// |
| +// What this does NOT catch: Go template files (*.html) that write |
| +// rst-* attributes directly without a matching CSS rule, uses of rst- |
| +// prefixed selectors in comments or documentation (this file's own |
| +// header included -- .css files only), or a component that avoids |
| +// both prefixes and picks a third one. It is a tripwire on estilo's |
| +// own CSS, not a full grammar checker. |
| +func TestNoRstPrefixInEstiloCSS(t *testing.T) { |
| + root := repoRoot(t) |
| + |
| + type hit struct { |
| + path string |
| + match string |
| + } |
| + var hits []hit |
| + |
| + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if d.IsDir() { |
| + if d.Name() == ".git" { |
| + return filepath.SkipDir |
| + } |
| + return nil |
| + } |
| + if filepath.Ext(path) != ".css" { |
| + return nil |
| + } |
| + data, err := os.ReadFile(path) |
| + if err != nil { |
| + return fmt.Errorf("reading %s: %w", path, err) |
| + } |
| + rel, err := filepath.Rel(root, path) |
| + if err != nil { |
| + rel = path |
| + } |
| + for _, m := range rstSelector.FindAll(data, -1) { |
| + hits = append(hits, hit{path: rel, match: string(m)}) |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatalf("walking %s: %v", root, err) |
| + } |
| + |
| + if len(hits) == 0 { |
| + return |
| + } |
| + sort.Slice(hits, func(i, j int) bool { |
| + if hits[i].path != hits[j].path { |
| + return hits[i].path < hits[j].path |
| + } |
| + return hits[i].match < hits[j].match |
| + }) |
| + msg := "found rst-prefixed selectors in estilo's own CSS -- that namespace is rastrillo's " + |
| + "(AGENTS.md, Vocabulary). Estilo's own components take estilo-*:\n" |
| + for _, h := range hits { |
| + msg += fmt.Sprintf(" %s: %s\n", h.path, h.match) |
| + } |
| + t.Fatal(msg) |
| +} |
| + |
| +// repoRoot finds the module root by walking up from this test file's |
| +// own location until it finds go.mod. Tests run with the package |
| +// directory as their working directory, not the repo root, so this |
| +// walks the source path instead of relying on os.Getwd. |
| +func repoRoot(t *testing.T) string { |
| + t.Helper() |
| + _, file, _, ok := runtime.Caller(0) |
| + if !ok { |
| + t.Fatal("runtime.Caller(0) failed; cannot locate this test file") |
| + } |
| + dir := filepath.Dir(file) |
| + for { |
| + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { |
| + return dir |
| + } |
| + parent := filepath.Dir(dir) |
| + if parent == dir { |
| + t.Fatalf("walked up to %s without finding go.mod", dir) |
| + } |
| + dir = parent |
| + } |
| +} |