package main import ( "bufio" "fmt" "go/ast" "go/parser" "go/token" "os" "git.dev.alexdunmow.com/block/check-safety/internal/helpers" "path/filepath" "regexp" "strings" ) type reinventedViolation struct { file string line int rule string snippet string hint string } // --- Backend: Go AST checks --- // Directories where type assertions on map[string]any are expected (they define the helpers) func isHelperReimplementAllowed(path string) bool { return strings.Contains(path, "/internal/helpers/") || strings.Contains(path, "/blocks/shared/") || strings.Contains(path, "/blocks/tags/") || strings.HasSuffix(path, "_test.go") || strings.Contains(path, "/cmd/check-safety/") } // Backend function definitions that should exist in one place only. // Each entry: regex to match the function signature, the one allowed file, and a hint. var backendDuplicateFuncs = []struct { pattern *regexp.Regexp allowedFile string // relative path where this function SHOULD live rule string hint string }{ { pattern: regexp.MustCompile(`func\s+timestamptz\s*\(`), allowedFile: "", // doesn't exist yet — flag all duplicates rule: "dup-timestamptz", hint: "Consolidate timestamptz() into internal/helpers/ — 3 copies exist", }, { pattern: regexp.MustCompile(`func\s+toPgTimestamptz\s*\(`), allowedFile: "", rule: "dup-toPgTimestamptz", hint: "Consolidate toPgTimestamptz() into internal/helpers/ — duplicate of timestamptz()", }, { pattern: regexp.MustCompile(`func\s+timestampFromTime\s*\(`), allowedFile: "", rule: "dup-timestampFromTime", hint: "Use timestamppb.New(t) directly — this wrapper adds nothing", }, } // checkReinventedBackend detects re-implemented utilities in Go code. func checkReinventedBackend(root string) []reinventedViolation { var violations []reinventedViolation // Pass 1: Scan for duplicate function definitions (line-based, fast) if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") { return nil } if strings.Contains(path, "/vendor/") || strings.HasSuffix(path, "_test.go") { return nil } relPath, _ := filepath.Rel(root, path) f, ferr := os.Open(path) if ferr != nil { return nil } defer helpers.LogDeferredError(nil, "close reinvented scan file", f.Close, "path", path) scanner := bufio.NewScanner(f) lineNum := 0 for scanner.Scan() { lineNum++ line := scanner.Text() for _, check := range backendDuplicateFuncs { if check.allowedFile != "" && strings.HasSuffix(relPath, check.allowedFile) { continue } if check.pattern.MatchString(line) { violations = append(violations, reinventedViolation{ file: relPath, line: lineNum, rule: check.rule, snippet: strings.TrimSpace(line), hint: check.hint, }) } } } return nil }); err != nil { violations = append(violations, reinventedViolation{ file: root, line: 0, rule: "walk-error", snippet: err.Error(), hint: "", }) } // Pass 2: AST-based type assertion check if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") { return nil } if strings.Contains(path, "/vendor/") || isHelperReimplementAllowed(path) { return nil } fset := token.NewFileSet() f, err := parser.ParseFile(fset, path, nil, 0) if err != nil { return nil } relPath, _ := filepath.Rel(root, path) // Check if file imports helpers — if it doesn't, type assertions might be justified // (e.g., in packages that can't import helpers due to dependency direction) importsHelpers := false for _, imp := range f.Imports { if imp.Path != nil && strings.Contains(imp.Path.Value, "internal/helpers") { importsHelpers = true break } } ast.Inspect(f, func(n ast.Node) bool { // Pattern: m["key"].(string) — raw type assertion on map value // Should use helpers.GetStringOr / GetBoolOr / GetIntOr / GetFloat64Or typeAssert, ok := n.(*ast.TypeAssertExpr) if !ok { return true } // Check if the expression being asserted is an index expression: m["key"] indexExpr, ok := typeAssert.X.(*ast.IndexExpr) if !ok { return true } // The index must be a string literal (map key) _, isStringKey := indexExpr.Index.(*ast.BasicLit) if !isStringKey { return true } // Check the asserted type targetType := "" hint := "" if ident, ok := typeAssert.Type.(*ast.Ident); ok { switch ident.Name { case "string": targetType = "string" hint = "helpers.GetStringOr(m, key, default)" case "bool": targetType = "bool" hint = "helpers.GetBoolOr(m, key, default)" case "int": targetType = "int" hint = "helpers.GetIntOr(m, key, default)" case "float64": targetType = "float64" hint = "helpers.GetFloat64Or(m, key, default)" } } if targetType == "" { return true } // Skip if the file is in a package that can't import helpers // (blocks/builtin already uses its own getStringOr — that's fine) if strings.Contains(relPath, "/blocks/builtin/") { return true } // Only flag if the file already imports helpers (otherwise it may be // in a package that can't due to dependency direction) if !importsHelpers { return true } pos := fset.Position(typeAssert.Pos()) violations = append(violations, reinventedViolation{ file: relPath, line: pos.Line, rule: "use-helper-accessor", snippet: fmt.Sprintf("m[\"key\"].(%s)", targetType), hint: hint, }) return true }) return nil }); err != nil { return violations } return violations } // --- Frontend: pattern scan checks --- // Common reinvented utility patterns in TypeScript var frontendReinventions = []struct { pattern *regexp.Regexp rule string hint string // Files where this pattern is expected (the utility definition itself) allowedFiles []string }{ { pattern: regexp.MustCompile(`(?:classnames|clsx)\s*\(`), rule: "use-cn", hint: "import { cn } from '@/lib/utils' — don't use classnames/clsx directly", allowedFiles: []string{"lib/utils.ts"}, }, { pattern: regexp.MustCompile(`\.replace\s*\(\s*/\s*<\[^>]\*>/`), rule: "use-stripHtml", hint: "import { stripHtml } from '@/lib/seo' — don't hand-roll HTML stripping", allowedFiles: []string{"lib/seo/text-utils.ts"}, }, { pattern: regexp.MustCompile(`(?:function|const)\s+debounce\b`), rule: "use-debounce", hint: "import { debounce } from '@/lib/utils' or useDebounce hook", allowedFiles: []string{"lib/utils.ts"}, }, { pattern: regexp.MustCompile(`(?:function|const)\s+formatBytes\b`), rule: "use-formatBytes", hint: "import { formatBytes } from '@/lib/utils'", allowedFiles: []string{"lib/utils.ts"}, }, { pattern: regexp.MustCompile(`(?:function|const)\s+slugify\b`), rule: "use-slugify", hint: "import { slugify } from '@/lib/utils'", allowedFiles: []string{"lib/utils.ts"}, }, { pattern: regexp.MustCompile(`(?:function|const)\s+countWords\b`), rule: "use-countWords", hint: "import { countWords } from '@/lib/seo'", allowedFiles: []string{"lib/seo/text-utils.ts"}, }, { pattern: regexp.MustCompile(`new\s+URLSearchParams\(window\.location\.search\)`), rule: "use-router-search", hint: "Use Route.useSearch() from TanStack Router, not raw URLSearchParams", allowedFiles: []string{"hooks/use-auth.ts"}, // Can't use Route.useSearch() outside route components }, { pattern: regexp.MustCompile(`(?:function|const)\s+formatDate\b`), rule: "use-shared-formatDate", hint: "Create/use a shared formatDate in @/lib/utils — 8 copies already exist across components", allowedFiles: []string{"lib/utils.ts", "lib/format.ts"}, }, { pattern: regexp.MustCompile(`(?:function|const)\s+formatTime\b`), rule: "use-shared-formatTime", hint: "Create/use a shared formatTime in @/lib/utils", allowedFiles: []string{"lib/utils.ts", "lib/format.ts"}, }, { pattern: regexp.MustCompile(`(?:function|const)\s+formatDuration\b`), rule: "use-shared-formatDuration", hint: "Create/use a shared formatDuration in @/lib/utils", allowedFiles: []string{"lib/utils.ts", "lib/format.ts"}, }, { pattern: regexp.MustCompile(`new\s+Date\(Number\(\w+\.seconds\)\s*\*\s*1000\)`), rule: "use-shared-formatDate", hint: "Create/use a shared formatProtoTimestamp in @/lib/utils — protobuf timestamp → Date is duplicated everywhere", allowedFiles: []string{"lib/utils.ts", "lib/format.ts"}, }, } func checkReinventedFrontend(webSrcDir string) []reinventedViolation { var violations []reinventedViolation 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 frontend reinvented 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, "//") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "{/*") { continue } for _, check := range frontendReinventions { // Skip allowed files (where the utility is defined) isAllowed := false for _, af := range check.allowedFiles { if strings.HasSuffix(relPath, af) { isAllowed = true break } } if isAllowed { continue } if check.pattern.MatchString(line) { violations = append(violations, reinventedViolation{ file: relPath, line: lineNum, rule: check.rule, snippet: strings.TrimSpace(line), hint: check.hint, }) } } } return nil }); err != nil { violations = append(violations, reinventedViolation{file: webSrcDir, line: 0, rule: "walk-error", snippet: err.Error(), hint: ""}) } return violations }