check-safety/frontend_extra.go
Alex Dunmow 2c7fe9aa62 fix: model orchestrator + cli as first-class scan targets (WO-WZ-023)
- resolveScanRoots recognizes the orchestrator layout (backend/go.mod +
  frontend/src) so backendDir resolves to backend/ — fixes the go.mod check,
  classifies the frontend as a standalone app (not a plugin), and routes tsc
  through the plain typecheck. Clears three orchestrator false-positives.
- check 2 (RBAC): only require MethodRoles for proto services a target actually
  serves (mounted connect handler). The orchestrator carries but does not serve
  the blockninja.v1 surface (714 false 'missing' -> 0). Exclude cli
  (definitions-only, vendors a registry client) from check 2.
- Skip buf-generated gen/ output in the placeholder check (real proto enum
  COMING_SOON values are not TODO debt).
- Normalize the color allowlist path so theme-card.tsx swatch previews match
  regardless of scan root.
- Whitelist the gitignored, tool-regenerated styles/package-lock.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 22:39:47 +08:00

254 lines
6.3 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/") || strings.Contains(path, "/.worktrees/") {
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/") || strings.Contains(path, "/.worktrees/") {
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/") || strings.Contains(path, "/.worktrees/") {
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
}
// allowedLockfiles are non-pnpm lockfiles intentionally tolerated, keyed by
// repo-relative slash path. These live in gitignored tool working dirs (never
// committed) and are regenerated by an external toolchain we don't control, so
// they are not part of the repo's dependency surface.
var allowedLockfiles = map[string]bool{
// cms/styles is a gitignored tailwind-service working dir; its
// package-lock.json is regenerated by the tailwind tooling and never
// committed (see cms/.gitignore).
"styles/package-lock.json": true,
}
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") || strings.Contains(path, "/.worktrees/") {
return filepath.SkipDir
}
base := filepath.Base(path)
if base == "package-lock.json" || base == "yarn.lock" {
relPath, _ := filepath.Rel(repoRoot, path)
rel := filepath.ToSlash(relPath)
if allowedLockfiles[rel] {
return nil
}
violations = append(violations, lockfileViolation{file: rel})
}
return nil
}); err != nil {
violations = append(violations, lockfileViolation{file: filepath.ToSlash(repoRoot)})
}
return violations
}