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

239 lines
5.5 KiB
Go

package main
import (
"bufio"
"os"
"path/filepath"
"regexp"
"strings"
"git.dev.alexdunmow.com/block/check-safety/internal/helpers"
)
// --- Check: No useState for tab state in route files ---
type tabStateViolation struct {
file string
line int
snippet string
}
type eslintDirectiveViolation struct {
file string
line int
snippet string
}
var reUseStateTab = regexp.MustCompile(`useState\s*[<(].*(?i:tab|activeTab|selectedTab|currentTab)`)
var reUseStateTabAlt = regexp.MustCompile(`(?:activeTab|selectedTab|currentTab|tabValue)\s*,\s*set\w+\]\s*=\s*useState`)
// False positives: layout preferences that are not content tabs
var tabStateIgnorePatterns = []string{"preferTabs"}
var reESLintDisableNextLine = regexp.MustCompile(`eslint-disable-next-line`)
func checkTabState(webSrcDir string) []tabStateViolation {
var violations []tabStateViolation
if err := filepath.Walk(webSrcDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".tsx") {
return nil
}
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") {
return nil
}
relPath, _ := filepath.Rel(webSrcDir, path)
relPath = filepath.ToSlash(relPath)
if !strings.HasPrefix(relPath, "routes/") {
return nil
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer helpers.LogDeferredError(nil, "close routable tab scan file", f.Close, "path", path)
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") {
continue
}
isIgnored := false
for _, pat := range tabStateIgnorePatterns {
if strings.Contains(line, pat) {
isIgnored = true
break
}
}
if !isIgnored && (reUseStateTab.MatchString(line) || reUseStateTabAlt.MatchString(line)) {
violations = append(violations, tabStateViolation{
file: relPath,
line: lineNum,
snippet: strings.TrimSpace(line),
})
}
}
return nil
}); err != nil {
violations = append(violations, tabStateViolation{
file: filepath.ToSlash(webSrcDir),
line: 0,
snippet: err.Error(),
})
}
return violations
}
func checkESLintDisableNextLine(root string) []eslintDirectiveViolation {
var violations []eslintDirectiveViolation
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
ext := filepath.Ext(path)
if ext != ".ts" && ext != ".tsx" && ext != ".js" && ext != ".jsx" {
return nil
}
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") {
return nil
}
lines, readErr := readSourceLines(path)
if readErr != nil {
return nil
}
relPath, _ := filepath.Rel(root, path)
relPath = filepath.ToSlash(relPath)
if relPath == "" {
relPath = filepath.ToSlash(path)
}
for idx, line := range lines {
if !reESLintDisableNextLine.MatchString(line) {
continue
}
violations = append(violations, eslintDirectiveViolation{
file: relPath,
line: idx + 1,
snippet: strings.TrimSpace(line),
})
}
return nil
}); err != nil {
violations = append(violations, eslintDirectiveViolation{
file: filepath.ToSlash(root),
line: 0,
snippet: err.Error(),
})
}
return violations
}
// --- Check: Import from @block-ninja/api subpaths only ---
type bareImportViolation struct {
file string
line int
snippet string
}
// Matches: from '@block-ninja/api' (bare, without /types, /queries, /services suffix)
var reBareAPIImport = regexp.MustCompile(`from\s+['"]@block-ninja/api['"]`)
func checkBareImports(webSrcDir string) []bareImportViolation {
var violations []bareImportViolation
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 != ".ts" && ext != ".tsx" {
return nil
}
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") {
return nil
}
relPath, _ := filepath.Rel(webSrcDir, path)
f, err := os.Open(path)
if err != nil {
return nil
}
defer helpers.LogDeferredError(nil, "close bare API import scan file", f.Close, "path", path)
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
if reBareAPIImport.MatchString(line) {
violations = append(violations, bareImportViolation{
file: relPath,
line: lineNum,
snippet: strings.TrimSpace(line),
})
}
}
return nil
}); err != nil {
violations = append(violations, bareImportViolation{
file: filepath.ToSlash(webSrcDir),
line: 0,
snippet: err.Error(),
})
}
return violations
}
// --- Check: No npm/yarn lockfiles (pnpm only) ---
type lockfileViolation struct {
file string
}
func checkLockfiles(repoRoot string) []lockfileViolation {
var violations []lockfileViolation
if err := filepath.Walk(repoRoot, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if strings.Contains(path, "node_modules") {
return filepath.SkipDir
}
base := filepath.Base(path)
if base == "package-lock.json" || base == "yarn.lock" {
relPath, _ := filepath.Rel(repoRoot, path)
violations = append(violations, lockfileViolation{file: relPath})
}
return nil
}); err != nil {
violations = append(violations, lockfileViolation{file: filepath.ToSlash(repoRoot)})
}
return violations
}