check-safety/reinvented.go
Alex Dunmow c4d01de82c feat: concise output + diff-scoped any check
Reporter is now a collector: checks declare a verdict (OK/Skip/Warn/Fail/
Fatal) plus findings, and a single central render() prints output. Default
output is silent on pass/skip — only FAIL/WARN/ERR checks print, followed by
one tally line, so a clean run is two lines. --verbose restores full per-check
output. All ~30 checks were converted to this API; orphaned guidance/label
helpers (printPerTargetOKLines, per-check *Help blocks, colors_format.go) were
removed.

The any-usage check (2e) now defaults to only the unstaged working-tree diff
(changed lines), via a new per-repo git-diff index in changedlines.go; --all-any
restores the full scan. Not-a-git-repo / no-diff warns on nothing.

Golden fixtures regenerated; integration tests updated to the new format; added
unit tests for the diff index.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 01:16:56 +08:00

357 lines
10 KiB
Go

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
}