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

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)
}
}