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>
476 lines
15 KiB
Go
476 lines
15 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"git.dev.alexdunmow.com/block/check-safety/internal/helpers"
|
|
)
|
|
|
|
type colorViolation struct {
|
|
file string
|
|
line int
|
|
rule string
|
|
snippet string
|
|
}
|
|
|
|
// reHexInTemplClass checks for hex colors in HTML class= attributes (templ syntax uses class=, not className=)
|
|
var reHexInTemplClass = regexp.MustCompile(`class=["'][^"']*#[0-9a-fA-F]{3,8}`)
|
|
|
|
// reInlineHexTemplStyle matches raw hex colors in templ style= string literals.
|
|
var reInlineHexTemplStyle = regexp.MustCompile(`style=(?:\{\s*)?["'][^"']*(?:color|background(?:-color)?)\s*:\s*#[0-9a-fA-F]{3,8}`)
|
|
|
|
// reInlineRGBTemplStyle matches literal rgb()/hsl() colors in templ style= string literals.
|
|
var reInlineRGBTemplStyle = regexp.MustCompile(`style=(?:\{\s*)?["'][^"']*(?:color|background(?:-color)?)\s*:\s*(?:rgb|hsl)a?\([^"']*\)`)
|
|
|
|
// checkTemplColors scans .templ files under root for hardcoded color violations.
|
|
// Templ files use HTML class= (not React className=), so they need separate handling.
|
|
func checkTemplColors(root string) []colorViolation {
|
|
var violations []colorViolation
|
|
|
|
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
if filepath.Ext(path) != ".templ" {
|
|
return nil
|
|
}
|
|
if strings.Contains(path, "/vendor/") || strings.Contains(path, "/.git/") {
|
|
return nil
|
|
}
|
|
|
|
relPath, _ := filepath.Rel(root, path)
|
|
if relPath == "" {
|
|
relPath = path
|
|
}
|
|
|
|
if isColorAllowed(relPath) {
|
|
return nil
|
|
}
|
|
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer helpers.LogDeferredError(nil, "close templ color scan file", f.Close, "path", path)
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
lineNum := 0
|
|
for scanner.Scan() {
|
|
lineNum++
|
|
line := scanner.Text()
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "/*") {
|
|
continue
|
|
}
|
|
if reColorDataKey.MatchString(trimmed) &&
|
|
!strings.Contains(trimmed, "class=") &&
|
|
!strings.Contains(trimmed, "className=") &&
|
|
!strings.Contains(trimmed, "style=") {
|
|
continue
|
|
}
|
|
|
|
if reTwHardcodedColor.MatchString(line) {
|
|
match := reTwHardcodedColor.FindString(line)
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-hardcoded-tw-color",
|
|
snippet: match,
|
|
})
|
|
}
|
|
|
|
if reHexInTemplClass.MatchString(line) {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-hex-in-class",
|
|
snippet: strings.TrimSpace(reHexInTemplClass.FindString(line)),
|
|
})
|
|
}
|
|
|
|
if reTwArbitraryHex.MatchString(line) {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-tw-arbitrary-hex",
|
|
snippet: reTwArbitraryHex.FindString(line),
|
|
})
|
|
}
|
|
|
|
if reTwArbitraryRGB.MatchString(line) {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-tw-arbitrary-rgb",
|
|
snippet: reTwArbitraryRGB.FindString(line),
|
|
})
|
|
}
|
|
|
|
if reInlineHexTemplStyle.MatchString(line) {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-inline-hex-style",
|
|
snippet: strings.TrimSpace(reInlineHexTemplStyle.FindString(line)),
|
|
})
|
|
}
|
|
|
|
if reInlineRGBTemplStyle.MatchString(line) && !strings.Contains(line, "var(--") {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-inline-rgb-style",
|
|
snippet: strings.TrimSpace(reInlineRGBTemplStyle.FindString(line)),
|
|
})
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
violations = append(violations, colorViolation{
|
|
file: filepath.ToSlash(root),
|
|
line: 0,
|
|
rule: "walk-error",
|
|
snippet: err.Error(),
|
|
})
|
|
}
|
|
|
|
return violations
|
|
}
|
|
|
|
// Only files that ARE theme definitions may have raw color values.
|
|
var colorAllowedDirs []string
|
|
var colorAllowedFiles = map[string]bool{
|
|
"routes/admin/system.tsx": true, // Theme preset picker — displays theme color swatches using actual HSL values
|
|
"components/settings/theme-card.tsx": true, // Orchestrator theme gallery — preview swatches need real HSL ramps
|
|
"routes/dashboard/sites/new/step-design.tsx": true, // Template chooser artwork uses explicit preview ramps
|
|
}
|
|
|
|
// Tailwind color names that should use semantic tokens instead
|
|
var twColorNames = []string{
|
|
"gray", "slate", "zinc", "neutral", "stone",
|
|
"red", "orange", "amber", "yellow", "lime",
|
|
"green", "emerald", "teal", "cyan", "sky",
|
|
"blue", "indigo", "violet", "purple", "fuchsia",
|
|
"pink", "rose",
|
|
}
|
|
|
|
var (
|
|
// Matches: text-blue-500, bg-red-100, border-green-700, ring-amber-300, etc.
|
|
// Also matches hover:, focus:, dark: variants
|
|
reTwHardcodedColor *regexp.Regexp
|
|
|
|
// Matches: #fff, #000000, #3b82f6, etc. in className or JSX strings
|
|
reHexInClass = regexp.MustCompile(`className=["'\x60][^"'\x60]*#[0-9a-fA-F]{3,8}`)
|
|
|
|
// Matches: style={{ color: '#...', backgroundColor: '#...' }} with string literals
|
|
reInlineHexStyle = regexp.MustCompile(`style=\{?\{[^}]*(?:color|backgroundColor|background)\s*:\s*['"]#[0-9a-fA-F]{3,8}['"]`)
|
|
|
|
// Matches: style={{ color: 'rgb(...)', backgroundColor: 'hsl(...)' }} with string literals (not var(--)
|
|
reInlineRGBStyle = regexp.MustCompile(`style=\{?\{[^}]*(?:color|backgroundColor|background)\s*:\s*['"](?:rgb|hsl)a?\([^)]*\)['"]`)
|
|
|
|
// Matches Tailwind arbitrary hex values: bg-[#ff0000], text-[#333], border-[#abc123]
|
|
reTwArbitraryHex = regexp.MustCompile(`(?:bg|text|border|ring|fill|stroke)-\[#[0-9a-fA-F]{3,8}\]`)
|
|
|
|
// Matches Tailwind arbitrary rgb/hsl values: bg-[rgb(255,0,0)], text-[hsl(0,100%,50%)]
|
|
reTwArbitraryRGB = regexp.MustCompile(`(?:bg|text|border)-\[(?:rgb|hsl)a?\([^)]+\)\]`)
|
|
|
|
// Heuristic: skip lines that are defining data/constants (color maps, arrays)
|
|
reColorDataKey = regexp.MustCompile(`(?:hex|value|color)\s*[:=]`)
|
|
)
|
|
|
|
func init() {
|
|
// Build regex for tailwind hardcoded colors: (dark:|hover:|focus:)?(bg|text|border|ring|shadow|outline|decoration|divide|from|to|via)-(color)-(shade)
|
|
var colorAlts []string
|
|
colorAlts = append(colorAlts, twColorNames...)
|
|
colorGroup := strings.Join(colorAlts, "|")
|
|
// Match in className strings — look for the Tailwind class pattern
|
|
reTwHardcodedColor = regexp.MustCompile(
|
|
`(?:(?:dark|hover|focus|active|group-hover|peer-hover|disabled):)*` +
|
|
`(?:bg|text|border|ring|shadow|outline|decoration|divide|from|to|via|fill|stroke|accent|caret|placeholder)-` +
|
|
`(?:` + colorGroup + `)-` +
|
|
`\d{2,3}`,
|
|
)
|
|
}
|
|
|
|
func isColorAllowed(relPath string) bool {
|
|
if colorAllowedFiles[relPath] {
|
|
return true
|
|
}
|
|
for _, dir := range colorAllowedDirs {
|
|
if strings.HasPrefix(relPath, dir) {
|
|
return true
|
|
}
|
|
}
|
|
// Files with "color" in the name are likely color utilities
|
|
base := filepath.Base(relPath)
|
|
return strings.Contains(strings.ToLower(base), "color")
|
|
}
|
|
|
|
// reNinjaTplHardcodedHSL matches hsl() with literal numeric hue values — not CSS variable references.
|
|
// Flags: hsl(168, 82%, 46%), hsl(150 27% 33%)
|
|
// Allows: hsl(var(--primary)), hsl(var(--background))
|
|
var reNinjaTplHardcodedHSL = regexp.MustCompile(`\bhsl\(\s*\d`)
|
|
|
|
// reNinjaTplHardcodedRGB matches rgb()/rgba() with literal numeric values.
|
|
var reNinjaTplHardcodedRGB = regexp.MustCompile(`\brgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)`)
|
|
|
|
// reNinjaTplArbitraryColor matches Tailwind arbitrary HSL/RGB/RGBA values in class= attributes.
|
|
// Flags: text-[hsl(168,82%,46%)], bg-[rgba(130,70,220,0.1)], border-[rgb(0,205,155)]
|
|
var reNinjaTplArbitraryColor = regexp.MustCompile(
|
|
`(?:bg|text|border|ring|fill|stroke|from|to|via)-\[(?:hsl|rgb|rgba)\([^)]+\)\]`,
|
|
)
|
|
|
|
// reNinjaTplArbitraryHex matches Tailwind arbitrary hex values: bg-[#ff0000], text-[#333]
|
|
var reNinjaTplArbitraryHex = regexp.MustCompile(`(?:bg|text|border|ring|fill|stroke)-\[#[0-9a-fA-F]{3,8}\]`)
|
|
|
|
// reNinjaTplInlineHex matches hex colors inside style= attributes.
|
|
var reNinjaTplInlineHex = regexp.MustCompile(`style=[^>]*#[0-9a-fA-F]{3,8}`)
|
|
|
|
// reNinjaTplExtractRGB extracts the first three RGB components from an rgb/rgba call.
|
|
var reNinjaTplExtractRGB = regexp.MustCompile(`\brgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)`)
|
|
|
|
// isNeutralRGB reports whether an rgb/rgba match uses only 0 or 255 for all three channels
|
|
// (pure black, pure white, or transparent). These are structural overlays, not brand colors.
|
|
func isNeutralRGB(match string) bool {
|
|
m := reNinjaTplExtractRGB.FindStringSubmatch(match)
|
|
if m == nil {
|
|
return false
|
|
}
|
|
for _, v := range m[1:4] {
|
|
if v != "0" && v != "255" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// checkNinjaTplColors scans .ninjatpl block templates under root for hardcoded color violations.
|
|
// All colors in .ninjatpl files must use CSS custom properties (hsl(var(--token))) rather than
|
|
// literal HSL, RGB, RGBA, or hex values.
|
|
func checkNinjaTplColors(root string) []colorViolation {
|
|
var violations []colorViolation
|
|
|
|
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
if filepath.Ext(path) != ".ninjatpl" {
|
|
return nil
|
|
}
|
|
if strings.Contains(path, "/vendor/") || strings.Contains(path, "/.git/") {
|
|
return nil
|
|
}
|
|
|
|
relPath, _ := filepath.Rel(root, path)
|
|
if relPath == "" {
|
|
relPath = path
|
|
}
|
|
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer helpers.LogDeferredError(nil, "close ninjatpl color scan file", f.Close, "path", path)
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
lineNum := 0
|
|
for scanner.Scan() {
|
|
lineNum++
|
|
line := scanner.Text()
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
// Skip HTML comments
|
|
if strings.HasPrefix(trimmed, "<!--") {
|
|
continue
|
|
}
|
|
|
|
// 1. Hardcoded hsl() with literal values (not var(--)
|
|
if reNinjaTplHardcodedHSL.MatchString(line) {
|
|
for _, m := range reNinjaTplHardcodedHSL.FindAllStringIndex(line, -1) {
|
|
ctx := line[m[0]:]
|
|
if !strings.HasPrefix(ctx, "hsl(var(") {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-hardcoded-hsl",
|
|
snippet: line[m[0]:min(m[0]+30, len(line))],
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// 2. Hardcoded rgb()/rgba() with non-neutral values
|
|
if reNinjaTplHardcodedRGB.MatchString(line) {
|
|
for _, m := range reNinjaTplHardcodedRGB.FindAllString(line, -1) {
|
|
if !isNeutralRGB(m) {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-hardcoded-rgb",
|
|
snippet: m,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// 3. Hex colors in style= attributes
|
|
if reNinjaTplInlineHex.MatchString(line) {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-inline-hex",
|
|
snippet: strings.TrimSpace(reNinjaTplInlineHex.FindString(line)),
|
|
})
|
|
}
|
|
|
|
// 4. Tailwind arbitrary hsl/rgb/rgba values: text-[hsl(...)], bg-[rgba(...)]
|
|
if reNinjaTplArbitraryColor.MatchString(line) {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-tw-arbitrary-color",
|
|
snippet: reNinjaTplArbitraryColor.FindString(line),
|
|
})
|
|
}
|
|
|
|
// 5. Tailwind arbitrary hex values: bg-[#333], text-[#ff0000]
|
|
if reNinjaTplArbitraryHex.MatchString(line) {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-tw-arbitrary-hex",
|
|
snippet: reNinjaTplArbitraryHex.FindString(line),
|
|
})
|
|
}
|
|
|
|
// 6. Tailwind named color classes (e.g. text-blue-500, bg-amber-200)
|
|
if reTwHardcodedColor.MatchString(line) {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-hardcoded-tw-color",
|
|
snippet: reTwHardcodedColor.FindString(line),
|
|
})
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
violations = append(violations, colorViolation{
|
|
file: filepath.ToSlash(root),
|
|
line: 0,
|
|
rule: "walk-error",
|
|
snippet: err.Error(),
|
|
})
|
|
}
|
|
|
|
return violations
|
|
}
|
|
|
|
func checkColors(webSrcDir string) []colorViolation {
|
|
var violations []colorViolation
|
|
|
|
if err := filepath.Walk(webSrcDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
|
|
ext := filepath.Ext(path)
|
|
if ext != ".tsx" && ext != ".ts" && ext != ".jsx" {
|
|
return nil
|
|
}
|
|
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") {
|
|
return nil
|
|
}
|
|
|
|
relPath, _ := filepath.Rel(webSrcDir, path)
|
|
if relPath == "" {
|
|
relPath = path
|
|
}
|
|
|
|
if isColorAllowed(relPath) {
|
|
return nil
|
|
}
|
|
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer helpers.LogDeferredError(nil, "close frontend color scan file", f.Close, "path", path)
|
|
|
|
scanner := bufio.NewScanner(f)
|
|
lineNum := 0
|
|
for scanner.Scan() {
|
|
lineNum++
|
|
line := scanner.Text()
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
// Skip comments
|
|
if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "/*") {
|
|
continue
|
|
}
|
|
|
|
// Skip SVG fill/stroke attributes (these can't use Tailwind classes)
|
|
if strings.Contains(trimmed, "fill=") || strings.Contains(trimmed, "stroke=") {
|
|
if !strings.Contains(trimmed, "className") {
|
|
continue
|
|
}
|
|
}
|
|
|
|
// Skip lines that are just defining data/constants (color maps, arrays)
|
|
// Heuristic: if the line contains hex/value/color as an object key, it's data
|
|
if reColorDataKey.MatchString(trimmed) &&
|
|
!strings.Contains(trimmed, "class=") &&
|
|
!strings.Contains(trimmed, "className=") &&
|
|
!strings.Contains(trimmed, "style=") {
|
|
continue
|
|
}
|
|
|
|
// Check for Tailwind hardcoded color classes
|
|
if reTwHardcodedColor.MatchString(line) {
|
|
match := reTwHardcodedColor.FindString(line)
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-hardcoded-tw-color",
|
|
snippet: match,
|
|
})
|
|
}
|
|
|
|
// Check for hex colors in className
|
|
if reHexInClass.MatchString(line) {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-hex-in-className",
|
|
snippet: strings.TrimSpace(reHexInClass.FindString(line)),
|
|
})
|
|
}
|
|
|
|
// Check for inline style hex colors (skip if value is a variable)
|
|
if reInlineHexStyle.MatchString(line) {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-inline-hex-style",
|
|
snippet: strings.TrimSpace(reInlineHexStyle.FindString(line)),
|
|
})
|
|
}
|
|
|
|
// Check for inline style rgb/hsl with literal values (not css variables)
|
|
if reInlineRGBStyle.MatchString(line) {
|
|
// Skip if it's using css variables: hsl(var(--...))
|
|
if !strings.Contains(line, "var(--") {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-inline-rgb-style",
|
|
snippet: strings.TrimSpace(reInlineRGBStyle.FindString(line)),
|
|
})
|
|
}
|
|
}
|
|
|
|
// Check for Tailwind arbitrary hex values: bg-[#ff0000], text-[#333]
|
|
if reTwArbitraryHex.MatchString(line) {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-tw-arbitrary-hex",
|
|
snippet: strings.TrimSpace(reTwArbitraryHex.FindString(line)),
|
|
})
|
|
}
|
|
|
|
// Check for Tailwind arbitrary rgb/hsl values: bg-[rgb(255,0,0)]
|
|
if reTwArbitraryRGB.MatchString(line) {
|
|
violations = append(violations, colorViolation{
|
|
file: relPath, line: lineNum, rule: "no-tw-arbitrary-rgb",
|
|
snippet: strings.TrimSpace(reTwArbitraryRGB.FindString(line)),
|
|
})
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
violations = append(violations, colorViolation{
|
|
file: filepath.ToSlash(webSrcDir),
|
|
line: 0,
|
|
rule: "walk-error",
|
|
snippet: err.Error(),
|
|
})
|
|
}
|
|
|
|
return violations
|
|
}
|