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

246 lines
6.3 KiB
Go

package main
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"strings"
)
// --- Check: Button automation attributes ---
type buttonViolation struct {
file string
line int
snippet string
}
// Matches <Button without an action= prop on the same or adjacent lines
// We check for <Button that does NOT have action= on the same line
var reButtonOpen = regexp.MustCompile(`<Button\b`)
var reButtonAction = regexp.MustCompile(`\baction=`)
// reButtonSelfClose removed — no longer needed with lookahead approach
// Skip buttons that are type="submit" (action="submit" is implicit)
var reButtonSubmit = regexp.MustCompile(`type=["']submit["']`)
// Skip AlertDialogAction (doesn't support action prop per CLAUDE.md)
var reAlertDialogAction = regexp.MustCompile(`<AlertDialogAction`)
func checkButtonAutomation(webSrcDir string) []buttonViolation {
var violations []buttonViolation
if err := filepath.Walk(webSrcDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".tsx") {
return nil
}
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") {
return nil
}
relPath, _ := filepath.Rel(webSrcDir, path)
// Skip UI primitive definitions (they define Button, not use it)
if strings.HasPrefix(relPath, "components/ui/") {
return nil
}
// Read entire file into lines for lookahead
data, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(string(data), "\n")
for i, line := range lines {
lineNum := i + 1
if !reButtonOpen.MatchString(line) {
continue
}
// Skip AlertDialogAction
if reAlertDialogAction.MatchString(line) {
continue
}
// Skip if line is a comment
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "{/*") {
continue
}
// Skip type="submit" buttons (implicit action)
if reButtonSubmit.MatchString(line) {
continue
}
// Collect the full opening tag into one string, strip {…}
// expressions, then check for action= in the flat props.
hasAction := reButtonAction.MatchString(line)
if !hasAction {
tag := collectJSXTag(lines, i)
hasAction = reButtonAction.MatchString(tag)
}
if hasAction {
continue
}
violations = append(violations, buttonViolation{
file: relPath,
line: lineNum,
snippet: strings.TrimSpace(line),
})
}
return nil
}); err != nil {
violations = append(violations, buttonViolation{
file: webSrcDir,
line: 0,
snippet: err.Error(),
})
}
return violations
}
// --- Check: Services in correct directories ---
type structureViolation struct {
file string
line int
rule string
snippet string
}
// collectJSXTag joins lines starting at idx into a single string,
// strips all {…} brace expressions (nested), and returns the flat
// prop-level content up to the closing >.
func collectJSXTag(lines []string, idx int) string {
var sb strings.Builder
depth := 0
for j := idx; j < len(lines) && j <= idx+50; j++ {
for _, ch := range lines[j] {
switch ch {
case '{':
depth++
case '}':
depth--
default:
if depth == 0 {
sb.WriteRune(ch)
}
}
}
// Check if tag closed at prop level
flat := sb.String()
if depth == 0 && j > idx && strings.Contains(flat, ">") {
break
}
sb.WriteRune(' ')
}
return sb.String()
}
// checkServiceStructure verifies the core backend layout only:
// service implementations live in internal/services/ and concrete handlers in
// internal/handlers/. External plugin repos commonly use a flatter layout, so
// this check intentionally skips roots that do not look like the core backend.
func checkServiceStructure(root string) []structureViolation {
var violations []structureViolation
if !usesCoreInternalLayout(root) {
return violations
}
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
return nil
}
if strings.Contains(path, "/vendor/") || strings.HasSuffix(path, "_test.go") {
return nil
}
if strings.HasSuffix(path, ".connect.go") || strings.HasSuffix(path, ".pb.go") || strings.HasSuffix(path, ".gen.go") {
return nil
}
relPath, _ := filepath.Rel(root, path)
relPath = filepath.ToSlash(relPath)
if strings.HasPrefix(relPath, "internal/api/") || strings.HasPrefix(relPath, "api/") {
return nil
}
// Skip expected locations
isServicesDir := strings.HasPrefix(relPath, "internal/services/")
isHandlersDir := strings.HasPrefix(relPath, "internal/handlers/")
isCmdDir := strings.HasPrefix(relPath, "cmd/")
if isServicesDir || isHandlersDir || isCmdDir {
return nil
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil
}
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.TYPE {
continue
}
for _, spec := range genDecl.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
name := ts.Name.Name
// Service types should be in internal/services/
if strings.HasSuffix(name, "Service") && !strings.HasPrefix(relPath, "internal/") {
pos := fset.Position(ts.Pos())
violations = append(violations, structureViolation{
file: relPath,
line: pos.Line,
rule: "service-location",
snippet: "type " + name + " — should be in internal/services/",
})
}
// Handler types should be in internal/handlers/
if strings.HasSuffix(name, "Handler") && !strings.HasPrefix(relPath, "internal/") {
pos := fset.Position(ts.Pos())
violations = append(violations, structureViolation{
file: relPath,
line: pos.Line,
rule: "handler-location",
snippet: "type " + name + " — should be in internal/handlers/",
})
}
}
}
return nil
}); err != nil {
violations = append(violations, structureViolation{
file: root,
line: 0,
rule: "walk-error",
snippet: err.Error(),
})
}
return violations
}
func usesCoreInternalLayout(root string) bool {
return dirExists(filepath.Join(root, "internal", "services")) ||
dirExists(filepath.Join(root, "internal", "handlers"))
}