- 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>
254 lines
6.3 KiB
Go
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
|
|
}
|
|
|