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>
611 lines
16 KiB
Go
611 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// ─────────────────────────────────────────────
|
|
// checkTabState
|
|
// ─────────────────────────────────────────────
|
|
|
|
// checkTabState only scans files under "routes/" within the webSrcDir.
|
|
|
|
func TestCheckTabState_FlagsUseStateWithTabInRoutes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
routesDir := filepath.Join(dir, "routes")
|
|
if err := os.MkdirAll(routesDir, 0755); err != nil {
|
|
t.Fatalf("mkdir routes: %v", err)
|
|
}
|
|
writeFile(t, routesDir, "settings.tsx", `
|
|
import { useState } from 'react'
|
|
|
|
export function Settings() {
|
|
const [activeTab, setActiveTab] = useState('general')
|
|
return <Tabs value={activeTab} />
|
|
}
|
|
`)
|
|
|
|
vs := checkTabState(dir)
|
|
if len(vs) == 0 {
|
|
t.Fatal("expected violation for useState activeTab in routes/, got 0")
|
|
}
|
|
if vs[0].file != "routes/settings.tsx" {
|
|
t.Fatalf("expected file 'routes/settings.tsx', got %q", vs[0].file)
|
|
}
|
|
}
|
|
|
|
func TestCheckTabState_FlagsUseStateTabAlt(t *testing.T) {
|
|
dir := t.TempDir()
|
|
routesDir := filepath.Join(dir, "routes")
|
|
if err := os.MkdirAll(routesDir, 0755); err != nil {
|
|
t.Fatalf("mkdir routes: %v", err)
|
|
}
|
|
// Alt pattern: [tabValue, setTabValue] = useState(...)
|
|
writeFile(t, routesDir, "page.tsx", `
|
|
import { useState } from 'react'
|
|
|
|
export function Page() {
|
|
const [tabValue, setTabValue] = useState('first')
|
|
return <div />
|
|
}
|
|
`)
|
|
|
|
vs := checkTabState(dir)
|
|
if len(vs) == 0 {
|
|
t.Fatal("expected violation for tabValue useState alt pattern, got 0")
|
|
}
|
|
}
|
|
|
|
func TestCheckTabState_AllowsPreferTabs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
routesDir := filepath.Join(dir, "routes")
|
|
if err := os.MkdirAll(routesDir, 0755); err != nil {
|
|
t.Fatalf("mkdir routes: %v", err)
|
|
}
|
|
// "preferTabs" is in tabStateIgnorePatterns — should be allowed
|
|
writeFile(t, routesDir, "settings.tsx", `
|
|
import { useState } from 'react'
|
|
|
|
export function Settings() {
|
|
const [preferTabs, setPreferTabs] = useState(true)
|
|
return <div />
|
|
}
|
|
`)
|
|
|
|
vs := checkTabState(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for preferTabs ignore pattern, got %d: %#v", len(vs), vs)
|
|
}
|
|
}
|
|
|
|
func TestCheckTabState_IgnoresNonRoutesFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// File is in components/ — not routes/, should be skipped
|
|
compDir := filepath.Join(dir, "components")
|
|
if err := os.MkdirAll(compDir, 0755); err != nil {
|
|
t.Fatalf("mkdir components: %v", err)
|
|
}
|
|
writeFile(t, compDir, "tabs.tsx", `
|
|
import { useState } from 'react'
|
|
|
|
export function MyTabs() {
|
|
const [activeTab, setActiveTab] = useState('a')
|
|
return <div />
|
|
}
|
|
`)
|
|
|
|
vs := checkTabState(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for useState in components/ (not routes/), got %d", len(vs))
|
|
}
|
|
}
|
|
|
|
func TestCheckTabState_IgnoresCommentedLines(t *testing.T) {
|
|
dir := t.TempDir()
|
|
routesDir := filepath.Join(dir, "routes")
|
|
if err := os.MkdirAll(routesDir, 0755); err != nil {
|
|
t.Fatalf("mkdir routes: %v", err)
|
|
}
|
|
writeFile(t, routesDir, "page.tsx", `
|
|
export function Page() {
|
|
// const [activeTab, setActiveTab] = useState('a')
|
|
return <div />
|
|
}
|
|
`)
|
|
|
|
vs := checkTabState(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for commented-out useState, got %d", len(vs))
|
|
}
|
|
}
|
|
|
|
func TestCheckTabState_IgnoresNonTSXFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
routesDir := filepath.Join(dir, "routes")
|
|
if err := os.MkdirAll(routesDir, 0755); err != nil {
|
|
t.Fatalf("mkdir routes: %v", err)
|
|
}
|
|
writeFile(t, routesDir, "helpers.ts", `
|
|
const [activeTab, setActiveTab] = useState('a')
|
|
`)
|
|
|
|
vs := checkTabState(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for .ts file (not .tsx), got %d", len(vs))
|
|
}
|
|
}
|
|
|
|
func TestCheckTabState_CleanRouteFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
routesDir := filepath.Join(dir, "routes")
|
|
if err := os.MkdirAll(routesDir, 0755); err != nil {
|
|
t.Fatalf("mkdir routes: %v", err)
|
|
}
|
|
// Uses URL search params — correct pattern
|
|
writeFile(t, routesDir, "settings.tsx", `
|
|
import { useSearch } from '@tanstack/react-router'
|
|
|
|
export function Settings() {
|
|
const { tab } = useSearch({ from: '/settings' })
|
|
return <Tabs value={tab ?? 'general'} />
|
|
}
|
|
`)
|
|
|
|
vs := checkTabState(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for URL-based tab routing, got %d: %#v", len(vs), vs)
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────
|
|
// checkESLintDisableNextLine
|
|
// ─────────────────────────────────────────────
|
|
|
|
func TestCheckESLintDisableNextLine_FlagsInTSX(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "component.tsx", `
|
|
export function Foo() {
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const x: any = {}
|
|
return <div>{x}</div>
|
|
}
|
|
`)
|
|
|
|
vs := checkESLintDisableNextLine(dir)
|
|
if len(vs) == 0 {
|
|
t.Fatal("expected violation for eslint-disable-next-line in .tsx, got 0")
|
|
}
|
|
if vs[0].line == 0 {
|
|
t.Error("expected non-zero line number")
|
|
}
|
|
}
|
|
|
|
func TestCheckESLintDisableNextLine_FlagsInTS(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "utils.ts", `
|
|
// eslint-disable-next-line prefer-const
|
|
var x = 5
|
|
`)
|
|
|
|
vs := checkESLintDisableNextLine(dir)
|
|
if len(vs) == 0 {
|
|
t.Fatal("expected violation for eslint-disable-next-line in .ts, got 0")
|
|
}
|
|
}
|
|
|
|
func TestCheckESLintDisableNextLine_FlagsInJS(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "script.js", `
|
|
// eslint-disable-next-line no-console
|
|
console.log('debug')
|
|
`)
|
|
|
|
vs := checkESLintDisableNextLine(dir)
|
|
if len(vs) == 0 {
|
|
t.Fatal("expected violation for eslint-disable-next-line in .js, got 0")
|
|
}
|
|
}
|
|
|
|
func TestCheckESLintDisableNextLine_CleanFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "clean.tsx", `
|
|
export function Foo() {
|
|
const x = 5
|
|
return <div>{x}</div>
|
|
}
|
|
`)
|
|
|
|
vs := checkESLintDisableNextLine(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for clean file, got %d: %#v", len(vs), vs)
|
|
}
|
|
}
|
|
|
|
func TestCheckESLintDisableNextLine_SkipsNodeModules(t *testing.T) {
|
|
dir := t.TempDir()
|
|
nmDir := filepath.Join(dir, "node_modules", "somepkg")
|
|
if err := os.MkdirAll(nmDir, 0755); err != nil {
|
|
t.Fatalf("mkdir node_modules: %v", err)
|
|
}
|
|
writeFile(t, nmDir, "index.ts", `
|
|
// eslint-disable-next-line no-console
|
|
console.log('pkg')
|
|
`)
|
|
|
|
vs := checkESLintDisableNextLine(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations (node_modules should be skipped), got %d", len(vs))
|
|
}
|
|
}
|
|
|
|
func TestCheckESLintDisableNextLine_MultipleViolations(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "messy.tsx", `
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const a: any = 1
|
|
// eslint-disable-next-line prefer-const
|
|
var b = 2
|
|
`)
|
|
|
|
vs := checkESLintDisableNextLine(dir)
|
|
if len(vs) < 2 {
|
|
t.Fatalf("expected at least 2 violations, got %d: %#v", len(vs), vs)
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────
|
|
// checkBareImports
|
|
// ─────────────────────────────────────────────
|
|
|
|
func TestCheckBareImports_FlagsBareAPIImport(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "component.tsx", `
|
|
import { AdminUser } from '@block-ninja/api'
|
|
|
|
export function MyComp() {
|
|
return <div />
|
|
}
|
|
`)
|
|
|
|
vs := checkBareImports(dir)
|
|
if len(vs) == 0 {
|
|
t.Fatal("expected violation for bare @block-ninja/api import, got 0")
|
|
}
|
|
if vs[0].line == 0 {
|
|
t.Error("expected non-zero line number")
|
|
}
|
|
}
|
|
|
|
func TestCheckBareImports_AllowsSubpathImport(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "component.tsx", `
|
|
import type { AdminUser } from '@block-ninja/api/types'
|
|
import { AdminService } from '@block-ninja/api/services'
|
|
import { listAdminUsers } from '@block-ninja/api/queries'
|
|
|
|
export function MyComp() {
|
|
return <div />
|
|
}
|
|
`)
|
|
|
|
vs := checkBareImports(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for subpath imports, got %d: %#v", len(vs), vs)
|
|
}
|
|
}
|
|
|
|
func TestCheckBareImports_SkipsNonTSTSX(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// .js file — not scanned by checkBareImports
|
|
writeFile(t, dir, "script.js", `
|
|
import { something } from '@block-ninja/api'
|
|
`)
|
|
|
|
vs := checkBareImports(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for .js file (not .ts/.tsx), got %d", len(vs))
|
|
}
|
|
}
|
|
|
|
func TestCheckBareImports_SkipsNodeModules(t *testing.T) {
|
|
dir := t.TempDir()
|
|
nmDir := filepath.Join(dir, "node_modules", "@block-ninja", "ui")
|
|
if err := os.MkdirAll(nmDir, 0755); err != nil {
|
|
t.Fatalf("mkdir node_modules: %v", err)
|
|
}
|
|
writeFile(t, nmDir, "index.ts", `
|
|
import { something } from '@block-ninja/api'
|
|
`)
|
|
|
|
vs := checkBareImports(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations in node_modules, got %d", len(vs))
|
|
}
|
|
}
|
|
|
|
func TestCheckBareImports_CleanTSFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "util.ts", `
|
|
import type { AdminUser } from '@block-ninja/api/types'
|
|
|
|
export function transform(u: AdminUser): string {
|
|
return u.email
|
|
}
|
|
`)
|
|
|
|
vs := checkBareImports(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for clean .ts file, got %d: %#v", len(vs), vs)
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────
|
|
// checkLockfiles
|
|
// ─────────────────────────────────────────────
|
|
|
|
func TestCheckLockfiles_FlagsPackageLockJSON(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "package-lock.json", `{"lockfileVersion":2}`)
|
|
|
|
vs := checkLockfiles(dir)
|
|
if len(vs) == 0 {
|
|
t.Fatal("expected violation for package-lock.json, got 0")
|
|
}
|
|
found := false
|
|
for _, v := range vs {
|
|
if v.file == "package-lock.json" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected 'package-lock.json' in violations, got: %#v", vs)
|
|
}
|
|
}
|
|
|
|
func TestCheckLockfiles_FlagsYarnLock(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "yarn.lock", `# yarn lockfile v1`)
|
|
|
|
vs := checkLockfiles(dir)
|
|
if len(vs) == 0 {
|
|
t.Fatal("expected violation for yarn.lock, got 0")
|
|
}
|
|
found := false
|
|
for _, v := range vs {
|
|
if v.file == "yarn.lock" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatalf("expected 'yarn.lock' in violations, got: %#v", vs)
|
|
}
|
|
}
|
|
|
|
func TestCheckLockfiles_AllowsPnpmLockYaml(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "pnpm-lock.yaml", `lockfileVersion: '9.0'`)
|
|
|
|
vs := checkLockfiles(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for pnpm-lock.yaml, got %d: %#v", len(vs), vs)
|
|
}
|
|
}
|
|
|
|
func TestCheckLockfiles_SkipsNodeModules(t *testing.T) {
|
|
dir := t.TempDir()
|
|
nmDir := filepath.Join(dir, "node_modules", "somepkg")
|
|
if err := os.MkdirAll(nmDir, 0755); err != nil {
|
|
t.Fatalf("mkdir node_modules: %v", err)
|
|
}
|
|
writeFile(t, nmDir, "package-lock.json", `{}`)
|
|
|
|
vs := checkLockfiles(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations (node_modules should be skipped), got %d", len(vs))
|
|
}
|
|
}
|
|
|
|
func TestCheckLockfiles_NoLockfile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "package.json", `{"name":"test"}`)
|
|
|
|
vs := checkLockfiles(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for repo with no lockfiles, got %d", len(vs))
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────
|
|
// checkButtonAutomation
|
|
// ─────────────────────────────────────────────
|
|
|
|
func TestCheckButtonAutomation_FlagsButtonWithoutAction(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "page.tsx", `
|
|
export function Page() {
|
|
return (
|
|
<Button variant="default">Click Me</Button>
|
|
)
|
|
}
|
|
`)
|
|
|
|
vs := checkButtonAutomation(dir)
|
|
if len(vs) == 0 {
|
|
t.Fatal("expected violation for <Button> without action=, got 0")
|
|
}
|
|
}
|
|
|
|
func TestCheckButtonAutomation_AllowsButtonWithAction(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "page.tsx", `
|
|
export function Page() {
|
|
return (
|
|
<Button action="create" entity="user">Add User</Button>
|
|
)
|
|
}
|
|
`)
|
|
|
|
vs := checkButtonAutomation(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for <Button action=...>, got %d: %#v", len(vs), vs)
|
|
}
|
|
}
|
|
|
|
func TestCheckButtonAutomation_AllowsSubmitType(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// type="submit" is exempt from the action= requirement
|
|
writeFile(t, dir, "form.tsx", `
|
|
export function MyForm() {
|
|
return (
|
|
<form>
|
|
<Button type="submit">Submit</Button>
|
|
</form>
|
|
)
|
|
}
|
|
`)
|
|
|
|
vs := checkButtonAutomation(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for type='submit' button, got %d: %#v", len(vs), vs)
|
|
}
|
|
}
|
|
|
|
func TestCheckButtonAutomation_AllowsAlertDialogAction(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// AlertDialogAction doesn't support action= per CLAUDE.md
|
|
writeFile(t, dir, "confirm.tsx", `
|
|
export function Confirm() {
|
|
return (
|
|
<AlertDialogAction>Confirm Delete</AlertDialogAction>
|
|
)
|
|
}
|
|
`)
|
|
|
|
vs := checkButtonAutomation(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for AlertDialogAction, got %d: %#v", len(vs), vs)
|
|
}
|
|
}
|
|
|
|
func TestCheckButtonAutomation_SkipsUIComponents(t *testing.T) {
|
|
dir := t.TempDir()
|
|
uiDir := filepath.Join(dir, "components", "ui")
|
|
if err := os.MkdirAll(uiDir, 0755); err != nil {
|
|
t.Fatalf("mkdir components/ui: %v", err)
|
|
}
|
|
// UI primitive definitions — exempt from check
|
|
writeFile(t, uiDir, "button.tsx", `
|
|
import * as React from 'react'
|
|
|
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
({ className, variant, ...props }, ref) => {
|
|
return <button className={cn(buttonVariants({ variant }), className)} ref={ref} {...props} />
|
|
}
|
|
)
|
|
Button.displayName = 'Button'
|
|
export { Button }
|
|
`)
|
|
|
|
vs := checkButtonAutomation(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for components/ui/ definition file, got %d: %#v", len(vs), vs)
|
|
}
|
|
}
|
|
|
|
func TestCheckButtonAutomation_SkipsCommentedButton(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "page.tsx", `
|
|
export function Page() {
|
|
return (
|
|
// <Button variant="default">Old button</Button>
|
|
<div>content</div>
|
|
)
|
|
}
|
|
`)
|
|
|
|
vs := checkButtonAutomation(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for commented-out Button, got %d: %#v", len(vs), vs)
|
|
}
|
|
}
|
|
|
|
func TestCheckButtonAutomation_MultiLineActionOnNextLine(t *testing.T) {
|
|
dir := t.TempDir()
|
|
// action= is on the next line — collectJSXTag lookahead should find it
|
|
writeFile(t, dir, "page.tsx", `
|
|
export function Page() {
|
|
return (
|
|
<Button
|
|
action="delete"
|
|
entity="block"
|
|
variant="destructive"
|
|
>
|
|
Delete
|
|
</Button>
|
|
)
|
|
}
|
|
`)
|
|
|
|
vs := checkButtonAutomation(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for multi-line <Button> with action= on next line, got %d: %#v", len(vs), vs)
|
|
}
|
|
}
|
|
|
|
func TestCheckButtonAutomation_SkipsNonTSXFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
writeFile(t, dir, "component.ts", `
|
|
// Not a TSX file — should be skipped
|
|
const x = '<Button variant="default">Click</Button>'
|
|
`)
|
|
|
|
vs := checkButtonAutomation(dir)
|
|
if len(vs) != 0 {
|
|
t.Fatalf("expected 0 violations for non-.tsx file, got %d", len(vs))
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────
|
|
// collectJSXTag
|
|
// ─────────────────────────────────────────────
|
|
|
|
func TestCollectJSXTag_SingleLine(t *testing.T) {
|
|
lines := []string{`<Button action="create">Click</Button>`}
|
|
result := collectJSXTag(lines, 0)
|
|
if result == "" {
|
|
t.Fatal("collectJSXTag() returned empty string for single-line tag")
|
|
}
|
|
}
|
|
|
|
func TestCollectJSXTag_MultiLinePropExtraction(t *testing.T) {
|
|
lines := []string{
|
|
`<Button`,
|
|
` action="create"`,
|
|
` entity="user"`,
|
|
`>`,
|
|
` Save`,
|
|
`</Button>`,
|
|
}
|
|
result := collectJSXTag(lines, 0)
|
|
// Should contain action= because it's prop-level content
|
|
if !reButtonAction.MatchString(result) {
|
|
t.Fatalf("collectJSXTag() = %q, expected to contain 'action='", result)
|
|
}
|
|
}
|
|
|
|
func TestCollectJSXTag_StripsBraceExpressions(t *testing.T) {
|
|
lines := []string{
|
|
`<Button`,
|
|
` onClick={() => { doSomething() }}`,
|
|
` action="save"`,
|
|
`>`,
|
|
}
|
|
result := collectJSXTag(lines, 0)
|
|
// The brace expression is stripped but action= should still be present
|
|
if !reButtonAction.MatchString(result) {
|
|
t.Fatalf("collectJSXTag() = %q, expected 'action=' to be preserved outside braces", result)
|
|
}
|
|
}
|