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>
330 lines
10 KiB
Go
330 lines
10 KiB
Go
package main
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
)
|
||
|
||
// makeRPCDir creates a webSrcDir with optional lib/query-client.ts for the
|
||
// global mutation handler check.
|
||
func makeRPCDir(t *testing.T) string {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
return dir
|
||
}
|
||
|
||
func writeRPCComponent(t *testing.T, webSrcDir, subpath, content string) {
|
||
t.Helper()
|
||
fullPath := filepath.Join(webSrcDir, subpath)
|
||
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
|
||
t.Fatalf("mkdir %s: %v", filepath.Dir(fullPath), err)
|
||
}
|
||
if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
|
||
t.Fatalf("write %s: %v", fullPath, err)
|
||
}
|
||
}
|
||
|
||
func writeGlobalMutationHandler(t *testing.T, webSrcDir string) {
|
||
t.Helper()
|
||
libDir := filepath.Join(webSrcDir, "lib")
|
||
if err := os.MkdirAll(libDir, 0755); err != nil {
|
||
t.Fatalf("mkdir lib: %v", err)
|
||
}
|
||
if err := os.WriteFile(filepath.Join(libDir, "query-client.ts"), []byte(`
|
||
import { MutationCache, QueryClient } from '@tanstack/react-query'
|
||
|
||
export const queryClient = new QueryClient({
|
||
mutationCache: new MutationCache({
|
||
onError: (error) => {
|
||
console.error('Global mutation error', error)
|
||
},
|
||
}),
|
||
})
|
||
`), 0644); err != nil {
|
||
t.Fatalf("write query-client.ts: %v", err)
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// checkRPCErrors – query-no-error warning
|
||
// ─────────────────────────────────────────────
|
||
|
||
func TestCheckRPCErrors_QueryWithoutError_Warning(t *testing.T) {
|
||
dir := makeRPCDir(t)
|
||
writeRPCComponent(t, dir, "components/my-component.tsx", `
|
||
import { useQuery } from '@block-ninja/api/queries'
|
||
|
||
export function MyComponent() {
|
||
const { data, isLoading } = useQuery(myQuery, {})
|
||
return <div>{data}</div>
|
||
}
|
||
`)
|
||
|
||
violations, warnings := checkRPCErrors(dir)
|
||
if len(warnings) == 0 {
|
||
t.Fatal("expected at least 1 warning for useQuery without error destructuring")
|
||
}
|
||
found := false
|
||
for _, w := range warnings {
|
||
if w.rule == "query-no-error" {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("expected rule 'query-no-error' in warnings, got: %#v", warnings)
|
||
}
|
||
// Should not be a hard violation
|
||
if len(violations) != 0 {
|
||
t.Fatalf("expected 0 hard violations for missing query error, got %d: %#v", len(violations), violations)
|
||
}
|
||
}
|
||
|
||
func TestCheckRPCErrors_QueryWithError_NoWarning(t *testing.T) {
|
||
dir := makeRPCDir(t)
|
||
writeRPCComponent(t, dir, "components/my-component.tsx", `
|
||
import { useQuery } from '@block-ninja/api/queries'
|
||
|
||
export function MyComponent() {
|
||
const { data, isLoading, error } = useQuery(myQuery, {})
|
||
if (error) return <div>Error!</div>
|
||
return <div>{data}</div>
|
||
}
|
||
`)
|
||
|
||
_, warnings := checkRPCErrors(dir)
|
||
if len(warnings) != 0 {
|
||
t.Fatalf("expected 0 warnings when error is destructured, got %d: %#v", len(warnings), warnings)
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// checkRPCErrors – mutation-no-error-handler violation
|
||
// ─────────────────────────────────────────────
|
||
|
||
func TestCheckRPCErrors_MutationWithNoErrorHandler_Violation(t *testing.T) {
|
||
dir := makeRPCDir(t)
|
||
// No global handler, no onError, no try/catch, no toast.error
|
||
writeRPCComponent(t, dir, "components/editor.tsx", `
|
||
import { useMutation } from '@block-ninja/api/queries'
|
||
|
||
export function Editor() {
|
||
const saveMutation = useMutation(saveBlock)
|
||
|
||
function handleSave() {
|
||
saveMutation.mutate({ content: 'hello' })
|
||
}
|
||
|
||
return <button onClick={handleSave}>Save</button>
|
||
}
|
||
`)
|
||
|
||
violations, _ := checkRPCErrors(dir)
|
||
if len(violations) == 0 {
|
||
t.Fatal("expected violation for mutation without error handler")
|
||
}
|
||
found := false
|
||
for _, v := range violations {
|
||
if v.rule == "mutation-no-error-handler" {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("expected rule 'mutation-no-error-handler', got: %#v", violations)
|
||
}
|
||
}
|
||
|
||
func TestCheckRPCErrors_MutationWithOnError_Clean(t *testing.T) {
|
||
dir := makeRPCDir(t)
|
||
writeRPCComponent(t, dir, "components/editor.tsx", `
|
||
import { useMutation } from '@block-ninja/api/queries'
|
||
import { toast } from 'sonner'
|
||
|
||
export function Editor() {
|
||
const saveMutation = useMutation(saveBlock, {
|
||
onError: (err) => toast.error('Save failed', { description: err.message }),
|
||
onSuccess: () => toast.success('Saved'),
|
||
})
|
||
|
||
return <button onClick={() => saveMutation.mutate({})}>Save</button>
|
||
}
|
||
`)
|
||
|
||
violations, _ := checkRPCErrors(dir)
|
||
if len(violations) != 0 {
|
||
t.Fatalf("expected 0 violations for mutation with onError, got %d: %#v", len(violations), violations)
|
||
}
|
||
}
|
||
|
||
func TestCheckRPCErrors_MutationWithTryCatch_Clean(t *testing.T) {
|
||
dir := makeRPCDir(t)
|
||
writeRPCComponent(t, dir, "components/editor.tsx", `
|
||
import { useMutation } from '@block-ninja/api/queries'
|
||
|
||
export function Editor() {
|
||
const saveMutation = useMutation(saveBlock)
|
||
|
||
async function handleSave() {
|
||
try {
|
||
await saveMutation.mutateAsync({})
|
||
} catch (err) {
|
||
console.error(err)
|
||
}
|
||
}
|
||
|
||
return <button onClick={handleSave}>Save</button>
|
||
}
|
||
`)
|
||
|
||
violations, _ := checkRPCErrors(dir)
|
||
if len(violations) != 0 {
|
||
t.Fatalf("expected 0 violations when try/catch is present, got %d: %#v", len(violations), violations)
|
||
}
|
||
}
|
||
|
||
func TestCheckRPCErrors_MutationWithToastError_Clean(t *testing.T) {
|
||
dir := makeRPCDir(t)
|
||
writeRPCComponent(t, dir, "components/editor.tsx", `
|
||
import { useMutation } from '@block-ninja/api/queries'
|
||
import { toast } from 'sonner'
|
||
|
||
export function Editor() {
|
||
const saveMutation = useMutation(saveBlock)
|
||
|
||
function handleSave() {
|
||
saveMutation.mutate({}, {
|
||
onSuccess: () => toast.success('Done'),
|
||
})
|
||
// fallback: toast.error happens elsewhere
|
||
}
|
||
|
||
function handleError(err) {
|
||
toast.error('Failed: ' + err.message)
|
||
}
|
||
|
||
return <button onClick={handleSave}>Save</button>
|
||
}
|
||
`)
|
||
|
||
violations, _ := checkRPCErrors(dir)
|
||
if len(violations) != 0 {
|
||
t.Fatalf("expected 0 violations when toast.error is present in file, got %d: %#v", len(violations), violations)
|
||
}
|
||
}
|
||
|
||
func TestCheckRPCErrors_GlobalHandlerSuppressesViolation(t *testing.T) {
|
||
dir := makeRPCDir(t)
|
||
writeGlobalMutationHandler(t, dir)
|
||
|
||
// Mutation with no per-mutation error handling — suppressed by global handler
|
||
writeRPCComponent(t, dir, "components/editor.tsx", `
|
||
import { useMutation } from '@block-ninja/api/queries'
|
||
|
||
export function Editor() {
|
||
const saveMutation = useMutation(saveBlock)
|
||
return <button onClick={() => saveMutation.mutate({})}>Save</button>
|
||
}
|
||
`)
|
||
|
||
violations, _ := checkRPCErrors(dir)
|
||
if len(violations) != 0 {
|
||
t.Fatalf("expected 0 violations when global mutation handler is present, got %d: %#v", len(violations), violations)
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// checkRPCErrors – file path scoping
|
||
// ─────────────────────────────────────────────
|
||
|
||
func TestCheckRPCErrors_SkipsFilesOutsideComponentsAndRoutes(t *testing.T) {
|
||
dir := makeRPCDir(t)
|
||
// File is in lib/ — not components/ or routes/, should be skipped
|
||
writeRPCComponent(t, dir, "lib/utils.tsx", `
|
||
import { useMutation } from '@block-ninja/api/queries'
|
||
|
||
export function useHelperMutation() {
|
||
return useMutation(doSomething)
|
||
}
|
||
`)
|
||
|
||
violations, warnings := checkRPCErrors(dir)
|
||
if len(violations) != 0 || len(warnings) != 0 {
|
||
t.Fatalf("expected 0 violations and 0 warnings for file outside scoped dirs, got v=%d w=%d",
|
||
len(violations), len(warnings))
|
||
}
|
||
}
|
||
|
||
func TestCheckRPCErrors_ChecksRouteFiles(t *testing.T) {
|
||
dir := makeRPCDir(t)
|
||
// File in routes/ should be checked — mutation with no handler
|
||
writeRPCComponent(t, dir, "routes/dashboard.tsx", `
|
||
import { useMutation } from '@block-ninja/api/queries'
|
||
|
||
export function Dashboard() {
|
||
const doThing = useMutation(action)
|
||
return <button onClick={() => doThing.mutate({})}>Go</button>
|
||
}
|
||
`)
|
||
|
||
violations, _ := checkRPCErrors(dir)
|
||
if len(violations) == 0 {
|
||
t.Fatal("expected violation for mutation in routes/ without error handler")
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// checkRPCErrors – comment lines skipped
|
||
// ─────────────────────────────────────────────
|
||
|
||
func TestCheckRPCErrors_CommentedMutationSkipped(t *testing.T) {
|
||
dir := makeRPCDir(t)
|
||
writeRPCComponent(t, dir, "components/thing.tsx", `
|
||
export function Thing() {
|
||
// const doThing = useMutation(action)
|
||
return <div />
|
||
}
|
||
`)
|
||
|
||
violations, _ := checkRPCErrors(dir)
|
||
if len(violations) != 0 {
|
||
t.Fatalf("expected 0 violations for commented-out mutation, got %d: %#v", len(violations), violations)
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────
|
||
// hasGlobalMutationErrorHandler
|
||
// ─────────────────────────────────────────────
|
||
|
||
func TestHasGlobalMutationErrorHandler_Present(t *testing.T) {
|
||
dir := makeRPCDir(t)
|
||
writeGlobalMutationHandler(t, dir)
|
||
if !hasGlobalMutationErrorHandler(dir) {
|
||
t.Fatal("expected hasGlobalMutationErrorHandler to return true when handler is present")
|
||
}
|
||
}
|
||
|
||
func TestHasGlobalMutationErrorHandler_Missing(t *testing.T) {
|
||
dir := makeRPCDir(t)
|
||
if hasGlobalMutationErrorHandler(dir) {
|
||
t.Fatal("expected hasGlobalMutationErrorHandler to return false when file is absent")
|
||
}
|
||
}
|
||
|
||
func TestHasGlobalMutationErrorHandler_PartialContent(t *testing.T) {
|
||
dir := makeRPCDir(t)
|
||
libDir := filepath.Join(dir, "lib")
|
||
if err := os.MkdirAll(libDir, 0755); err != nil {
|
||
t.Fatalf("mkdir lib: %v", err)
|
||
}
|
||
// Has MutationCache but no onError
|
||
if err := os.WriteFile(filepath.Join(libDir, "query-client.ts"), []byte(`
|
||
import { MutationCache } from '@tanstack/react-query'
|
||
const cache = new MutationCache({})
|
||
`), 0644); err != nil {
|
||
t.Fatalf("write query-client.ts: %v", err)
|
||
}
|
||
if hasGlobalMutationErrorHandler(dir) {
|
||
t.Fatal("expected false when MutationCache present but onError absent")
|
||
}
|
||
}
|