oficina / estilo Public

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
Paul Campbell pushed by paul@keymail.dev 01c14bfa2be017f39bac9602ed7ba5d2f19824b3 parent ce38478
1 file changed, +125 −0
  • internal/lint/prefix_test.go +125 −0
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
+ }
+}