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>
357 lines
10 KiB
Go
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
|
|
}
|