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>
246 lines
6.3 KiB
Go
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"))
|
|
}
|