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>
222 lines
6.0 KiB
Go
222 lines
6.0 KiB
Go
package main
|
|
|
|
import (
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
type anyUsageWarning struct {
|
|
file string
|
|
line int
|
|
lang string
|
|
snippet string
|
|
}
|
|
|
|
var (
|
|
reTypeScriptAny = regexp.MustCompile(`\bany\b`)
|
|
reTypeScriptAsAny = regexp.MustCompile(`\bas\s+any\b`)
|
|
reTypeScriptColonAny = regexp.MustCompile(`:\s*any(?:\b|[\s,\]\)>|=])`)
|
|
reTypeScriptCastAny = regexp.MustCompile(`<\s*any\s*>`)
|
|
reTypeScriptArrayAny = regexp.MustCompile(`\bany\s*\[\]`)
|
|
reTypeScriptEqAny = regexp.MustCompile(`=\s*any(?:\b|[\s;])`)
|
|
)
|
|
|
|
func checkGoAnyUsage(root string) []anyUsageWarning {
|
|
var warnings []anyUsageWarning
|
|
|
|
_ = 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, ".pb.go") || strings.HasSuffix(path, ".connect.go") || strings.HasSuffix(path, ".gen.go") || strings.HasSuffix(path, "_templ.go") {
|
|
return nil
|
|
}
|
|
|
|
lines, readErr := readSourceLines(path)
|
|
if readErr != nil {
|
|
return nil
|
|
}
|
|
|
|
fset := token.NewFileSet()
|
|
file, parseErr := parser.ParseFile(fset, path, nil, 0)
|
|
if parseErr != nil {
|
|
return nil
|
|
}
|
|
|
|
relPath, _ := filepath.Rel(root, path)
|
|
relPath = filepath.ToSlash(relPath)
|
|
seen := make(map[int]bool)
|
|
recordAnyExpr := func(expr ast.Expr) {
|
|
for _, pos := range findAnyTypePositions(expr) {
|
|
position := fset.Position(pos)
|
|
if seen[position.Line] {
|
|
continue
|
|
}
|
|
seen[position.Line] = true
|
|
snippet := ""
|
|
if position.Line-1 >= 0 && position.Line-1 < len(lines) {
|
|
snippet = strings.TrimSpace(lines[position.Line-1])
|
|
}
|
|
warnings = append(warnings, anyUsageWarning{
|
|
file: relPath,
|
|
line: position.Line,
|
|
lang: "go",
|
|
snippet: snippet,
|
|
})
|
|
}
|
|
}
|
|
|
|
ast.Inspect(file, func(n ast.Node) bool {
|
|
switch node := n.(type) {
|
|
case *ast.Field:
|
|
recordAnyExpr(node.Type)
|
|
case *ast.TypeSpec:
|
|
recordAnyExpr(node.Type)
|
|
case *ast.ValueSpec:
|
|
recordAnyExpr(node.Type)
|
|
case *ast.FuncDecl:
|
|
if node.Type != nil {
|
|
recordAnyExpr(node.Type)
|
|
}
|
|
case *ast.FuncLit:
|
|
if node.Type != nil {
|
|
recordAnyExpr(node.Type)
|
|
}
|
|
case *ast.TypeAssertExpr:
|
|
recordAnyExpr(node.Type)
|
|
case *ast.CompositeLit:
|
|
recordAnyExpr(node.Type)
|
|
}
|
|
return true
|
|
})
|
|
|
|
return nil
|
|
})
|
|
|
|
return warnings
|
|
}
|
|
|
|
func findAnyTypePositions(expr ast.Expr) []token.Pos {
|
|
if expr == nil {
|
|
return nil
|
|
}
|
|
|
|
var positions []token.Pos
|
|
switch node := expr.(type) {
|
|
case *ast.Ident:
|
|
if node.Name == "any" {
|
|
positions = append(positions, node.Pos())
|
|
}
|
|
case *ast.ArrayType:
|
|
positions = append(positions, findAnyTypePositions(node.Elt)...)
|
|
case *ast.MapType:
|
|
positions = append(positions, findAnyTypePositions(node.Key)...)
|
|
positions = append(positions, findAnyTypePositions(node.Value)...)
|
|
case *ast.ChanType:
|
|
positions = append(positions, findAnyTypePositions(node.Value)...)
|
|
case *ast.Ellipsis:
|
|
positions = append(positions, findAnyTypePositions(node.Elt)...)
|
|
case *ast.ParenExpr:
|
|
positions = append(positions, findAnyTypePositions(node.X)...)
|
|
case *ast.StarExpr:
|
|
positions = append(positions, findAnyTypePositions(node.X)...)
|
|
case *ast.IndexExpr:
|
|
positions = append(positions, findAnyTypePositions(node.X)...)
|
|
positions = append(positions, findAnyTypePositions(node.Index)...)
|
|
case *ast.IndexListExpr:
|
|
positions = append(positions, findAnyTypePositions(node.X)...)
|
|
for _, index := range node.Indices {
|
|
positions = append(positions, findAnyTypePositions(index)...)
|
|
}
|
|
case *ast.SelectorExpr:
|
|
positions = append(positions, findAnyTypePositions(node.X)...)
|
|
case *ast.StructType:
|
|
if node.Fields != nil {
|
|
for _, field := range node.Fields.List {
|
|
positions = append(positions, findAnyTypePositions(field.Type)...)
|
|
}
|
|
}
|
|
case *ast.InterfaceType:
|
|
if node.Methods != nil {
|
|
for _, field := range node.Methods.List {
|
|
positions = append(positions, findAnyTypePositions(field.Type)...)
|
|
}
|
|
}
|
|
case *ast.FuncType:
|
|
if node.Params != nil {
|
|
for _, field := range node.Params.List {
|
|
positions = append(positions, findAnyTypePositions(field.Type)...)
|
|
}
|
|
}
|
|
if node.Results != nil {
|
|
for _, field := range node.Results.List {
|
|
positions = append(positions, findAnyTypePositions(field.Type)...)
|
|
}
|
|
}
|
|
}
|
|
|
|
return positions
|
|
}
|
|
|
|
func checkTypeScriptAnyUsage(root string) []anyUsageWarning {
|
|
var warnings []anyUsageWarning
|
|
|
|
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
ext := filepath.Ext(path)
|
|
if ext != ".ts" && ext != ".tsx" {
|
|
return nil
|
|
}
|
|
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") || strings.Contains(path, "/generated/") {
|
|
return nil
|
|
}
|
|
if strings.HasSuffix(path, ".d.ts") || strings.HasSuffix(path, ".gen.ts") || strings.HasSuffix(path, ".gen.tsx") {
|
|
return nil
|
|
}
|
|
|
|
lines, readErr := readSourceLines(path)
|
|
if readErr != nil {
|
|
return nil
|
|
}
|
|
|
|
relPath, _ := filepath.Rel(root, path)
|
|
relPath = filepath.ToSlash(relPath)
|
|
for idx, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" || strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") {
|
|
continue
|
|
}
|
|
if strings.Contains(line, "'any'") || strings.Contains(line, `"any"`) || strings.Contains(line, "`any") || strings.Contains(line, "any`") {
|
|
continue
|
|
}
|
|
if !reTypeScriptAny.MatchString(line) {
|
|
continue
|
|
}
|
|
if !reTypeScriptAsAny.MatchString(line) && !reTypeScriptColonAny.MatchString(line) && !reTypeScriptCastAny.MatchString(line) && !reTypeScriptArrayAny.MatchString(line) && !reTypeScriptEqAny.MatchString(line) {
|
|
continue
|
|
}
|
|
|
|
warnings = append(warnings, anyUsageWarning{
|
|
file: relPath,
|
|
line: idx + 1,
|
|
lang: "ts",
|
|
snippet: trimmed,
|
|
})
|
|
}
|
|
|
|
return nil
|
|
})
|
|
|
|
return warnings
|
|
}
|