Static safety/lint runner for the BlockNinja codebase. ~25 invariant
checks across Go and frontend sources. Was at git.dev.alexdunmow.com:block/ninja
in backend/cmd/check-safety/ until the 2026-06-06 consolidation moved
the BlockNinja repos under a shared ~/src/blockninja/ parent.
This repo is the standalone extraction:
- Own go.mod (git.dev.alexdunmow.com/block/check-safety, go 1.26.4)
- Vendored internal/{helpers,theme} from CMS (Go's internal/ rule
blocks cross-module imports; vendoring is the workaround)
- CLI contract unchanged: `check-safety <target-dir> [--flags]`
- CMS Makefile shells into ../check-safety for safety-check /
install-safety-checker targets
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
188 lines
4.7 KiB
Go
188 lines
4.7 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"git.dev.alexdunmow.com/block/check-safety/internal/helpers"
|
|
)
|
|
|
|
type tailwindViolation struct {
|
|
file string
|
|
line int
|
|
rule string
|
|
snippet string
|
|
}
|
|
|
|
var (
|
|
// PostCSS config: bare tailwindcss plugin (v3 syntax)
|
|
rePostCSSBarePlugin = regexp.MustCompile(`(?:['"]?tailwindcss['"]?\s*:|['"]tailwindcss['"])`)
|
|
|
|
// PostCSS config: correct v4 plugin
|
|
rePostCSSV4Plugin = regexp.MustCompile(`@tailwindcss/postcss`)
|
|
|
|
// CSS: old @tailwind directives (v3)
|
|
reTailwindDirective = regexp.MustCompile(`^\s*@tailwind\s+(base|components|utilities)`)
|
|
|
|
// CSS: v4 import
|
|
reTailwindV4Import = regexp.MustCompile(`@import\s+["']tailwindcss["']`)
|
|
|
|
// CSS: @config directive
|
|
reCSSConfigDirective = regexp.MustCompile(`^\s*@config\s+`)
|
|
|
|
// CSS: old autoprefixer in postcss config (unnecessary with v4)
|
|
rePostCSSAutoprefixer = regexp.MustCompile(`(?:['"]?autoprefixer['"]?\s*:|['"]autoprefixer['"])`)
|
|
)
|
|
|
|
func checkTailwindV4(frontendDir string) []tailwindViolation {
|
|
var violations []tailwindViolation
|
|
|
|
projectDir := filepath.Dir(frontendDir)
|
|
|
|
for _, v := range checkPostCSSConfig(projectDir) {
|
|
v.file = filepath.Clean(filepath.Join("..", v.file))
|
|
violations = append(violations, v)
|
|
}
|
|
violations = append(violations, checkCSSDirectives(frontendDir, projectDir)...)
|
|
|
|
return violations
|
|
}
|
|
|
|
func checkPostCSSConfig(projectDir string) []tailwindViolation {
|
|
var violations []tailwindViolation
|
|
|
|
configNames := []string{"postcss.config.js", "postcss.config.mjs", "postcss.config.cjs", "postcss.config.ts", ".postcssrc", ".postcssrc.js", ".postcssrc.json"}
|
|
|
|
for _, name := range configNames {
|
|
configPath := filepath.Join(projectDir, name)
|
|
if !fileExists(configPath) {
|
|
continue
|
|
}
|
|
|
|
violations = append(violations, scanPostCSSFile(configPath, name)...)
|
|
}
|
|
|
|
return violations
|
|
}
|
|
|
|
func scanPostCSSFile(configPath, name string) []tailwindViolation {
|
|
var violations []tailwindViolation
|
|
|
|
f, err := os.Open(configPath)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer helpers.LogDeferredError(nil, "close postcss config file", f.Close, "path", configPath)
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
lineNum := 0
|
|
hasV4Plugin := false
|
|
for scanner.Scan() {
|
|
lineNum++
|
|
line := scanner.Text()
|
|
|
|
if rePostCSSV4Plugin.MatchString(line) {
|
|
hasV4Plugin = true
|
|
}
|
|
|
|
if rePostCSSBarePlugin.MatchString(line) && !rePostCSSV4Plugin.MatchString(line) {
|
|
violations = append(violations, tailwindViolation{
|
|
file: name,
|
|
line: lineNum,
|
|
rule: "postcss-v4-plugin",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
|
|
if hasV4Plugin && rePostCSSAutoprefixer.MatchString(line) {
|
|
violations = append(violations, tailwindViolation{
|
|
file: name,
|
|
line: lineNum,
|
|
rule: "postcss-no-autoprefixer",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
}
|
|
|
|
return violations
|
|
}
|
|
|
|
func checkCSSDirectives(frontendDir, projectDir string) []tailwindViolation {
|
|
var violations []tailwindViolation
|
|
|
|
hasTailwindConfig := fileExists(filepath.Join(projectDir, "tailwind.config.ts")) ||
|
|
fileExists(filepath.Join(projectDir, "tailwind.config.js"))
|
|
|
|
if err := filepath.Walk(frontendDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(path, ".css") {
|
|
return nil
|
|
}
|
|
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") {
|
|
return nil
|
|
}
|
|
|
|
relPath, _ := filepath.Rel(frontendDir, path)
|
|
if relPath == "" {
|
|
relPath = path
|
|
}
|
|
|
|
f, openErr := os.Open(path)
|
|
if openErr != nil {
|
|
return nil
|
|
}
|
|
defer helpers.LogDeferredError(nil, "close CSS file for tailwind scan", f.Close, "path", path)
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
lineNum := 0
|
|
hasTailwindImport := false
|
|
hasConfigDirective := false
|
|
|
|
for scanner.Scan() {
|
|
lineNum++
|
|
line := scanner.Text()
|
|
|
|
if reTailwindV4Import.MatchString(line) {
|
|
hasTailwindImport = true
|
|
}
|
|
if reCSSConfigDirective.MatchString(line) {
|
|
hasConfigDirective = true
|
|
}
|
|
|
|
if reTailwindDirective.MatchString(line) {
|
|
violations = append(violations, tailwindViolation{
|
|
file: relPath,
|
|
line: lineNum,
|
|
rule: "css-v4-import",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
}
|
|
|
|
if hasTailwindImport && hasTailwindConfig && !hasConfigDirective {
|
|
violations = append(violations, tailwindViolation{
|
|
file: relPath,
|
|
line: 0,
|
|
rule: "css-missing-config",
|
|
snippet: "uses @import \"tailwindcss\" but missing @config directive (tailwind.config.* exists)",
|
|
})
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
violations = append(violations, tailwindViolation{
|
|
file: frontendDir,
|
|
line: 0,
|
|
rule: "walk-error",
|
|
snippet: err.Error(),
|
|
})
|
|
}
|
|
|
|
return violations
|
|
}
|