// 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. // notEstilosCSS names the CSS in this repository that is not estilo's own // but rastrillo's, carried byte for byte: its rst- selectors are rastrillo // styling rastrillo's markup, which is exactly what the prefix rule // permits. Each is pinned to its source by a provenance test, so it can't // quietly become estilo's own CSS with rastrillo's names. var notEstilosCSS = map[string]bool{ "busy/busy.css": true, // rastrillo ui/tokens.css; busy/provenance.json } 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 } if notEstilosCSS[filepath.ToSlash(rel)] { return nil } 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 } }