check-safety/presets.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

116 lines
2.8 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"git.dev.alexdunmow.com/block/check-safety/internal/theme"
)
type presetViolation struct {
file string
message string
}
type presetJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Theme theme.Theme `json:"theme"`
}
func checkPresets(root string) []presetViolation {
var violations []presetViolation
presetsFiles := findPresetsFiles(root)
for _, path := range presetsFiles {
relPath, _ := filepath.Rel(root, path)
if relPath == "" {
relPath = path
}
data, err := os.ReadFile(path)
if err != nil {
violations = append(violations, presetViolation{
file: relPath,
message: fmt.Sprintf("cannot read file: %v", err),
})
continue
}
// Strict unmarshal: catches type mismatches (e.g., int where string expected)
var presets []presetJSON
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&presets); err != nil {
// Distinguish between unknown fields (warning-worthy) and hard parse errors
if isUnknownFieldError(err) {
// Re-try without strict mode to get the actual parse result
var lenient []presetJSON
if lenientErr := json.Unmarshal(data, &lenient); lenientErr != nil {
violations = append(violations, presetViolation{
file: relPath,
message: fmt.Sprintf("invalid JSON: %v", lenientErr),
})
} else {
violations = append(violations, presetViolation{
file: relPath,
message: fmt.Sprintf("unknown fields (stale preset data): %v", err),
})
}
} else {
violations = append(violations, presetViolation{
file: relPath,
message: fmt.Sprintf("invalid JSON: %v", err),
})
}
continue
}
for i, p := range presets {
if p.ID == "" {
violations = append(violations, presetViolation{
file: relPath,
message: fmt.Sprintf("preset[%d]: missing \"id\" field", i),
})
}
if p.Name == "" {
violations = append(violations, presetViolation{
file: relPath,
message: fmt.Sprintf("preset[%d]: missing \"name\" field", i),
})
}
}
}
return violations
}
func findPresetsFiles(root string) []string {
var files []string
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
if info != nil && info.IsDir() {
switch info.Name() {
case ".git", "vendor", "node_modules", "web", "dist":
return filepath.SkipDir
}
}
return nil
}
if info.Name() == "presets.json" {
files = append(files, path)
}
return nil
})
return files
}
func isUnknownFieldError(err error) bool {
return strings.Contains(err.Error(), "unknown field")
}