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>
148 lines
4.4 KiB
Go
148 lines
4.4 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// hintForColor returns a likely semantic replacement for a hardcoded color snippet.
|
|
// This is intentionally approximate and is used only in the consolidated fix block.
|
|
func hintForColor(snippet string) string {
|
|
s := strings.ToLower(snippet)
|
|
|
|
for _, prefix := range []string{
|
|
"bg-", "text-", "border-", "ring-", "shadow-", "outline-",
|
|
"decoration-", "divide-", "from-", "to-", "via-", "fill-", "stroke-",
|
|
"accent-", "caret-", "placeholder-",
|
|
} {
|
|
_, after, ok := strings.Cut(s, prefix)
|
|
if !ok {
|
|
continue
|
|
}
|
|
twPrefix := prefix[:len(prefix)-1]
|
|
|
|
isLowShadeBg := false
|
|
if twPrefix == "bg" || twPrefix == "from" || twPrefix == "to" || twPrefix == "via" {
|
|
for _, low := range []string{"-50", "-100", "-200"} {
|
|
if strings.Contains(after, low) {
|
|
isLowShadeBg = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(after, "green") || strings.HasPrefix(after, "emerald") || strings.HasPrefix(after, "lime"):
|
|
if isLowShadeBg {
|
|
return twPrefix + "-success/10"
|
|
}
|
|
return twPrefix + "-success"
|
|
case strings.HasPrefix(after, "red") || strings.HasPrefix(after, "rose"):
|
|
if isLowShadeBg {
|
|
return twPrefix + "-destructive/10"
|
|
}
|
|
return twPrefix + "-destructive"
|
|
case strings.HasPrefix(after, "amber") || strings.HasPrefix(after, "yellow") || strings.HasPrefix(after, "orange"):
|
|
if isLowShadeBg {
|
|
return twPrefix + "-warning/10"
|
|
}
|
|
return twPrefix + "-warning"
|
|
case strings.HasPrefix(after, "blue") || strings.HasPrefix(after, "sky") || strings.HasPrefix(after, "cyan"):
|
|
if isLowShadeBg {
|
|
return twPrefix + "-info/10"
|
|
}
|
|
return twPrefix + "-info"
|
|
case strings.HasPrefix(after, "indigo"):
|
|
return twPrefix + "-primary"
|
|
case strings.HasPrefix(after, "purple") || strings.HasPrefix(after, "violet") || strings.HasPrefix(after, "fuchsia") || strings.HasPrefix(after, "pink") || strings.HasPrefix(after, "teal"):
|
|
return twPrefix + "-accent"
|
|
case strings.HasPrefix(after, "gray") || strings.HasPrefix(after, "slate") || strings.HasPrefix(after, "zinc") || strings.HasPrefix(after, "neutral") || strings.HasPrefix(after, "stone"):
|
|
if twPrefix == "text" {
|
|
return "text-muted-foreground"
|
|
}
|
|
return twPrefix + "-muted"
|
|
}
|
|
}
|
|
|
|
if strings.Contains(s, "#") || strings.Contains(s, "rgb") || strings.Contains(s, "hsl") {
|
|
return "hsl(var(--token))"
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func formatColorViolations(scope string, violations []colorViolation) string {
|
|
if len(violations) == 0 {
|
|
return ""
|
|
}
|
|
|
|
sorted := append([]colorViolation(nil), violations...)
|
|
sort.Slice(sorted, func(i, j int) bool {
|
|
if sorted[i].file != sorted[j].file {
|
|
return sorted[i].file < sorted[j].file
|
|
}
|
|
if sorted[i].line != sorted[j].line {
|
|
return sorted[i].line < sorted[j].line
|
|
}
|
|
if sorted[i].rule != sorted[j].rule {
|
|
return sorted[i].rule < sorted[j].rule
|
|
}
|
|
return sorted[i].snippet < sorted[j].snippet
|
|
})
|
|
|
|
var out strings.Builder
|
|
fmt.Fprintf(&out, " FAIL: %d hardcoded color(s) in %s:\n", len(sorted), scope)
|
|
|
|
currentFile := ""
|
|
for _, v := range sorted {
|
|
if v.file != currentFile {
|
|
fmt.Fprintf(&out, " %s\n", v.file)
|
|
currentFile = v.file
|
|
}
|
|
if v.line > 0 {
|
|
fmt.Fprintf(&out, " %d [%s] %s\n", v.line, v.rule, v.snippet)
|
|
continue
|
|
}
|
|
fmt.Fprintf(&out, " [%s] %s\n", v.rule, v.snippet)
|
|
}
|
|
|
|
return strings.TrimRight(out.String(), "\n")
|
|
}
|
|
|
|
func collectColorHints(violationGroups ...[]colorViolation) []string {
|
|
seen := make(map[string]bool)
|
|
var hints []string
|
|
|
|
for _, violations := range violationGroups {
|
|
for _, v := range violations {
|
|
hint := hintForColor(v.snippet)
|
|
if hint == "" || seen[hint] {
|
|
continue
|
|
}
|
|
seen[hint] = true
|
|
hints = append(hints, hint)
|
|
}
|
|
}
|
|
|
|
sort.Strings(hints)
|
|
if len(hints) > 5 {
|
|
hints = append(hints[:5], "...")
|
|
}
|
|
|
|
return hints
|
|
}
|
|
|
|
func printColorFixInstructions(violationGroups ...[]colorViolation) {
|
|
fmt.Println(" Fix:")
|
|
if hints := collectColorHints(violationGroups...); len(hints) > 0 {
|
|
fmt.Printf(" 1. Likely replacements for this batch: %s\n", strings.Join(hints, ", "))
|
|
fmt.Println(" 2. Replace literals with semantic tokens or CSS vars where needed.")
|
|
} else {
|
|
fmt.Println(" 1. Replace literals with semantic tokens or CSS vars.")
|
|
}
|
|
fmt.Println(" 3. Tailwind token surface: tools/tailwind-service/input.base.css")
|
|
fmt.Println(" 4. Runtime theme variable generation: internal/theme/css.go")
|
|
fmt.Println(" 5. Plugin CSS examples: internal/plugins/defaultplugin/assets/style.css")
|
|
}
|