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>
169 lines
6.0 KiB
Go
169 lines
6.0 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
type rpcErrorViolation struct {
|
|
file string
|
|
line int
|
|
rule string
|
|
snippet string
|
|
}
|
|
|
|
var (
|
|
// Matches: const { ... } = useQuery — destructuring pattern
|
|
reQueryDestructure = regexp.MustCompile(`(?:const|let|var)\s+\{([^}]+)\}\s*=\s*useQuery`)
|
|
|
|
// Matches: useMutation( — start of a mutation hook call
|
|
reUseMutation = regexp.MustCompile(`\buseMutation\s*\(`)
|
|
|
|
// Matches: onError in options
|
|
reOnError = regexp.MustCompile(`onError\s*:`)
|
|
|
|
// Matches: catch or .catch(
|
|
reCatch = regexp.MustCompile(`\bcatch\b`)
|
|
|
|
// Matches: toast.error
|
|
reToastError = regexp.MustCompile(`toast\.error\s*\(`)
|
|
)
|
|
|
|
// checkRPCErrors detects missing error handling for RPC queries and mutations.
|
|
func checkRPCErrors(webSrcDir string) (violations []rpcErrorViolation, warnings []rpcErrorViolation) {
|
|
globalMutationHandler := hasGlobalMutationErrorHandler(webSrcDir)
|
|
|
|
if err := filepath.Walk(webSrcDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
ext := filepath.Ext(path)
|
|
if ext != ".tsx" && ext != ".ts" {
|
|
return nil
|
|
}
|
|
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") {
|
|
return nil
|
|
}
|
|
|
|
relPath, _ := filepath.Rel(webSrcDir, path)
|
|
|
|
// Only check components/ and routes/
|
|
if !strings.HasPrefix(relPath, "components/") && !strings.HasPrefix(relPath, "routes/") {
|
|
return nil
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
lines := strings.Split(string(data), "\n")
|
|
fullContent := string(data)
|
|
|
|
for i, line := range lines {
|
|
lineNum := i + 1
|
|
trimmed := strings.TrimSpace(line)
|
|
|
|
// Skip comments
|
|
if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "{/*") {
|
|
continue
|
|
}
|
|
|
|
// Check useQuery destructuring for missing error
|
|
if reQueryDestructure.MatchString(line) {
|
|
matches := reQueryDestructure.FindStringSubmatch(line)
|
|
if len(matches) > 1 {
|
|
destructured := matches[1]
|
|
if !strings.Contains(destructured, "error") {
|
|
warnings = append(warnings, rpcErrorViolation{
|
|
file: relPath,
|
|
line: lineNum,
|
|
rule: "query-no-error",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check useMutation — find mutations with no error handling anywhere in file
|
|
if reUseMutation.MatchString(line) && !strings.HasPrefix(trimmed, "//") {
|
|
// Check if this mutation's options or its call sites have error handling
|
|
// Look in the surrounding context (next 20 lines) for onError
|
|
hasErrorHandling := false
|
|
|
|
// Check the mutation definition options (same line or next few lines)
|
|
for j := i; j < len(lines) && j <= i+15; j++ {
|
|
if reOnError.MatchString(lines[j]) {
|
|
hasErrorHandling = true
|
|
break
|
|
}
|
|
// Stop at next statement
|
|
if j > i && (strings.Contains(lines[j], "const ") || strings.Contains(lines[j], "return ")) {
|
|
break
|
|
}
|
|
}
|
|
|
|
// Check if the file has ANY try/catch or toast.error (broad heuristic)
|
|
if !hasErrorHandling {
|
|
if reCatch.MatchString(fullContent) || reToastError.MatchString(fullContent) {
|
|
// File has some error handling — mutation call sites likely handle it
|
|
hasErrorHandling = true
|
|
}
|
|
}
|
|
|
|
if !hasErrorHandling && !globalMutationHandler {
|
|
violations = append(violations, rpcErrorViolation{
|
|
file: relPath,
|
|
line: lineNum,
|
|
rule: "mutation-no-error-handler",
|
|
snippet: strings.TrimSpace(line),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
_ = err // Walk errors are non-fatal for safety checks
|
|
}
|
|
|
|
return violations, warnings
|
|
}
|
|
|
|
func hasGlobalMutationErrorHandler(webSrcDir string) bool {
|
|
queryClientPath := filepath.Join(webSrcDir, "lib", "query-client.ts")
|
|
data, err := os.ReadFile(queryClientPath)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
|
|
content := string(data)
|
|
return strings.Contains(content, "MutationCache") && strings.Contains(content, "onError")
|
|
}
|
|
|
|
func printRPCErrorHelp() {
|
|
fmt.Println(`
|
|
RPC error handling patterns:
|
|
┌──────────────────────────────────────────────────────────────────────┐
|
|
│ Global handlers in App.tsx catch unhandled errors automatically: │
|
|
│ • QueryCache.onError → toasts on refetch failures │
|
|
│ • MutationCache.onError → toasts when no onError at call site │
|
|
├──────────────────────────────────────────────────────────────────────┤
|
|
│ For custom error UI, destructure 'error' from useQuery: │
|
|
│ │
|
|
│ const { data, isLoading, error } = useQuery(myQuery, input) │
|
|
│ if (error) return <ErrorState message={error.message} /> │
|
|
├──────────────────────────────────────────────────────────────────────┤
|
|
│ For mutations, add onError or use try/catch: │
|
|
│ │
|
|
│ mutation.mutate(data, { │
|
|
│ onSuccess: () => toast.success('Saved'), │
|
|
│ onError: (err) => toast.error('Failed', { │
|
|
│ description: err.message │
|
|
│ }), │
|
|
│ }) │
|
|
└──────────────────────────────────────────────────────────────────────┘`)
|
|
}
|