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

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 │
│ }), │
│ }) │
└──────────────────────────────────────────────────────────────────────┘`)
}