check-safety/colors.go
Alex Dunmow 2c7fe9aa62 fix: model orchestrator + cli as first-class scan targets (WO-WZ-023)
- resolveScanRoots recognizes the orchestrator layout (backend/go.mod +
  frontend/src) so backendDir resolves to backend/ — fixes the go.mod check,
  classifies the frontend as a standalone app (not a plugin), and routes tsc
  through the plain typecheck. Clears three orchestrator false-positives.
- check 2 (RBAC): only require MethodRoles for proto services a target actually
  serves (mounted connect handler). The orchestrator carries but does not serve
  the blockninja.v1 surface (714 false 'missing' -> 0). Exclude cli
  (definitions-only, vendors a registry client) from check 2.
- Skip buf-generated gen/ output in the placeholder check (real proto enum
  COMING_SOON values are not TODO debt).
- Normalize the color allowlist path so theme-card.tsx swatch previews match
  regardless of scan root.
- Whitelist the gitignored, tool-regenerated styles/package-lock.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 22:39:47 +08:00

498 lines
16 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 {
// The color allowlist keys are source-relative (e.g.
// "components/settings/theme-card.tsx"), but relPath is computed relative to
// whatever scan root the caller used. That root varies by repo: the CMS
// frontend scan root is "web/src" (relPath = "components/..."), while the
// orchestrator's is "frontend" (relPath = "src/components/..."). Trim any of
// these leading source prefixes so one allowlist entry matches regardless of
// which repo is the primary scan target. Mirrors isAllowedPlaceholderFile's
// web/src trimming.
normalized := filepath.ToSlash(relPath)
normalized = strings.TrimPrefix(normalized, "web/src/")
normalized = strings.TrimPrefix(normalized, "frontend/src/")
normalized = strings.TrimPrefix(normalized, "src/")
if colorAllowedFiles[normalized] {
return true
}
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
}
// Email wrapper templates (templates/email/*.ninjatpl) legitimately
// require literal hex: mail clients don't support CSS custom
// properties, so themed emails inline resolved theme colors
// ({{ colors.* }}) with hex fallbacks (WO-WZ-021).
if strings.Contains(filepath.ToSlash(path), "/templates/email/") {
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
}