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>
134 lines
4.3 KiB
Go
134 lines
4.3 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
type rawSQLViolation struct {
|
|
file string
|
|
line int
|
|
snippet string
|
|
}
|
|
|
|
// SQL keywords that indicate raw SQL usage
|
|
var sqlKeywordRe = regexp.MustCompile(`(?i)\b(INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM|SELECT\s+.+\s+FROM|ALTER\s+TABLE|DROP\s+TABLE|CREATE\s+TABLE)\b`)
|
|
|
|
// Directories where raw SQL is expected (migrations, sqlc queries, seeder, test fixtures)
|
|
func isRawSQLAllowed(path string) bool {
|
|
return strings.Contains(path, "/sql/") ||
|
|
strings.Contains(path, "/migrations/") ||
|
|
strings.Contains(path, "/internal/db/") ||
|
|
strings.Contains(path, "/seeder/") ||
|
|
strings.HasSuffix(path, "_test.go") ||
|
|
strings.Contains(path, "/cmd/admincli/") ||
|
|
strings.Contains(path, "/datasources/") || // Bob query builder files
|
|
strings.Contains(path, "internal/plugins/") // Plugin tables are not in core schema
|
|
}
|
|
|
|
// Specific SQL patterns that are allowed (postgres admin commands, not data queries)
|
|
var allowedSQLPatterns = []string{
|
|
"pg_terminate_backend", // backup_service.go — kills connections before restore
|
|
"FROM pg_database", // Administrative catalog lookup for tenant database existence
|
|
}
|
|
|
|
func isSQLAllowed(sql string) bool {
|
|
for _, pattern := range allowedSQLPatterns {
|
|
if strings.Contains(sql, pattern) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// checkRawSQL finds raw SQL string literals passed to Exec/Query methods outside allowed dirs.
|
|
func checkRawSQL(root string) []rawSQLViolation {
|
|
var violations []rawSQLViolation
|
|
|
|
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/") {
|
|
return nil
|
|
}
|
|
if isRawSQLAllowed(path) {
|
|
return nil
|
|
}
|
|
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, path, nil, 0)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
relPath, _ := filepath.Rel(root, path)
|
|
|
|
ast.Inspect(f, func(n ast.Node) bool {
|
|
call, ok := n.(*ast.CallExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
|
|
// Match method calls: *.ExecContext, *.QueryContext, *.QueryRowContext, *.Exec, *.Query, *.QueryRow
|
|
sel, ok := call.Fun.(*ast.SelectorExpr)
|
|
if !ok {
|
|
return true
|
|
}
|
|
method := sel.Sel.Name
|
|
isDBMethod := method == "ExecContext" || method == "QueryContext" || method == "QueryRowContext" ||
|
|
method == "Exec" || method == "Query" || method == "QueryRow"
|
|
if !isDBMethod {
|
|
return true
|
|
}
|
|
|
|
// Check if any string argument contains SQL keywords
|
|
for _, arg := range call.Args {
|
|
lit, ok := arg.(*ast.BasicLit)
|
|
if !ok || lit.Kind != token.STRING {
|
|
continue
|
|
}
|
|
val := strings.Trim(lit.Value, "`\"")
|
|
if sqlKeywordRe.MatchString(val) && !isSQLAllowed(val) {
|
|
pos := fset.Position(call.Pos())
|
|
// Truncate the SQL for display
|
|
display := val
|
|
if len(display) > 80 {
|
|
display = display[:77] + "..."
|
|
}
|
|
violations = append(violations, rawSQLViolation{
|
|
file: relPath,
|
|
line: pos.Line,
|
|
snippet: display,
|
|
})
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
|
|
return nil
|
|
}); err != nil {
|
|
violations = append(violations, rawSQLViolation{file: root, line: 0, snippet: err.Error()})
|
|
}
|
|
|
|
return violations
|
|
}
|
|
|
|
func printRawSQLHelp() {
|
|
fmt.Println(`
|
|
Use sqlc for static queries or Bob for dynamic queries:
|
|
┌───────────────────┬─────────────────────────────────────────────────────┐
|
|
│ Need │ Use │
|
|
├───────────────────┼─────────────────────────────────────────────────────┤
|
|
│ Static queries │ sqlc (sql/queries/*.sql → internal/db/*.go) │
|
|
│ Dynamic queries │ Bob (github.com/stephenafamo/bob) │
|
|
└───────────────────┴─────────────────────────────────────────────────────┘
|
|
Raw SQL causes bugs when schema changes. sqlc catches these at compile time.`)
|
|
}
|