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, "