check-safety/colors.go
Alex Dunmow cd88c808b0 initial: standalone check-safety module hoisted from CMS
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>
2026-06-06 13:04:02 +08:00

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
}