initial: standalone check-safety module hoisted from CMS

Static safety/lint runner for the BlockNinja codebase. ~25 invariant
checks across Go and frontend sources. Was at git.dev.alexdunmow.com:block/ninja
in backend/cmd/check-safety/ until the 2026-06-06 consolidation moved
the BlockNinja repos under a shared ~/src/blockninja/ parent.

This repo is the standalone extraction:
- Own go.mod (git.dev.alexdunmow.com/block/check-safety, go 1.26.4)
- Vendored internal/{helpers,theme} from CMS (Go's internal/ rule
  blocks cross-module imports; vendoring is the workaround)
- CLI contract unchanged: `check-safety <target-dir> [--flags]`
- CMS Makefile shells into ../check-safety for safety-check /
  install-safety-checker targets

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-06-06 13:04:02 +08:00
commit cd88c808b0
98 changed files with 16139 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
/check-safety
*.test
*.out
.idea/
.vscode/

36
CLAUDE.md Normal file
View File

@ -0,0 +1,36 @@
# check-safety
Standalone Go module: BlockNinja static safety/lint runner. ~25 checks across the consolidated tree.
## Layout
```
check-safety/
├── main.go # CLI entry, orders all checks
├── check_*.go # one per check, wires its rule to the runner
├── <rule>.go # rule implementation (errleak.go, colors.go, ...)
├── *_test.go # unit tests per rule
├── golden_test.go # snapshot test that runs the whole binary on fixtures
├── testdata/golden/ # snapshot fixtures + expected stdout/exit
├── internal/helpers/ # vendored from cms/backend/internal/helpers (LogDeferredError)
└── internal/theme/ # vendored from cms/backend/internal/theme (Theme struct + helpers)
```
## Invariants when editing
- This is **standalone** — no imports from sibling repos (CMS, orchestrator, plugins). The two vendored packages above are the only CMS surfaces it depends on, and they are intentional copies.
- `blockNinjaRepoRoot()` and `orchestratorRepoRoot()` in `lint_pipeline.go` hardcode the consolidated layout (`~/src/blockninja/{cms,orchestrator}`). Update them if the tree layout changes.
- Golden tests are characterisation tests — if you intentionally change a check's output, run `make test-update` and commit the new fixture.
- The CMS Makefile's `safety-check` and `install-safety-checker` targets shell out into this directory (`cd ../check-safety`). Keep the CLI contract stable: `check-safety <target-dir> [--flags]`.
## Adding a check
1. New `check_<name>.go` with a `runCheck<Name>(rep *reporter, ...)` func.
2. New `<rule>.go` for the actual rule logic.
3. Add `_test.go` for unit coverage.
4. Wire into `main.go`'s ordered check list. Numbered comments at the top of `main.go` should stay in sync.
5. If the check is deterministic, add a fixture under `testdata/golden/<case>/` and `make test-update`.
## Why vendored, not imported
`internal/helpers` and `internal/theme` are inside CMS's `internal/` tree. Go's internal-package rule blocks cross-module imports of `internal/...`. Options were: refactor CMS to make them public (large blast radius), use a `replace` directive (still blocked by internal rule), or vendor (chosen). Drift between CMS and the vendored copies is the whole point of check 21 (preset validation against `theme.Theme`) — it surfaces schema changes.

28
Makefile Normal file
View File

@ -0,0 +1,28 @@
.PHONY: build install run test test-short test-update tidy clean
BIN := check-safety
TARGET ?= ../cms
build:
go build -o $(BIN) .
install:
go install .
run:
go run . $(TARGET)
test:
go test -count=1 ./...
test-short:
go test -short -count=1 ./...
test-update:
go test -count=1 -run TestGoldenCharacterization -update ./...
tidy:
go mod tidy
clean:
rm -f $(BIN)

56
README.md Normal file
View File

@ -0,0 +1,56 @@
# check-safety
Static safety checker for the BlockNinja codebase. Walks a target tree, runs ~25 invariant checks across Go and frontend sources, and exits non-zero on violations.
Lives at `~/src/blockninja/check-safety/` as a standalone Go module, alongside `cms/`, `orchestrator/`, `core/`, etc.
## Run
```bash
# Scan CMS (default target)
make run
# Scan something else
make run TARGET=../orchestrator
# Direct
go run . ../sites/bidbuddy
```
## Install globally
```bash
make install # → $GOPATH/bin/check-safety
check-safety ~/src/blockninja/cms
```
## Test
```bash
make test # all tests (some shell out to npm/tsc/golangci-lint and may skip if absent)
make test-short # skip the long ones
make test-update # regenerate golden snapshots after intentional output changes
```
## What it checks
See the comment block at the top of `main.go` for the canonical list. Highlights:
- Secret env-var reads happen only inside `config.Load()`
- All RPC methods are registered in the RBAC interceptor
- Frontend uses generated ConnectRPC hooks, no hand-crafted clients
- No hardcoded colors, no `useState` for tab state, no npm/yarn lockfiles
- No raw SQL outside sqlc/Bob, no `err.Error()` leaked to HTTP clients
- Plugin presets validate against `theme.Theme`
- Standalone plugins stay on the published SDK boundary
## How it stays in sync with CMS
`internal/helpers/deferlog.go` and `internal/theme/*.go` are vendored copies from CMS (`cms/backend/internal/{helpers,theme}/`). Go's `internal/` rule blocks direct imports across modules, so they live here. When CMS changes the theme schema or `LogDeferredError`, re-copy them — that drift is the point of the preset-validation check.
## Adding a new check
1. Add a `check_<name>.go` file with a `runCheck<Name>` func
2. Wire it into `main.go`'s check sequence
3. Add a goldens case in `golden_test.go` if the output is deterministic
4. Run `make test-update` to regenerate goldens

221
any_usage.go Normal file
View File

@ -0,0 +1,221 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"strings"
)
type anyUsageWarning struct {
file string
line int
lang string
snippet string
}
var (
reTypeScriptAny = regexp.MustCompile(`\bany\b`)
reTypeScriptAsAny = regexp.MustCompile(`\bas\s+any\b`)
reTypeScriptColonAny = regexp.MustCompile(`:\s*any(?:\b|[\s,\]\)>|=])`)
reTypeScriptCastAny = regexp.MustCompile(`<\s*any\s*>`)
reTypeScriptArrayAny = regexp.MustCompile(`\bany\s*\[\]`)
reTypeScriptEqAny = regexp.MustCompile(`=\s*any(?:\b|[\s;])`)
)
func checkGoAnyUsage(root string) []anyUsageWarning {
var warnings []anyUsageWarning
_ = 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
}
if strings.HasSuffix(path, ".pb.go") || strings.HasSuffix(path, ".connect.go") || strings.HasSuffix(path, ".gen.go") || strings.HasSuffix(path, "_templ.go") {
return nil
}
lines, readErr := readSourceLines(path)
if readErr != nil {
return nil
}
fset := token.NewFileSet()
file, parseErr := parser.ParseFile(fset, path, nil, 0)
if parseErr != nil {
return nil
}
relPath, _ := filepath.Rel(root, path)
relPath = filepath.ToSlash(relPath)
seen := make(map[int]bool)
recordAnyExpr := func(expr ast.Expr) {
for _, pos := range findAnyTypePositions(expr) {
position := fset.Position(pos)
if seen[position.Line] {
continue
}
seen[position.Line] = true
snippet := ""
if position.Line-1 >= 0 && position.Line-1 < len(lines) {
snippet = strings.TrimSpace(lines[position.Line-1])
}
warnings = append(warnings, anyUsageWarning{
file: relPath,
line: position.Line,
lang: "go",
snippet: snippet,
})
}
}
ast.Inspect(file, func(n ast.Node) bool {
switch node := n.(type) {
case *ast.Field:
recordAnyExpr(node.Type)
case *ast.TypeSpec:
recordAnyExpr(node.Type)
case *ast.ValueSpec:
recordAnyExpr(node.Type)
case *ast.FuncDecl:
if node.Type != nil {
recordAnyExpr(node.Type)
}
case *ast.FuncLit:
if node.Type != nil {
recordAnyExpr(node.Type)
}
case *ast.TypeAssertExpr:
recordAnyExpr(node.Type)
case *ast.CompositeLit:
recordAnyExpr(node.Type)
}
return true
})
return nil
})
return warnings
}
func findAnyTypePositions(expr ast.Expr) []token.Pos {
if expr == nil {
return nil
}
var positions []token.Pos
switch node := expr.(type) {
case *ast.Ident:
if node.Name == "any" {
positions = append(positions, node.Pos())
}
case *ast.ArrayType:
positions = append(positions, findAnyTypePositions(node.Elt)...)
case *ast.MapType:
positions = append(positions, findAnyTypePositions(node.Key)...)
positions = append(positions, findAnyTypePositions(node.Value)...)
case *ast.ChanType:
positions = append(positions, findAnyTypePositions(node.Value)...)
case *ast.Ellipsis:
positions = append(positions, findAnyTypePositions(node.Elt)...)
case *ast.ParenExpr:
positions = append(positions, findAnyTypePositions(node.X)...)
case *ast.StarExpr:
positions = append(positions, findAnyTypePositions(node.X)...)
case *ast.IndexExpr:
positions = append(positions, findAnyTypePositions(node.X)...)
positions = append(positions, findAnyTypePositions(node.Index)...)
case *ast.IndexListExpr:
positions = append(positions, findAnyTypePositions(node.X)...)
for _, index := range node.Indices {
positions = append(positions, findAnyTypePositions(index)...)
}
case *ast.SelectorExpr:
positions = append(positions, findAnyTypePositions(node.X)...)
case *ast.StructType:
if node.Fields != nil {
for _, field := range node.Fields.List {
positions = append(positions, findAnyTypePositions(field.Type)...)
}
}
case *ast.InterfaceType:
if node.Methods != nil {
for _, field := range node.Methods.List {
positions = append(positions, findAnyTypePositions(field.Type)...)
}
}
case *ast.FuncType:
if node.Params != nil {
for _, field := range node.Params.List {
positions = append(positions, findAnyTypePositions(field.Type)...)
}
}
if node.Results != nil {
for _, field := range node.Results.List {
positions = append(positions, findAnyTypePositions(field.Type)...)
}
}
}
return positions
}
func checkTypeScriptAnyUsage(root string) []anyUsageWarning {
var warnings []anyUsageWarning
_ = 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" {
return nil
}
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") || strings.Contains(path, "/generated/") {
return nil
}
if strings.HasSuffix(path, ".d.ts") || strings.HasSuffix(path, ".gen.ts") || strings.HasSuffix(path, ".gen.tsx") {
return nil
}
lines, readErr := readSourceLines(path)
if readErr != nil {
return nil
}
relPath, _ := filepath.Rel(root, path)
relPath = filepath.ToSlash(relPath)
for idx, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "/*") || strings.HasPrefix(trimmed, "*") {
continue
}
if strings.Contains(line, "'any'") || strings.Contains(line, `"any"`) || strings.Contains(line, "`any") || strings.Contains(line, "any`") {
continue
}
if !reTypeScriptAny.MatchString(line) {
continue
}
if !reTypeScriptAsAny.MatchString(line) && !reTypeScriptColonAny.MatchString(line) && !reTypeScriptCastAny.MatchString(line) && !reTypeScriptArrayAny.MatchString(line) && !reTypeScriptEqAny.MatchString(line) {
continue
}
warnings = append(warnings, anyUsageWarning{
file: relPath,
line: idx + 1,
lang: "ts",
snippet: trimmed,
})
}
return nil
})
return warnings
}

70
any_usage_test.go Normal file
View File

@ -0,0 +1,70 @@
package main
import (
"path/filepath"
"testing"
)
func TestCheckGoAnyUsageFlagsAnyTypes(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "sample.go"), `package sample
type Payload struct {
Value any
Items []any
}
func Map() map[string]any {
return nil
}
`, 0644)
warnings := checkGoAnyUsage(root)
if len(warnings) < 3 {
t.Fatalf("checkGoAnyUsage() returned %d warnings, want at least 3: %#v", len(warnings), warnings)
}
}
func TestCheckGoAnyUsageSkipsTestsAndGeneratedFiles(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "sample_test.go"), `package sample
func TestThing(value any) {}
`, 0644)
writeTestFile(t, filepath.Join(root, "sample.pb.go"), `package sample
type Payload struct {
Value any
}
`, 0644)
warnings := checkGoAnyUsage(root)
if len(warnings) != 0 {
t.Fatalf("checkGoAnyUsage() returned %d warnings, want 0: %#v", len(warnings), warnings)
}
}
func TestCheckTypeScriptAnyUsageFlagsAnyPatterns(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "sample.ts"), `export type Row = any
export const fn = (value: any[]): any => value as any
`, 0644)
warnings := checkTypeScriptAnyUsage(root)
if len(warnings) < 2 {
t.Fatalf("checkTypeScriptAnyUsage() returned %d warnings, want at least 2: %#v", len(warnings), warnings)
}
}
func TestCheckTypeScriptAnyUsageSkipsCommentsAndStrings(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "sample.ts"), `// any should not count
const label = "any";
/* any */
`, 0644)
warnings := checkTypeScriptAnyUsage(root)
if len(warnings) != 0 {
t.Fatalf("checkTypeScriptAnyUsage() returned %d warnings, want 0: %#v", len(warnings), warnings)
}
}

196
authprecedence.go Normal file
View File

@ -0,0 +1,196 @@
package main
import (
"bufio"
"os"
"path/filepath"
"strings"
"git.dev.alexdunmow.com/block/check-safety/internal/helpers"
)
type authPrecedenceViolation struct {
file string
line int
cookieLine int
snippet string
}
// checkAuthPrecedence scans Go files for functions that extract auth tokens
// where cookies are checked before Bearer/Authorization headers.
//
// Bearer tokens are explicit per-request credentials. Cookies are ambient
// and may be stale (e.g., set by a prior registration while the caller
// intends to authenticate as a different user via Bearer). If both are
// present, Bearer must win.
//
// The anti-pattern:
//
// cookie, err := r.Cookie("access_token") // cookie read first
// if err == nil { token = cookie.Value }
// if token == "" { // bearer only as fallback
// auth := r.Header.Get("Authorization")
// ...
// }
//
// The correct pattern:
//
// auth := r.Header.Get("Authorization") // bearer first
// if bearer, ok := strings.CutPrefix(auth, "Bearer "); ok {
// token = bearer
// }
// if token == "" { // cookie only as fallback
// cookie, err := r.Cookie(...)
// ...
// }
func checkAuthPrecedence(root string) []authPrecedenceViolation {
var violations []authPrecedenceViolation
_ = 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
}
vs := scanFileAuthPrecedence(path, root)
violations = append(violations, vs...)
return nil
})
return violations
}
// scanFileAuthPrecedence scans a single Go file for the cookie-before-bearer
// anti-pattern. It tracks, per function scope, the first line where a cookie
// is read and the first line where a Bearer/Authorization header is read.
// If the cookie read comes first AND the bearer read is inside a
// "if token == """ guard, it's a violation.
func scanFileAuthPrecedence(path, root string) []authPrecedenceViolation {
f, err := os.Open(path)
if err != nil {
return nil
}
defer helpers.LogDeferredError(nil, "close auth scan file", f.Close, "path", path)
relPath, _ := filepath.Rel(root, path)
var violations []authPrecedenceViolation
scanner := bufio.NewScanner(f)
lineNum := 0
braceDepth := 0
funcStartDepth := -1
// Per-function state
cookieLine := 0
bearerLine := 0
bearerGuarded := false // bearer read is inside "if token == """
resetFunc := func() {
cookieLine = 0
bearerLine = 0
bearerGuarded = false
}
for scanner.Scan() {
lineNum++
line := scanner.Text()
trimmed := strings.TrimSpace(line)
// Track brace depth to detect function boundaries
for _, ch := range line {
switch ch {
case '{':
braceDepth++
case '}':
braceDepth--
}
}
// Detect function start
if strings.HasPrefix(trimmed, "func ") || strings.HasPrefix(trimmed, "func(") {
resetFunc()
funcStartDepth = braceDepth
}
// Detect function end
if funcStartDepth >= 0 && braceDepth < funcStartDepth {
// Function ended — emit violation if cookie came before bearer
if cookieLine > 0 && bearerLine > 0 && cookieLine < bearerLine && bearerGuarded {
violations = append(violations, authPrecedenceViolation{
file: relPath,
line: cookieLine,
cookieLine: cookieLine,
snippet: "cookie checked before Bearer token — Bearer must take precedence over ambient cookies",
})
}
resetFunc()
funcStartDepth = -1
}
// Detect cookie read: r.Cookie(...)
if cookieLine == 0 && (strings.Contains(trimmed, ".Cookie(") && !strings.HasPrefix(trimmed, "//")) {
// Exclude http.SetCookie and cookie-setting lines
if !strings.Contains(trimmed, "SetCookie") && !strings.Contains(trimmed, "http.Cookie{") {
cookieLine = lineNum
}
}
// Detect bearer/authorization read
if bearerLine == 0 && !strings.HasPrefix(trimmed, "//") {
if strings.Contains(trimmed, `"Authorization"`) ||
strings.Contains(trimmed, `"Bearer "`) ||
strings.Contains(trimmed, `"Bearer "`) {
bearerLine = lineNum
// Check if this line or a recent line has a guard like `if token == ""`
// by scanning back up to 3 lines for `token == ""`
bearerGuarded = isBearerGuarded(path, lineNum)
}
}
}
// Handle case where function is the last thing in the file
if cookieLine > 0 && bearerLine > 0 && cookieLine < bearerLine && bearerGuarded {
violations = append(violations, authPrecedenceViolation{
file: relPath,
line: cookieLine,
cookieLine: cookieLine,
snippet: "cookie checked before Bearer token — Bearer must take precedence over ambient cookies",
})
}
return violations
}
// isBearerGuarded checks whether the bearer read around the given line
// is inside a guard like `if token == ""` or `if tok == ""`, meaning
// it only fires when no cookie was found — the anti-pattern.
func isBearerGuarded(path string, bearerLineNum int) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer helpers.LogDeferredError(nil, "close bearer guard scan file", f.Close, "path", path)
scanner := bufio.NewScanner(f)
lineNum := 0
// Look at lines bearerLineNum-3 through bearerLineNum
for scanner.Scan() {
lineNum++
if lineNum < bearerLineNum-3 {
continue
}
if lineNum > bearerLineNum {
break
}
line := strings.TrimSpace(scanner.Text())
// Common guard patterns: `if token == ""`, `if tok == ""`
if strings.Contains(line, `== ""`) &&
(strings.Contains(line, "token") || strings.Contains(line, "tok ")) {
return true
}
}
return false
}

211
authprecedence_test.go Normal file
View File

@ -0,0 +1,211 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestCheckAuthPrecedence_CookieBeforeBearer(t *testing.T) {
// Anti-pattern: cookie checked first, bearer only as fallback.
src := `package auth
import "net/http"
func extractClaims(r *http.Request) string {
var token string
cookie, err := r.Cookie("access_token")
if err == nil && cookie.Value != "" {
token = cookie.Value
}
if token == "" {
auth := r.Header.Get("Authorization")
_ = auth
}
return token
}
`
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "bad.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
vs := checkAuthPrecedence(dir)
if len(vs) != 1 {
t.Fatalf("expected 1 violation, got %d", len(vs))
}
if vs[0].cookieLine == 0 {
t.Error("violation should have a cookieLine set")
}
}
func TestCheckAuthPrecedence_BearerBeforeCookie(t *testing.T) {
// Correct pattern: bearer first, cookie fallback.
src := `package auth
import "net/http"
import "strings"
func extractClaims(r *http.Request) string {
var token string
authHeader := r.Header.Get("Authorization")
if after, ok := strings.CutPrefix(authHeader, "Bearer "); ok {
token = after
}
if token == "" {
cookie, err := r.Cookie("access_token")
if err == nil && cookie.Value != "" {
token = cookie.Value
}
}
return token
}
`
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "good.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
vs := checkAuthPrecedence(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations, got %d: %+v", len(vs), vs)
}
}
func TestCheckAuthPrecedence_CookieOnly(t *testing.T) {
// Cookie-only auth (no bearer at all) — not a violation.
src := `package auth
import "net/http"
func extractFromCookie(r *http.Request) string {
cookie, err := r.Cookie("session")
if err != nil {
return ""
}
return cookie.Value
}
`
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "cookie_only.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
vs := checkAuthPrecedence(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations, got %d", len(vs))
}
}
func TestCheckAuthPrecedence_BearerOnly(t *testing.T) {
// Bearer-only auth (no cookie) — not a violation.
src := `package auth
import "net/http"
import "strings"
func extractBearer(r *http.Request) string {
auth := r.Header.Get("Authorization")
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
return after
}
return ""
}
`
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "bearer_only.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
vs := checkAuthPrecedence(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations, got %d", len(vs))
}
}
func TestCheckAuthPrecedence_SetCookieIgnored(t *testing.T) {
// http.SetCookie should not be flagged as a cookie read.
src := `package auth
import "net/http"
import "strings"
func setCookieAndReadBearer(w http.ResponseWriter, r *http.Request) string {
http.SetCookie(w, &http.Cookie{Name: "session", Value: "abc"})
auth := r.Header.Get("Authorization")
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
return after
}
return ""
}
`
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "setcookie.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
vs := checkAuthPrecedence(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations, got %d: %+v", len(vs), vs)
}
}
func TestCheckAuthPrecedence_TestFilesIgnored(t *testing.T) {
// Test files should be ignored.
src := `package auth
import "net/http"
func extractClaims(r *http.Request) string {
var token string
cookie, err := r.Cookie("access_token")
if err == nil && cookie.Value != "" {
token = cookie.Value
}
if token == "" {
auth := r.Header.Get("Authorization")
_ = auth
}
return token
}
`
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "bad_test.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
vs := checkAuthPrecedence(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for test files, got %d", len(vs))
}
}
func TestCheckAuthPrecedence_UnguardedBearerNotViolation(t *testing.T) {
// Cookie first but bearer is NOT guarded by `if token == ""` —
// both are read unconditionally, which is a different (acceptable) pattern
// where the code might merge or compare both.
src := `package auth
import "net/http"
import "strings"
func extractClaims(r *http.Request) string {
cookie, err := r.Cookie("access_token")
cookieToken := ""
if err == nil {
cookieToken = cookie.Value
}
auth := r.Header.Get("Authorization")
bearerToken := ""
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
bearerToken = after
}
if bearerToken != "" {
return bearerToken
}
return cookieToken
}
`
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "both.go"), []byte(src), 0o644); err != nil {
t.Fatal(err)
}
vs := checkAuthPrecedence(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for unguarded bearer, got %d: %+v", len(vs), vs)
}
}

49
check_anyusage.go Normal file
View File

@ -0,0 +1,49 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 24,
ID: "2e",
Title: "Warn on any usage in Go and TypeScript",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 2e: Warn on any usage in Go and TypeScript
fmt.Println("=== Check 2e: Warn on any usage in Go and TypeScript ===")
var anyWarnings []anyUsageWarning
for _, target := range ctx.backendTargets {
for _, w := range checkGoAnyUsage(target.root) {
w.file = prefixDisplayPath(target.displayOrRoot(), w.file)
anyWarnings = append(anyWarnings, w)
}
}
for _, target := range ctx.pluginTargets {
for _, w := range checkGoAnyUsage(target.root) {
w.file = prefixDisplayPath(target.display, w.file)
anyWarnings = append(anyWarnings, w)
}
}
for _, target := range collectESLintDirectiveTargets(ctx.repoRoot, ctx.frontendTargets) {
for _, w := range checkTypeScriptAnyUsage(target.dir) {
w.file = prefixDisplayPath(target.label, w.file)
anyWarnings = append(anyWarnings, w)
}
}
if len(anyWarnings) > 0 {
fmt.Printf(" WARN: %d any usage warning(s):\n", len(anyWarnings))
limit := min(len(anyWarnings), maxAnyWarningsPrinted)
for _, w := range anyWarnings[:limit] {
fmt.Printf(" %s:%d [%s] %s\n", w.file, w.line, w.lang, w.snippet)
}
if len(anyWarnings) > limit {
fmt.Printf(" ... %d additional warning(s) omitted\n", len(anyWarnings)-limit)
}
fmt.Println("\n Guidance: prefer concrete types, generics, typed maps/structs, or unknown-style narrowing patterns over any.")
} else {
fmt.Println(" OK: No any usage found in scanned Go or TypeScript sources")
}
fmt.Println()
},
})
}

39
check_authprecedence.go Normal file
View File

@ -0,0 +1,39 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 190,
ID: "19",
Title: "Bearer token precedence over cookies in auth middleware",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 19: Bearer token precedence over cookies
fmt.Println("\n=== Check 19: Bearer token precedence over cookies in auth middleware ===")
var authPrecViolations []authPrecedenceViolation
for _, target := range ctx.backendTargets {
for _, v := range checkAuthPrecedence(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
authPrecViolations = append(authPrecViolations, v)
}
}
for _, target := range ctx.pluginTargets {
for _, v := range checkAuthPrecedence(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
authPrecViolations = append(authPrecViolations, v)
}
}
if len(authPrecViolations) > 0 {
fmt.Printf(" FAIL: %d auth precedence violation(s):\n", len(authPrecViolations))
for _, v := range authPrecViolations {
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet)
}
fmt.Println("\n Fix: Check Bearer/Authorization header BEFORE cookies. Bearer is explicit; cookies are ambient and may be stale.")
rep.Fail()
} else {
fmt.Println(" OK: Bearer token takes precedence over cookies in all auth extraction")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
}
},
})
}

39
check_bareimports.go Normal file
View File

@ -0,0 +1,39 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 80,
ID: "8",
Title: "Import @block-ninja/api from subpaths only",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 8: Import @block-ninja/api from subpaths only
fmt.Println("=== Check 8: Import @block-ninja/api from subpaths only ===")
if len(ctx.frontendTargets) > 0 {
var bareImports []bareImportViolation
for _, target := range ctx.frontendTargets {
for _, v := range checkBareImports(target.dir) {
v.file = prefixDisplayPath(target.label, v.file)
bareImports = append(bareImports, v)
}
}
if len(bareImports) > 0 {
fmt.Printf(" FAIL: %d bare import(s) from '@block-ninja/api' (naming conflicts):\n", len(bareImports))
for _, v := range bareImports {
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet)
}
fmt.Println("\n Fix: Import from subpaths: @block-ninja/api/types, /queries, or /services")
rep.Fail()
} else {
fmt.Println(" OK: All @block-ninja/api imports use subpaths")
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
}
} else {
fmt.Println(" SKIP: no frontend sources found")
}
fmt.Println()
},
})
}

40
check_buttonautomation.go Normal file
View File

@ -0,0 +1,40 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 100,
ID: "10",
Title: "Button automation attributes",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 10: Button automation attributes
fmt.Println("=== Check 10: Button automation attributes ===")
if len(ctx.frontendTargets) > 0 {
var buttonViolations []buttonViolation
for _, target := range ctx.frontendTargets {
for _, v := range checkButtonAutomation(target.dir) {
v.file = prefixDisplayPath(target.label, v.file)
buttonViolations = append(buttonViolations, v)
}
}
if len(buttonViolations) > 0 {
fmt.Printf(" FAIL: %d <Button> without action=:\n", len(buttonViolations))
for _, v := range buttonViolations {
fmt.Printf(" %s:%d\n", v.file, v.line)
}
fmt.Println("\n Fix: Add action= and entity= props: <Button action=\"create\" entity=\"user\">")
fmt.Println(" See docs/BUTTON_AUTOMATION.md")
rep.Fail()
} else {
fmt.Println(" OK: All buttons have automation attributes")
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
}
} else {
fmt.Println(" SKIP: no frontend sources found")
}
fmt.Println()
},
})
}

48
check_codegen.go Normal file
View File

@ -0,0 +1,48 @@
package main
import (
"fmt"
"os"
"strings"
)
func init() {
register(Check{
Seq: 25,
ID: "2f",
Title: "sqlc compile and buf generate",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 2f: sqlc compile and buf generate
fmt.Println("=== Check 2f: sqlc compile and buf generate ===")
codegenTargets := discoverCodegenTargets(ctx.repoRoot, ctx.backendDir, ctx.backendTargets, ctx.pluginTargets, ctx.includeCoreTargets)
if len(codegenTargets) > 0 {
completedCodegenTargets, codegenFailures, codegenErr := runCodegenChecks(codegenTargets)
if codegenErr != nil {
fmt.Printf(" ERROR: failed to run codegen checks: %v\n", codegenErr)
os.Exit(2)
}
if len(codegenFailures) > 0 {
fmt.Printf(" FAIL: %d codegen target(s) failed sqlc compile or buf generate:\n", len(codegenFailures))
for _, failure := range codegenFailures {
fmt.Printf(" [%s] %s\n", failure.stage, failure.target)
if failure.output != "" {
for line := range strings.SplitSeq(failure.output, "\n") {
fmt.Printf(" %s\n", line)
}
}
}
rep.Fail()
} else {
fmt.Printf(" OK: sqlc compile / buf generate clean for %d target(s)\n", len(completedCodegenTargets))
for _, target := range completedCodegenTargets {
fmt.Printf(" - %s\n", target)
}
}
} else {
fmt.Println(" SKIP: no sqlc or buf codegen targets found")
}
fmt.Println()
},
})
}

99
check_colors.go Normal file
View File

@ -0,0 +1,99 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 60,
ID: "6",
Title: "No hardcoded colors in frontend (use theme tokens)",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 6: Hardcoded colors in frontend and .templ files
fmt.Println("=== Check 6: No hardcoded colors in frontend (use theme tokens) ===")
var colorViolations []colorViolation
for _, target := range ctx.frontendTargets {
for _, v := range checkColors(target.dir) {
v.file = prefixDisplayPath(target.label, v.file)
colorViolations = append(colorViolations, v)
}
}
// Also scan .templ files in backend and plugin roots (templ lives in backend/, not web/src/).
seenTemplRoots := make(map[string]bool)
var templColorViolations []colorViolation
for _, target := range ctx.backendTargets {
if seenTemplRoots[target.root] {
continue
}
seenTemplRoots[target.root] = true
for _, v := range checkTemplColors(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
templColorViolations = append(templColorViolations, v)
}
}
for _, target := range ctx.pluginTargets {
if seenTemplRoots[target.root] {
continue
}
seenTemplRoots[target.root] = true
for _, v := range checkTemplColors(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
templColorViolations = append(templColorViolations, v)
}
}
hasColorSources := len(ctx.frontendTargets) > 0 || len(templColorViolations) > 0 || len(seenTemplRoots) > 0
if !hasColorSources && len(ctx.backendTargets) == 0 {
fmt.Println(" SKIP: no frontend or templ sources found")
} else {
hadColorViolations := false
if len(colorViolations) > 0 {
fmt.Println(formatColorViolations("frontend", colorViolations))
hadColorViolations = true
rep.Fail()
} else if len(ctx.frontendTargets) > 0 {
fmt.Println(" OK: No hardcoded colors in frontend components")
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
}
if len(templColorViolations) > 0 {
fmt.Println(formatColorViolations(".templ files", templColorViolations))
hadColorViolations = true
rep.Fail()
} else if len(seenTemplRoots) > 0 {
fmt.Println(" OK: No hardcoded colors in .templ files")
}
// Scan .ninjatpl block templates in plugin roots for hardcoded colors.
// Use resolvedPluginTargets (pre-filter) so plugin-as-cwd roots aren't skipped.
seenNinjaTplRoots := make(map[string]bool)
var ninjaTplColorViolations []colorViolation
for _, target := range ctx.resolvedPluginTargets {
if seenNinjaTplRoots[target.root] {
continue
}
seenNinjaTplRoots[target.root] = true
for _, v := range checkNinjaTplColors(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
ninjaTplColorViolations = append(ninjaTplColorViolations, v)
}
}
if len(ninjaTplColorViolations) > 0 {
fmt.Println(formatColorViolations(".ninjatpl files", ninjaTplColorViolations))
hadColorViolations = true
rep.Fail()
} else if len(seenNinjaTplRoots) > 0 {
fmt.Println(" OK: No hardcoded colors in .ninjatpl files")
}
if hadColorViolations {
printColorFixInstructions(colorViolations, templColorViolations, ninjaTplColorViolations)
}
}
fmt.Println()
},
})
}

44
check_envreads.go Normal file
View File

@ -0,0 +1,44 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 10,
ID: "1",
Title: "Secret env var reads outside config.Load()",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 1: Secret env var reads
fmt.Println("=== Check 1: Secret env var reads outside config.Load() ===")
var envViolations []envViolation
for _, target := range ctx.backendTargets {
for _, v := range checkEnvReads(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
envViolations = append(envViolations, v)
}
}
for _, target := range ctx.pluginTargets {
for _, v := range checkEnvReads(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
envViolations = append(envViolations, v)
}
}
if len(envViolations) > 0 {
for _, v := range envViolations {
fmt.Printf(" FAIL: %s:%d — os.Getenv(%q)\n", v.file, v.line, v.envVar)
}
fmt.Printf("\n %d violation(s). Secrets must flow through Config → DI.\n", len(envViolations))
rep.Fail()
} else {
fmt.Printf(" OK: No secret env var reads outside config.Load()")
if len(ctx.pluginTargets) > 0 {
fmt.Printf(" (%d plugin roots scanned)", len(ctx.pluginTargets))
}
fmt.Println()
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
}
fmt.Println()
},
})
}

41
check_errleak.go Normal file
View File

@ -0,0 +1,41 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 150,
ID: "15",
Title: "No err.Error() leaked to HTTP clients",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 15: No err.Error() leaked to HTTP clients
fmt.Println("=== Check 15: No err.Error() leaked to HTTP clients ===")
var errLeaks []errLeakViolation
for _, target := range ctx.backendTargets {
for _, v := range checkErrLeak(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
errLeaks = append(errLeaks, v)
}
}
for _, target := range ctx.pluginTargets {
for _, v := range checkErrLeak(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
errLeaks = append(errLeaks, v)
}
}
if len(errLeaks) > 0 {
fmt.Printf(" FAIL: %d err.Error() leak(s) to HTTP clients:\n", len(errLeaks))
for _, v := range errLeaks {
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet)
}
fmt.Println("\n Fix: Log the error with slog.Error() and return a user-friendly message: http.Error(w, \"failed to open repository\", status)")
rep.Fail()
} else {
fmt.Println(" OK: No err.Error() leaked to HTTP clients")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
}
fmt.Println()
},
})
}

40
check_eslintdirective.go Normal file
View File

@ -0,0 +1,40 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 106,
ID: "10b",
Title: "No eslint-disable-next-line comments",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 10b: No eslint-disable-next-line comments
fmt.Println("=== Check 10b: No eslint-disable-next-line comments ===")
eslintDirectiveTargets := collectESLintDirectiveTargets(ctx.repoRoot, ctx.frontendTargets)
if len(eslintDirectiveTargets) > 0 {
var directiveViolations []eslintDirectiveViolation
for _, target := range eslintDirectiveTargets {
for _, v := range checkESLintDisableNextLine(target.dir) {
v.file = prefixDisplayPath(target.label, v.file)
directiveViolations = append(directiveViolations, v)
}
}
if len(directiveViolations) > 0 {
fmt.Printf(" FAIL: %d eslint-disable-next-line comment(s) found:\n", len(directiveViolations))
for _, v := range directiveViolations {
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet)
}
fmt.Println("\n Fix: rewrite the code or typing so the directive is unnecessary.")
rep.Fail()
} else {
fmt.Println(" OK: No eslint-disable-next-line comments found")
printPerTargetOKLines(frontendTargetLabels(eslintDirectiveTargets))
}
} else {
fmt.Println(" SKIP: no frontend sources found")
}
fmt.Println()
},
})
}

66
check_frontendapi.go Normal file
View File

@ -0,0 +1,66 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 50,
ID: "5",
Title: "Frontend uses generated ConnectRPC hooks",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 5: Frontend API usage patterns
fmt.Println("=== Check 5: Frontend uses generated ConnectRPC hooks ===")
if len(ctx.frontendTargets) > 0 {
var feViolations []frontendViolation
var feWarnings []frontendViolation
for _, target := range ctx.frontendTargets {
if target.pluginRules {
for _, v := range checkPluginPages([]string{target.dir}) {
v.file = prefixDisplayPath(target.label, v.file)
feViolations = append(feViolations, v)
}
continue
}
coreViolations, coreWarnings := checkFrontend(target.dir)
for _, v := range coreViolations {
v.file = prefixDisplayPath(target.label, v.file)
feViolations = append(feViolations, v)
}
for _, w := range coreWarnings {
w.file = prefixDisplayPath(target.label, w.file)
feWarnings = append(feWarnings, w)
}
}
if len(feViolations) > 0 {
fmt.Printf(" FAIL: %d violation(s) — use generated hooks from @block-ninja/api/queries:\n", len(feViolations))
for _, v := range feViolations {
fmt.Printf(" %s:%d [%s] %s\n", v.file, v.line, v.rule, v.snippet)
}
rep.Fail()
}
if len(feWarnings) > 0 {
fmt.Printf(" WARN: %d fetch() call(s) to non-proto REST endpoints (no ConnectRPC equivalent):\n", len(feWarnings))
for _, w := range feWarnings {
fmt.Printf(" %s:%d %s\n", w.file, w.line, w.snippet)
}
}
if len(feViolations) == 0 {
fmt.Printf(" OK: Frontend API patterns are clean")
if len(feWarnings) > 0 {
fmt.Printf(" (%d known non-proto fetches)", len(feWarnings))
}
fmt.Println()
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
}
} else {
fmt.Println(" SKIP: no frontend sources found")
}
fmt.Println()
},
})
}

54
check_frontendlint.go Normal file
View File

@ -0,0 +1,54 @@
package main
import (
"fmt"
"os"
"strings"
)
func init() {
register(Check{
Seq: 40,
ID: "4",
Title: "Frontend auto-format, lint, and typecheck",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 4: Frontend auto-fix, lint, and typecheck
fmt.Println("=== Check 4: Frontend auto-format, lint, and typecheck ===")
if len(ctx.frontendTargets) > 0 {
completedTargets, skippedTargets, lintFailures, lintErr := runFrontendAutoFixAndLint(ctx.repoRoot, ctx.frontendTargets)
if lintErr != nil {
fmt.Printf(" ERROR: failed to run frontend lint pipeline: %v\n", lintErr)
os.Exit(2)
}
for _, skipped := range skippedTargets {
fmt.Printf(" SKIP: %s\n", skipped)
}
if len(lintFailures) > 0 {
fmt.Printf(" FAIL: %d frontend target(s) failed Prettier, ESLint, or TypeScript checks:\n", len(lintFailures))
for _, failure := range lintFailures {
fmt.Printf(" [%s] %s\n", failure.stage, failure.target)
if failure.output != "" {
for line := range strings.SplitSeq(failure.output, "\n") {
fmt.Printf(" %s\n", line)
}
}
}
rep.Fail()
} else if len(completedTargets) > 0 {
fmt.Printf(" OK: Prettier, ESLint, and TypeScript checks clean for %d frontend target(s)\n", len(completedTargets))
for _, target := range completedTargets {
fmt.Printf(" - %s\n", target)
}
} else {
fmt.Println(" SKIP: no frontend lint targets with ESLint config")
}
} else {
fmt.Println(" SKIP: no frontend sources found")
}
fmt.Println()
},
})
}

56
check_golint.go Normal file
View File

@ -0,0 +1,56 @@
package main
import (
"fmt"
"os"
"strings"
)
func init() {
register(Check{
Seq: 30,
ID: "3",
Title: "Go code compiles and passes go fix, golangci-lint --fix, go vet, and strict lint",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 3: Go lint pipeline
fmt.Println("=== Check 3: Go code compiles and passes go fix, golangci-lint --fix, go vet, and strict lint ===")
goLintRoots := make([]string, 0, len(ctx.pluginTargets))
for _, target := range ctx.pluginTargets {
goLintRoots = append(goLintRoots, target.root)
}
backendLintRoots := make([]string, 0, len(ctx.backendTargets))
for _, target := range ctx.backendTargets {
backendLintRoots = append(backendLintRoots, target.root)
}
completedGoTargets, skippedGoTargets, goLintFailures, goLintErr := runStrictGoLint(ctx.repoRoot, backendLintRoots, goLintRoots)
if goLintErr != nil {
fmt.Printf(" ERROR: failed to run Go lint pipeline: %v\n", goLintErr)
os.Exit(2)
}
for _, skipped := range skippedGoTargets {
fmt.Printf(" SKIP: %s\n", skipped)
}
if len(goLintFailures) > 0 {
fmt.Printf(" FAIL: %d Go module(s) failed the Go lint pipeline:\n", len(goLintFailures))
for _, failure := range goLintFailures {
fmt.Printf(" [%s]\n", failure.target)
if failure.output != "" {
for line := range strings.SplitSeq(failure.output, "\n") {
fmt.Printf(" %s\n", line)
}
}
}
rep.Fail()
} else if len(completedGoTargets) > 0 {
fmt.Printf(" OK: Go lint pipeline clean for %d module(s)\n", len(completedGoTargets))
for _, target := range completedGoTargets {
fmt.Printf(" - %s\n", target)
}
} else {
fmt.Println(" SKIP: no Go modules found")
}
fmt.Println()
},
})
}

50
check_htmlsanitize.go Normal file
View File

@ -0,0 +1,50 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 220,
ID: "22",
Title: "No hand-rolled HTML sanitization (use bluemonday)",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 22: No hand-rolled HTML sanitization (use bluemonday)
fmt.Println("=== Check 22: No hand-rolled HTML sanitization (use bluemonday) ===")
var htmlSanViolations []htmlSanitizeViolation
for _, target := range ctx.backendTargets {
for _, v := range checkHTMLSanitize(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
htmlSanViolations = append(htmlSanViolations, v)
}
}
for _, target := range ctx.pluginTargets {
for _, v := range checkHTMLSanitize(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
htmlSanViolations = append(htmlSanViolations, v)
}
}
if len(htmlSanViolations) > 0 {
fmt.Printf(" FAIL: %d hand-rolled HTML sanitization pattern(s):\n", len(htmlSanViolations))
for _, v := range htmlSanViolations {
fmt.Printf(" %s:%d [%s] %s\n", v.file, v.line, v.rule, v.snippet)
}
fmt.Println(`
Fix: Use github.com/microcosm-cc/bluemonday for ALL HTML sanitization.
Instead of Use
regexp strip <tags> bluemonday.StrictPolicy().Sanitize(s)
func stripHTML() { char loop } bluemonday.StrictPolicy().Sanitize(s)
strings.NewReplacer("<br>","") bluemonday.StrictPolicy().Sanitize(s)
helpers.StripHTML(s) bluemonday.StrictPolicy().Sanitize(s)
Regex-based HTML stripping is a security risk it misses edge cases,
nested tags, and encoded entities. bluemonday is battle-tested.`)
rep.Fail()
} else {
fmt.Println(" OK: No hand-rolled HTML sanitization detected")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
}
},
})
}

38
check_lockfiles.go Normal file
View File

@ -0,0 +1,38 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 90,
ID: "9",
Title: "No npm/yarn lockfiles (pnpm only)",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 9: No npm/yarn lockfiles
fmt.Println("=== Check 9: No npm/yarn lockfiles (pnpm only) ===")
lockViolations := []lockfileViolation{}
if ctx.includeCoreTargets {
lockViolations = checkLockfiles(ctx.repoRoot)
}
for _, target := range ctx.pluginTargets {
for _, v := range checkLockfiles(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
lockViolations = append(lockViolations, v)
}
}
if len(lockViolations) > 0 {
fmt.Printf(" FAIL: %d non-pnpm lockfile(s) found:\n", len(lockViolations))
for _, v := range lockViolations {
fmt.Printf(" %s\n", v.file)
}
fmt.Println("\n Fix: Remove and use pnpm (pnpm-lock.yaml only)")
rep.Fail()
} else {
fmt.Println(" OK: Only pnpm lockfiles present")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
}
fmt.Println()
},
})
}

53
check_orchtests.go Normal file
View File

@ -0,0 +1,53 @@
package main
import (
"fmt"
"os"
"strings"
)
func init() {
register(Check{
Seq: 31,
ID: "3b",
Title: "Orchestrator backend tests",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 3b: Orchestrator backend tests
fmt.Println("=== Check 3b: Orchestrator backend tests ===")
var orchestratorTestTargets []goTestTarget
if ctx.orchestratorOnly {
orchestratorTestTargets = discoverOrchestratorTestTargetsFromRoot(orchestratorRepoRoot())
} else {
orchestratorTestTargets = discoverOrchestratorTestTargets(ctx.repoRoot, ctx.backendDir, ctx.includeCoreTargets)
}
if len(orchestratorTestTargets) > 0 {
completedOrchestratorTargets, orchestratorFailures, orchestratorErr := runGoTestTargets(orchestratorTestTargets)
if orchestratorErr != nil {
fmt.Printf(" ERROR: failed to run orchestrator tests: %v\n", orchestratorErr)
os.Exit(2)
}
if len(orchestratorFailures) > 0 {
fmt.Printf(" FAIL: %d orchestrator test target(s) failed:\n", len(orchestratorFailures))
for _, failure := range orchestratorFailures {
fmt.Printf(" [%s] %s\n", failure.stage, failure.target)
if failure.output != "" {
for line := range strings.SplitSeq(failure.output, "\n") {
fmt.Printf(" %s\n", line)
}
}
}
rep.Fail()
} else {
fmt.Printf(" OK: Orchestrator tests clean for %d target(s)\n", len(completedOrchestratorTargets))
for _, target := range completedOrchestratorTargets {
fmt.Printf(" - %s\n", target)
}
}
} else {
fmt.Println(" SKIP: orchestrator backend not found or not part of this scan")
}
fmt.Println()
},
})
}

65
check_placeholder.go Normal file
View File

@ -0,0 +1,65 @@
package main
import (
"fmt"
"sort"
)
func init() {
register(Check{
Seq: 110,
ID: "11",
Title: "No placeholder code; only shipped features",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 11: No placeholder code; only shipped features
fmt.Println("=== Check 11: No placeholder code; only shipped features ===")
comingSoonViolations := []comingSoonViolation{}
placeholderRoots := make([]todoScanRoot, 0, len(ctx.backendTargets)+len(ctx.frontendTargets)+len(ctx.pluginTargets))
for _, target := range ctx.backendTargets {
placeholderRoots = append(placeholderRoots, todoScanRoot{
root: target.root,
displayPrefix: target.display,
})
}
for _, target := range ctx.frontendTargets {
for _, v := range checkComingSoon(target.dir) {
v.file = prefixDisplayPath(target.label, v.file)
comingSoonViolations = append(comingSoonViolations, v)
}
placeholderRoots = append(placeholderRoots, todoScanRoot{
root: target.dir,
displayPrefix: target.label,
})
}
for _, target := range ctx.pluginTargets {
placeholderRoots = append(placeholderRoots, todoScanRoot{
root: target.root,
displayPrefix: target.display,
})
}
placeholderViolations := checkPlaceholderLanguage(placeholderRoots)
allPlaceholderViolations := append(comingSoonViolations, placeholderViolations...)
sort.Slice(allPlaceholderViolations, func(i, j int) bool {
if allPlaceholderViolations[i].file == allPlaceholderViolations[j].file {
return allPlaceholderViolations[i].line < allPlaceholderViolations[j].line
}
return allPlaceholderViolations[i].file < allPlaceholderViolations[j].file
})
if len(allPlaceholderViolations) > 0 {
fmt.Printf(" FAIL: %d placeholder reference(s) found:\n", len(allPlaceholderViolations))
for _, v := range allPlaceholderViolations {
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet)
}
fmt.Println("\n Fix: ship the feature now. Placeholder code is not allowed.")
rep.Fail()
} else {
fmt.Println(" OK: No placeholder code found")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
}
fmt.Println()
},
})
}

28
check_plugindiscovery.go Normal file
View File

@ -0,0 +1,28 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 107,
ID: "10disc",
Title: "Plugin frontend discovery",
Run: func(ctx *ScanContext, rep *Reporter) {
if len(ctx.unresolvedPluginRoots) > 0 {
fmt.Println("=== Check 10: Plugin frontend discovery ===")
fmt.Printf(" FAIL: %d plugin root(s) could not be resolved:\n", len(ctx.unresolvedPluginRoots))
for _, root := range ctx.unresolvedPluginRoots {
fmt.Printf(" - %s\n", root)
}
fmt.Println("\n Expected a plugin root directory. Frontend detection is optional; backend scans still run when a root resolves.")
fmt.Println()
rep.Fail()
} else if len(ctx.resolvedPluginTargets) > 0 {
fmt.Println("=== Check 10: Plugin frontend discovery ===")
fmt.Printf(" OK: Resolved %d plugin root(s)\n", len(ctx.resolvedPluginTargets))
printPerTargetOKLines(pluginTargetLabels(ctx.resolvedPluginTargets))
fmt.Println()
}
},
})
}

69
check_presets.go Normal file
View File

@ -0,0 +1,69 @@
package main
import (
"fmt"
"os"
"path/filepath"
)
func init() {
register(Check{
Seq: 210,
ID: "21",
Title: "Plugin presets.json validation",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 21: Plugin presets.json validation
fmt.Println("\n=== Check 21: Plugin presets.json validation ===")
var presetViolations []presetViolation
presetsChecked := 0
// Check internal plugins
if ctx.includeCoreTargets {
internalPluginsDir := filepath.Join(ctx.backendDir, "internal", "plugins")
if dirExists(internalPluginsDir) {
entries, _ := os.ReadDir(internalPluginsDir)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
pluginDir := filepath.Join(internalPluginsDir, entry.Name())
if !fileExists(filepath.Join(pluginDir, "presets.json")) {
continue
}
presetsChecked++
for _, v := range checkPresets(pluginDir) {
v.file = prefixDisplayPath("plugins/"+entry.Name(), v.file)
presetViolations = append(presetViolations, v)
}
}
}
}
// Check external plugin roots
for _, target := range ctx.resolvedPluginTargets {
vs := checkPresets(target.root)
if len(vs) == 0 && len(findPresetsFiles(target.root)) > 0 {
presetsChecked++
}
for _, v := range vs {
presetsChecked++
v.file = prefixDisplayPath(target.display, v.file)
presetViolations = append(presetViolations, v)
}
}
if len(presetViolations) > 0 {
fmt.Printf(" FAIL: %d presets.json validation error(s):\n", len(presetViolations))
for _, v := range presetViolations {
fmt.Printf(" %s — %s\n", v.file, v.message)
}
fmt.Println("\n Fix: Ensure presets.json matches the theme.Theme struct types.")
fmt.Println(" Common issues: numeric values where strings are expected (e.g., fontWeightBase: 400 → \"400\")")
rep.Fail()
} else if presetsChecked > 0 {
fmt.Printf(" OK: All presets.json files are valid (%d checked)\n", presetsChecked)
} else {
fmt.Println(" OK: No presets.json files found in scanned targets")
}
fmt.Println()
},
})
}

51
check_protoownership.go Normal file
View File

@ -0,0 +1,51 @@
package main
import (
"fmt"
"os"
)
func init() {
register(Check{
Seq: 21,
ID: "2b",
Title: "Plugin proto ownership",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 2b: Plugin proto ownership
fmt.Println("=== Check 2b: Plugin proto ownership ===")
var protoOwnershipViolations []protoOwnershipViolation
for _, target := range ctx.backendTargets {
if !isPluginModuleRoot(target.root) {
continue
}
violations, err := checkPluginProtoOwnership(target.root, target.displayOrRoot())
if err != nil {
fmt.Printf(" ERROR: failed to check proto ownership for %s: %v\n", target.displayOrRoot(), err)
os.Exit(2)
}
protoOwnershipViolations = append(protoOwnershipViolations, violations...)
}
for _, target := range ctx.pluginTargets {
violations, err := checkPluginProtoOwnership(target.root, target.display)
if err != nil {
fmt.Printf(" ERROR: failed to check proto ownership for %s: %v\n", target.display, err)
os.Exit(2)
}
protoOwnershipViolations = append(protoOwnershipViolations, violations...)
}
if len(protoOwnershipViolations) > 0 {
fmt.Printf(" FAIL: %d plugin proto ownership violation(s):\n", len(protoOwnershipViolations))
for _, v := range protoOwnershipViolations {
fmt.Printf(" %s [%s] local proto missing; matching proto exists in %s\n", v.root, v.namespace, v.coreProto)
}
fmt.Println("\n Fix: move plugin proto sources into the plugin repo and generate api/ from there.")
rep.Fail()
} else {
fmt.Println(" OK: Plugin proto ownership is clean")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
}
fmt.Println()
},
})
}

41
check_rawsql.go Normal file
View File

@ -0,0 +1,41 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 140,
ID: "14",
Title: "No raw SQL outside sqlc/Bob",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 14: No raw SQL outside sqlc/Bob
fmt.Println("=== Check 14: No raw SQL outside sqlc/Bob ===")
var rawSQLViolations []rawSQLViolation
for _, target := range ctx.backendTargets {
for _, v := range checkRawSQL(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
rawSQLViolations = append(rawSQLViolations, v)
}
}
for _, target := range ctx.pluginTargets {
for _, v := range checkRawSQL(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
rawSQLViolations = append(rawSQLViolations, v)
}
}
if len(rawSQLViolations) > 0 {
fmt.Printf(" FAIL: %d raw SQL statement(s) found outside sqlc/Bob:\n", len(rawSQLViolations))
for _, v := range rawSQLViolations {
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet)
}
printRawSQLHelp()
rep.Fail()
} else {
fmt.Println(" OK: No raw SQL outside sqlc/Bob")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
}
fmt.Println()
},
})
}

214
check_rbac.go Normal file
View File

@ -0,0 +1,214 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
)
func init() {
register(Check{
Seq: 20,
ID: "2",
Title: "RPC methods registered in RBAC interceptor",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 2: RPC methods in RBAC interceptor
fmt.Println("=== Check 2: RPC methods registered in RBAC interceptor ===")
protoMethods := make([]string, 0)
rbacMethods := make(map[string]string)
protoMethodSet := make(map[string]bool)
requiresRPCDiscovery := false
var rbacSummaries []rbacTargetSummary
for _, target := range ctx.backendTargets {
rbacFile := filepath.Join(target.root, "internal/rbac/interceptor.go")
hasCoreRBAC := fileExists(rbacFile)
summary := rbacTargetSummary{
label: target.displayOrRoot(),
roleByMethod: make(map[string]string),
}
if hasCoreRBAC {
requiresRPCDiscovery = true
summary.requiresRPCs = true
targetProtoMethods, err := extractBufProcedures(target.protoRoot, target.allowedPackagePrefixes...)
if err != nil {
fmt.Printf(" ERROR: failed to parse proto files for %s: %v\n", target.displayOrRoot(), err)
os.Exit(2)
}
summary.protoMethods = append(summary.protoMethods, targetProtoMethods...)
for _, pm := range targetProtoMethods {
if protoMethodSet[pm] {
continue
}
protoMethodSet[pm] = true
protoMethods = append(protoMethods, pm)
}
targetRBACMethods, err := extractRBACMethodRoles(rbacFile)
if err != nil {
fmt.Printf(" ERROR: failed to parse RBAC interceptor for %s: %v\n", target.displayOrRoot(), err)
os.Exit(2)
}
for method, role := range targetRBACMethods {
rbacMethods[method] = role
summary.roleByMethod[method] = role
}
rbacSummaries = append(rbacSummaries, summary)
continue
}
if len(target.allowedPackagePrefixes) > 0 {
requiresRPCDiscovery = true
summary.requiresRPCs = true
}
targetProtoMethods, err := extractBufProcedures(target.root)
if err != nil {
fmt.Printf(" ERROR: failed to parse proto files for %s: %v\n", target.displayOrRoot(), err)
os.Exit(2)
}
summary.protoMethods = append(summary.protoMethods, targetProtoMethods...)
for _, pm := range targetProtoMethods {
if protoMethodSet[pm] {
continue
}
protoMethodSet[pm] = true
protoMethods = append(protoMethods, pm)
}
targetRoleMethods, err := extractPluginRoleMethods(target.root)
if err != nil {
fmt.Printf(" ERROR: failed to parse RBAC roles for %s: %v\n", target.displayOrRoot(), err)
os.Exit(2)
}
for method, role := range targetRoleMethods {
rbacMethods[method] = role
summary.roleByMethod[method] = role
}
rbacSummaries = append(rbacSummaries, summary)
}
for _, target := range ctx.pluginTargets {
summary := rbacTargetSummary{
label: target.display,
roleByMethod: make(map[string]string),
}
pluginProtoMethods, err := extractBufProcedures(target.root)
if err != nil {
fmt.Printf(" ERROR: failed to parse plugin proto files for %s: %v\n", target.display, err)
os.Exit(2)
}
summary.protoMethods = append(summary.protoMethods, pluginProtoMethods...)
for _, pm := range pluginProtoMethods {
if protoMethodSet[pm] {
continue
}
protoMethodSet[pm] = true
protoMethods = append(protoMethods, pm)
}
pluginRoleMethods, err := extractPluginRoleMethods(target.root)
if err != nil {
fmt.Printf(" ERROR: failed to parse plugin RBAC roles for %s: %v\n", target.display, err)
os.Exit(2)
}
for method, role := range pluginRoleMethods {
rbacMethods[method] = role
summary.roleByMethod[method] = role
}
rbacSummaries = append(rbacSummaries, summary)
}
if len(protoMethods) == 0 {
if requiresRPCDiscovery {
fmt.Println(" ERROR: no RPC procedures found in proto sources or generated Connect files")
os.Exit(2)
}
fmt.Println(" SKIP: no proto-backed RPC procedures found in scanned target(s)")
} else {
var missing []string
for _, m := range protoMethods {
if isExcludedServiceMethod(m) {
continue
}
if _, ok := rbacMethods[m]; !ok {
missing = append(missing, m)
}
}
var stale []string
for m := range rbacMethods {
if !protoMethodSet[m] {
if isExcludedServiceMethod(m) {
continue
}
stale = append(stale, m)
}
}
sort.Strings(stale)
for i := range rbacSummaries {
summary := &rbacSummaries[i]
for _, method := range summary.protoMethods {
if isExcludedServiceMethod(method) {
continue
}
if _, ok := summary.roleByMethod[method]; !ok {
summary.missingCount++
}
}
for method := range summary.roleByMethod {
if isExcludedServiceMethod(method) {
continue
}
if !containsString(summary.protoMethods, method) {
summary.staleCount++
}
}
}
for _, summary := range rbacSummaries {
if len(summary.protoMethods) == 0 {
if summary.requiresRPCs {
fmt.Printf(" ERROR: %s — no RPC procedures found\n", summary.label)
} else {
fmt.Printf(" OK: %s — no proto-backed RPC procedures\n", summary.label)
}
continue
}
fmt.Printf(" OK: %s — %d RPC methods discovered\n", summary.label, len(summary.protoMethods))
printRoleBreakdown(countRBACRoles(summary.protoMethods, summary.roleByMethod))
if summary.missingCount > 0 {
fmt.Printf(" missing RBAC entries: %d\n", summary.missingCount)
}
if summary.staleCount > 0 {
fmt.Printf(" stale RBAC entries: %d\n", summary.staleCount)
}
}
if len(missing) > 0 {
fmt.Printf(" FAIL: %d RPC method(s) NOT in MethodRoles (will get permission_denied):\n", len(missing))
for _, m := range missing {
fmt.Printf(" - %s\n", m)
}
rep.Fail()
}
if len(stale) > 0 {
fmt.Printf(" WARN: %d RBAC entries have no matching proto method (stale?):\n", len(stale))
for _, m := range stale {
fmt.Printf(" - %s\n", m)
}
}
if len(missing) == 0 && len(stale) == 0 {
fmt.Printf(" OK: All %d RPC methods are registered in MethodRoles\n", len(protoMethods))
} else if len(missing) == 0 {
fmt.Printf(" OK: All %d RPC methods are registered (but %d stale entries exist)\n", len(protoMethods), len(stale))
}
}
fmt.Println()
},
})
}

49
check_reinvented.go Normal file
View File

@ -0,0 +1,49 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 120,
ID: "12",
Title: "No reinvented utilities (use helpers)",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 12: No reinvented utilities
fmt.Println("=== Check 12: No reinvented utilities (use helpers) ===")
var beReinvented []reinventedViolation
for _, target := range ctx.backendTargets {
for _, v := range checkReinventedBackend(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
beReinvented = append(beReinvented, v)
}
}
for _, target := range ctx.pluginTargets {
for _, v := range checkReinventedBackend(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
beReinvented = append(beReinvented, v)
}
}
var feReinvented []reinventedViolation
for _, target := range ctx.frontendTargets {
for _, v := range checkReinventedFrontend(target.dir) {
v.file = prefixDisplayPath(target.label, v.file)
feReinvented = append(feReinvented, v)
}
}
allReinvented := append(beReinvented, feReinvented...)
if len(allReinvented) > 0 {
fmt.Printf(" FAIL: %d reinvented utility pattern(s):\n", len(allReinvented))
for _, v := range allReinvented {
fmt.Printf(" %s:%d [%s] → %s\n", v.file, v.line, v.rule, v.hint)
}
printReinventedHelp()
rep.Fail()
} else {
fmt.Println(" OK: No reinvented utilities detected")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
}
fmt.Println()
},
})
}

56
check_rpcerrors.go Normal file
View File

@ -0,0 +1,56 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 130,
ID: "13",
Title: "RPC query/mutation error handling",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 13: RPC error handling
fmt.Println("=== Check 13: RPC query/mutation error handling ===")
if len(ctx.frontendTargets) > 0 {
var rpcViolations []rpcErrorViolation
var rpcWarnings []rpcErrorViolation
for _, target := range ctx.frontendTargets {
pv, pw := checkRPCErrors(target.dir)
for _, v := range pv {
v.file = prefixDisplayPath(target.label, v.file)
rpcViolations = append(rpcViolations, v)
}
for _, w := range pw {
w.file = prefixDisplayPath(target.label, w.file)
rpcWarnings = append(rpcWarnings, w)
}
}
if len(rpcViolations) > 0 {
fmt.Printf(" FAIL: %d mutation(s) with no error handling:\n", len(rpcViolations))
for _, v := range rpcViolations {
fmt.Printf(" %s:%d [%s] %s\n", v.file, v.line, v.rule, v.snippet)
}
printRPCErrorHelp()
rep.Fail()
}
if len(rpcWarnings) > 0 {
fmt.Printf(" INFO: %d useQuery call(s) without error destructuring (global handler covers these)\n", len(rpcWarnings))
}
if len(rpcViolations) == 0 {
fmt.Printf(" OK: All mutations have error handling")
if len(rpcWarnings) > 0 {
fmt.Printf(" (%d queries rely on global error handler)", len(rpcWarnings))
}
fmt.Println()
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
}
} else {
fmt.Println(" SKIP: no frontend sources found")
}
fmt.Println()
},
})
}

97
check_sdkboundaries.go Normal file
View File

@ -0,0 +1,97 @@
package main
import (
"fmt"
"os"
)
func init() {
register(Check{
Seq: 22,
ID: "2c",
Title: "Standalone plugin SDK import boundaries",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 2c: Standalone plugin SDK import boundaries
fmt.Println("=== Check 2c: Standalone plugin SDK import boundaries ===")
var cmsGoModViolations []pluginGoModViolation
var pluginImportViolations []pluginImportViolation
var pluginGoModViolations []pluginGoModViolation
var standalonePluginLabels []string
checkedStandalonePluginRoots := make(map[string]bool)
requiredSDKVersion, requiredSDKVersionErr := currentCMSCoreSDKVersion()
if requiredSDKVersionErr != nil {
fmt.Printf(" ERROR: failed to resolve CMS SDK version: %v\n", requiredSDKVersionErr)
os.Exit(2)
}
checkStandalonePluginRoot := func(root string, label string) {
if checkedStandalonePluginRoots[root] || !shouldCheckStandalonePluginImports(root) {
return
}
checkedStandalonePluginRoots[root] = true
standalonePluginLabels = append(standalonePluginLabels, label)
for _, v := range checkStandalonePluginImports(root) {
v.file = prefixDisplayPath(label, v.file)
pluginImportViolations = append(pluginImportViolations, v)
}
for _, v := range checkStandalonePluginGoMod(root, requiredSDKVersion) {
v.file = prefixDisplayPath(label, v.file)
pluginGoModViolations = append(pluginGoModViolations, v)
}
}
for _, target := range ctx.backendTargets {
if samePath(target.root, ctx.backendDir) {
for _, v := range checkCMSCoreSDKGoMod(target.root) {
v.file = prefixDisplayPath(target.displayOrRoot(), v.file)
cmsGoModViolations = append(cmsGoModViolations, v)
}
}
checkStandalonePluginRoot(target.root, target.displayOrRoot())
}
for _, target := range ctx.pluginTargets {
checkStandalonePluginRoot(target.root, target.display)
}
if len(cmsGoModViolations) > 0 || len(pluginImportViolations) > 0 || len(pluginGoModViolations) > 0 {
if len(cmsGoModViolations) > 0 {
fmt.Printf(" FAIL: %d CMS backend go.mod violation(s):\n", len(cmsGoModViolations))
for _, v := range cmsGoModViolations {
if v.line > 0 {
fmt.Printf(" %s:%d [%s] %s\n", v.file, v.line, v.rule, v.detail)
continue
}
fmt.Printf(" %s [%s] %s\n", v.file, v.rule, v.detail)
}
}
if len(pluginImportViolations) > 0 {
fmt.Printf(" FAIL: %d standalone plugin import violation(s):\n", len(pluginImportViolations))
for _, v := range pluginImportViolations {
fmt.Printf(" %s:%d imports BlockNinja CMS package %q\n", v.file, v.line, v.importPath)
}
}
if len(pluginGoModViolations) > 0 {
fmt.Printf(" FAIL: %d standalone plugin go.mod violation(s):\n", len(pluginGoModViolations))
for _, v := range pluginGoModViolations {
if v.line > 0 {
fmt.Printf(" %s:%d [%s] %s\n", v.file, v.line, v.rule, v.detail)
continue
}
fmt.Printf(" %s [%s] %s\n", v.file, v.rule, v.detail)
}
}
fmt.Printf("\n Fix: CMS backend must not replace %s locally, and standalone plugins must use %s imports, require %s %s, and declare no replace directives.\n", blockCoreImportPrefix, blockCoreImportPrefix, blockCoreImportPrefix, requiredSDKVersion)
rep.Fail()
} else if len(standalonePluginLabels) > 0 {
fmt.Printf(" OK: Standalone plugin imports and go.mod stay on SDK version %s\n", requiredSDKVersion)
printPerTargetOKLines(standalonePluginLabels)
if ctx.includeCoreTargets {
fmt.Printf(" - %s/go.mod does not locally replace %s\n", ctx.backendTargets[0].displayOrRoot(), blockCoreImportPrefix)
}
} else if ctx.includeCoreTargets {
fmt.Printf(" OK: %s/go.mod does not locally replace %s\n", ctx.backendTargets[0].displayOrRoot(), blockCoreImportPrefix)
} else {
fmt.Println(" SKIP: no standalone plugin roots scanned")
}
fmt.Println()
},
})
}

52
check_segmentation.go Normal file
View File

@ -0,0 +1,52 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 180,
ID: "18",
Title: "Plugin segmentation (safety-rules.yml)",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 18: Plugin segmentation (safety-rules.yml)
fmt.Println("=== Check 18: Plugin segmentation (safety-rules.yml) ===")
var segViolations []segmentationViolation
segPluginsChecked := 0
for _, target := range ctx.resolvedPluginTargets {
rules, loadErr := loadSafetyRules(target.root)
if loadErr != nil {
fmt.Printf(" ERROR: %s: %v\n", target.display, loadErr)
rep.Fail()
continue
}
if rules == nil || rules.Segmentation == nil {
continue // No safety-rules.yml or no segmentation section — skip
}
segPluginsChecked++
vs := checkSegmentation(target.root, target.display, rules.Segmentation)
segViolations = append(segViolations, vs...)
}
if len(segViolations) > 0 {
fmt.Printf(" FAIL: %d segmentation violation(s) found:\n", len(segViolations))
for _, v := range segViolations {
if v.line > 0 {
fmt.Printf(" %s/%s:%d [%s] %s\n", v.pluginName, v.file, v.line, v.rule, v.detail)
} else {
fmt.Printf(" %s/%s [%s] %s\n", v.pluginName, v.file, v.rule, v.detail)
}
}
rep.Fail()
} else if segPluginsChecked > 0 {
fmt.Printf(" OK: Plugin segmentation clean (%d plugin(s) checked)\n", segPluginsChecked)
for _, target := range ctx.resolvedPluginTargets {
rules, _ := loadSafetyRules(target.root)
if rules != nil && rules.Segmentation != nil {
fmt.Printf(" OK: %s\n", target.display)
}
}
} else {
fmt.Println(" OK: No plugins define safety-rules.yml segmentation rules")
}
},
})
}

53
check_sqlcuuid.go Normal file
View File

@ -0,0 +1,53 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 23,
ID: "2d",
Title: "sqlc UUID overrides in plugins and cmd",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 2d: sqlc UUID overrides in plugins and cmd
fmt.Println("=== Check 2d: sqlc UUID overrides in plugins and cmd ===")
var sqlcUUIDViolations []sqlcUUIDViolation
var sqlcScanLabels []string
for _, target := range ctx.backendTargets {
includeRootSQLC := shouldCheckStandalonePluginImports(target.root)
violations := checkSQLCUUIDOverrides(target.root, includeRootSQLC)
if len(violations) == 0 && len(findSQLCConfigFiles(target.root, includeRootSQLC)) > 0 {
sqlcScanLabels = append(sqlcScanLabels, target.displayOrRoot())
}
for _, v := range violations {
v.file = prefixDisplayPath(target.displayOrRoot(), v.file)
sqlcUUIDViolations = append(sqlcUUIDViolations, v)
}
}
for _, target := range ctx.pluginTargets {
violations := checkSQLCUUIDOverrides(target.root, true)
if len(violations) == 0 && len(findSQLCConfigFiles(target.root, true)) > 0 {
sqlcScanLabels = append(sqlcScanLabels, target.display)
}
for _, v := range violations {
v.file = prefixDisplayPath(target.display, v.file)
sqlcUUIDViolations = append(sqlcUUIDViolations, v)
}
}
if len(sqlcUUIDViolations) > 0 {
fmt.Printf(" FAIL: %d sqlc uuid override violation(s):\n", len(sqlcUUIDViolations))
for _, v := range sqlcUUIDViolations {
fmt.Printf(" %s [%s] %s\n", v.file, v.rule, v.detail)
}
fmt.Println("\n Fix: sqlc uuid overrides must use github.com/google/uuid.UUID and github.com/google/uuid.NullUUID, never string UUIDs.")
rep.Fail()
} else if len(sqlcScanLabels) > 0 {
fmt.Println(" OK: sqlc UUID overrides use github.com/google/uuid")
printPerTargetOKLines(sqlcScanLabels)
} else {
fmt.Println(" SKIP: no plugin or cmd sqlc configs found")
}
fmt.Println()
},
})
}

40
check_structure.go Normal file
View File

@ -0,0 +1,40 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 160,
ID: "16",
Title: "Services and handlers in correct directories",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 16: Services/handlers in correct directories
fmt.Println("=== Check 16: Services and handlers in correct directories ===")
var structViolations []structureViolation
for _, target := range ctx.backendTargets {
for _, v := range checkServiceStructure(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
structViolations = append(structViolations, v)
}
}
for _, target := range ctx.pluginTargets {
for _, v := range checkServiceStructure(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
structViolations = append(structViolations, v)
}
}
if len(structViolations) > 0 {
fmt.Printf(" FAIL: %d type(s) in wrong directory:\n", len(structViolations))
for _, v := range structViolations {
fmt.Printf(" %s:%d [%s] %s\n", v.file, v.line, v.rule, v.snippet)
}
rep.Fail()
} else {
fmt.Println(" OK: All services and handlers in correct directories")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
}
fmt.Println()
},
})
}

39
check_tabstate.go Normal file
View File

@ -0,0 +1,39 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 70,
ID: "7",
Title: "No useState for tab state in routes (use URL ?tab= params)",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 7: No useState for tab state in route files
fmt.Println("=== Check 7: No useState for tab state in routes (use URL ?tab= params) ===")
if len(ctx.frontendTargets) > 0 {
var tabViolations []tabStateViolation
for _, target := range ctx.frontendTargets {
for _, v := range checkTabState(target.dir) {
v.file = prefixDisplayPath(target.label, v.file)
tabViolations = append(tabViolations, v)
}
}
if len(tabViolations) > 0 {
fmt.Printf(" FAIL: %d route file(s) use useState for tab state:\n", len(tabViolations))
for _, v := range tabViolations {
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet)
}
fmt.Println("\n Fix: Use URL search params (?tab=) for bookmarkability. See settings.tsx for reference.")
rep.Fail()
} else {
fmt.Println(" OK: No useState tab state in routes")
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
}
} else {
fmt.Println(" SKIP: no frontend sources found")
}
fmt.Println()
},
})
}

51
check_tailwind.go Normal file
View File

@ -0,0 +1,51 @@
package main
import (
"fmt"
"path/filepath"
)
func init() {
register(Check{
Seq: 200,
ID: "20",
Title: "Tailwind v4 configuration (PostCSS, CSS directives, @config)",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 20: Tailwind v4 configuration
fmt.Println("\n=== Check 20: Tailwind v4 configuration (PostCSS, CSS directives, @config) ===")
if len(ctx.frontendTargets) > 0 {
var twViolations []tailwindViolation
for _, target := range ctx.frontendTargets {
if target.pluginRules {
continue // Plugin frontends don't have their own PostCSS/Tailwind setup
}
for _, v := range checkTailwindV4(target.dir) {
v.file = filepath.Clean(prefixDisplayPath(target.label, v.file))
twViolations = append(twViolations, v)
}
}
if len(twViolations) > 0 {
fmt.Printf(" FAIL: %d Tailwind v4 configuration issue(s):\n", len(twViolations))
for _, v := range twViolations {
if v.line > 0 {
fmt.Printf(" %s:%d [%s] %s\n", v.file, v.line, v.rule, v.snippet)
} else {
fmt.Printf(" %s [%s] %s\n", v.file, v.rule, v.snippet)
}
}
fmt.Println("\n Fix:")
fmt.Println(" postcss-v4-plugin → use '@tailwindcss/postcss': {} instead of tailwindcss: {}")
fmt.Println(" postcss-no-autoprefixer → remove autoprefixer (built into Tailwind v4)")
fmt.Println(" css-v4-import → replace @tailwind base/components/utilities with @import \"tailwindcss\"")
fmt.Println(" css-missing-config → add @config \"../tailwind.config.ts\" to your CSS entry point")
rep.Fail()
} else {
fmt.Println(" OK: Tailwind v4 configuration is correct")
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
}
} else {
fmt.Println(" SKIP: no frontend sources found")
}
},
})
}

48
check_todos.go Normal file
View File

@ -0,0 +1,48 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 170,
ID: "17",
Title: "No TODO markers in production code",
Run: func(ctx *ScanContext, rep *Reporter) {
// Check 17: No TODO markers in production code
fmt.Println("=== Check 17: No TODO markers in production code ===")
todoRoots := make([]todoScanRoot, 0, len(ctx.backendTargets)+len(ctx.frontendTargets)+len(ctx.pluginTargets))
for _, target := range ctx.backendTargets {
todoRoots = append(todoRoots, todoScanRoot{
root: target.root,
displayPrefix: target.display,
})
}
for _, target := range ctx.frontendTargets {
todoRoots = append(todoRoots, todoScanRoot{
root: target.dir,
displayPrefix: target.label,
})
}
for _, target := range ctx.pluginTargets {
todoRoots = append(todoRoots, todoScanRoot{
root: target.root,
displayPrefix: target.display,
})
}
todoViolations := checkTODOs(todoRoots)
if len(todoViolations) > 0 {
fmt.Printf(" FAIL: %d TODO marker(s) found:\n", len(todoViolations))
for _, v := range todoViolations {
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet)
}
fmt.Println("\n Fix: Remove TODO markers and ship explicit behavior instead of placeholders.")
rep.Fail()
} else {
fmt.Println(" OK: No TODO markers found in production code")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
}
fmt.Println()
},
})
}

130
codegen.go Normal file
View File

@ -0,0 +1,130 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
)
type codegenTarget struct {
label string
workdir string
stage string
binary string
args []string
}
type codegenFailure struct {
target string
stage string
output string
}
func discoverCodegenTargets(repoRoot, backendDir string, backendTargets []backendScanTarget, pluginTargets []pluginScanTarget, includeCoreTargets bool) []codegenTarget {
var targets []codegenTarget
seen := make(map[string]bool)
addTarget := func(workdir, label, stage, binary string, args ...string) {
key := workdir + "|" + stage
if workdir == "" || seen[key] {
return
}
seen[key] = true
targets = append(targets, codegenTarget{
label: label,
workdir: workdir,
stage: stage,
binary: binary,
args: append([]string{}, args...),
})
}
if includeCoreTargets && samePath(repoRoot, blockNinjaRepoRoot()) {
if fileExists(filepath.Join(backendDir, "sqlc.yaml")) {
addTarget(backendDir, "backend", "sqlc compile", "sqlc", "compile")
}
if fileExists(filepath.Join(repoRoot, "buf.yaml")) {
addTarget(repoRoot, "repo", "buf generate", "buf", "generate", "--path", "proto/blockninja/v1", "--path", "proto/orchestrator/v1")
}
}
for _, target := range pluginTargets {
if fileExists(filepath.Join(target.root, "sqlc.yaml")) {
addTarget(target.root, target.display, "sqlc compile", "sqlc", "compile")
}
if fileExists(filepath.Join(target.root, "buf.yaml")) {
addTarget(target.root, target.display, "buf generate", "buf", "generate")
}
}
for _, target := range backendTargets {
if !shouldCheckStandalonePluginImports(target.root) {
continue
}
if fileExists(filepath.Join(target.root, "sqlc.yaml")) {
addTarget(target.root, target.displayOrRoot(), "sqlc compile", "sqlc", "compile")
}
if fileExists(filepath.Join(target.root, "buf.yaml")) {
addTarget(target.root, target.displayOrRoot(), "buf generate", "buf", "generate")
}
}
sort.Slice(targets, func(i, j int) bool {
if targets[i].label == targets[j].label {
return targets[i].stage < targets[j].stage
}
return targets[i].label < targets[j].label
})
return targets
}
func runCodegenChecks(targets []codegenTarget) (completed []string, failures []codegenFailure, err error) {
sqlcBin, err := exec.LookPath("sqlc")
if err != nil {
return nil, nil, fmt.Errorf("sqlc not found in PATH")
}
bufBin, err := exec.LookPath("buf")
if err != nil {
return nil, nil, fmt.Errorf("buf not found in PATH")
}
return runCodegenChecksWithBinaries(targets, sqlcBin, bufBin)
}
func runCodegenChecksWithBinaries(targets []codegenTarget, sqlcBin, bufBin string) (completed []string, failures []codegenFailure, err error) {
for _, target := range targets {
binary := target.binary
args := append([]string{}, target.args...)
cleanup := func() {}
switch target.binary {
case "sqlc":
binary = sqlcBin
case "buf":
binary = bufBin
tempOutputDir, mkErr := os.MkdirTemp("", "check-safety-buf-*")
if mkErr != nil {
return nil, nil, fmt.Errorf("create temp output dir for %s: %w", target.label, mkErr)
}
args = append(args, "-o", tempOutputDir)
cleanup = func() {
_ = os.RemoveAll(tempOutputDir)
}
}
output, runErr := runCommand(target.workdir, binary, args...)
cleanup()
if runErr != nil {
failures = append(failures, codegenFailure{
target: target.label,
stage: target.stage,
output: output,
})
continue
}
completed = append(completed, target.label+" ["+target.stage+"]")
}
sort.Strings(completed)
return completed, failures, nil
}

75
codegen_test.go Normal file
View File

@ -0,0 +1,75 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDiscoverCodegenTargetsIncludesCoreAndPluginConfigs(t *testing.T) {
repoRoot := blockNinjaRepoRoot()
backendDir := filepath.Join(repoRoot, "backend")
pluginRoot := filepath.Join(t.TempDir(), "plugin")
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "sqlc.yaml"), `version: "2"`, 0644)
writeTestFile(t, filepath.Join(pluginRoot, "buf.yaml"), "version: v2\nmodules:\n - path: proto\n", 0644)
targets := discoverCodegenTargets(repoRoot, backendDir, []backendScanTarget{{root: backendDir, display: "backend"}}, []pluginScanTarget{{root: pluginRoot, display: "plugin"}}, true)
if len(targets) != 4 {
t.Fatalf("discoverCodegenTargets() returned %d targets, want 4: %#v", len(targets), targets)
}
}
func TestRunCodegenChecksWithBinariesRunsSQLCAndBuf(t *testing.T) {
root := t.TempDir()
sqlcBin := writeExecutableFile(t, filepath.Join(root, "sqlc"), "#!/bin/sh\nexit 0\n")
bufArgsFile := filepath.Join(root, "buf-args.txt")
bufBin := writeExecutableFile(t, filepath.Join(root, "buf"), "#!/bin/sh\nprintf '%s\\n' \"$@\" > "+shellQuote(bufArgsFile)+"\nexit 0\n")
completed, failures, err := runCodegenChecksWithBinaries([]codegenTarget{
{label: "backend", workdir: root, stage: "sqlc compile", binary: "sqlc", args: []string{"compile"}},
{label: "repo", workdir: root, stage: "buf generate", binary: "buf", args: []string{"generate"}},
}, sqlcBin, bufBin)
if err != nil {
t.Fatalf("runCodegenChecksWithBinaries() error = %v", err)
}
if len(failures) != 0 {
t.Fatalf("failures = %#v, want none", failures)
}
if len(completed) != 2 {
t.Fatalf("completed = %#v, want 2", completed)
}
bufArgsBytes, err := os.ReadFile(bufArgsFile)
if err != nil {
t.Fatalf("ReadFile(%s): %v", bufArgsFile, err)
}
bufArgs := string(bufArgsBytes)
if !strings.Contains(bufArgs, "-o") {
t.Fatalf("buf args = %q, want -o temp output redirection", bufArgs)
}
}
func TestRunCodegenChecksWithBinariesCapturesFailures(t *testing.T) {
root := t.TempDir()
sqlcBin := writeExecutableFile(t, filepath.Join(root, "sqlc"), "#!/bin/sh\necho \"sqlc failed\" >&2\nexit 1\n")
bufBin := writeExecutableFile(t, filepath.Join(root, "buf"), "#!/bin/sh\nexit 0\n")
completed, failures, err := runCodegenChecksWithBinaries([]codegenTarget{
{label: "backend", workdir: root, stage: "sqlc compile", binary: "sqlc", args: []string{"compile"}},
}, sqlcBin, bufBin)
if err != nil {
t.Fatalf("runCodegenChecksWithBinaries() error = %v", err)
}
if len(completed) != 0 {
t.Fatalf("completed = %#v, want none", completed)
}
if len(failures) != 1 {
t.Fatalf("failures = %#v, want 1", failures)
}
if !strings.Contains(failures[0].output, "sqlc failed") {
t.Fatalf("failure output = %q, want stderr", failures[0].output)
}
}

475
colors.go Normal file
View File

@ -0,0 +1,475 @@
package main
import (
"bufio"
"os"
"path/filepath"
"regexp"
"strings"
"git.dev.alexdunmow.com/block/check-safety/internal/helpers"
)
type colorViolation struct {
file string
line int
rule string
snippet string
}
// reHexInTemplClass checks for hex colors in HTML class= attributes (templ syntax uses class=, not className=)
var reHexInTemplClass = regexp.MustCompile(`class=["'][^"']*#[0-9a-fA-F]{3,8}`)
// reInlineHexTemplStyle matches raw hex colors in templ style= string literals.
var reInlineHexTemplStyle = regexp.MustCompile(`style=(?:\{\s*)?["'][^"']*(?:color|background(?:-color)?)\s*:\s*#[0-9a-fA-F]{3,8}`)
// reInlineRGBTemplStyle matches literal rgb()/hsl() colors in templ style= string literals.
var reInlineRGBTemplStyle = regexp.MustCompile(`style=(?:\{\s*)?["'][^"']*(?:color|background(?:-color)?)\s*:\s*(?:rgb|hsl)a?\([^"']*\)`)
// checkTemplColors scans .templ files under root for hardcoded color violations.
// Templ files use HTML class= (not React className=), so they need separate handling.
func checkTemplColors(root string) []colorViolation {
var violations []colorViolation
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if filepath.Ext(path) != ".templ" {
return nil
}
if strings.Contains(path, "/vendor/") || strings.Contains(path, "/.git/") {
return nil
}
relPath, _ := filepath.Rel(root, path)
if relPath == "" {
relPath = path
}
if isColorAllowed(relPath) {
return nil
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer helpers.LogDeferredError(nil, "close templ color 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
}
if reColorDataKey.MatchString(trimmed) &&
!strings.Contains(trimmed, "class=") &&
!strings.Contains(trimmed, "className=") &&
!strings.Contains(trimmed, "style=") {
continue
}
if reTwHardcodedColor.MatchString(line) {
match := reTwHardcodedColor.FindString(line)
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-hardcoded-tw-color",
snippet: match,
})
}
if reHexInTemplClass.MatchString(line) {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-hex-in-class",
snippet: strings.TrimSpace(reHexInTemplClass.FindString(line)),
})
}
if reTwArbitraryHex.MatchString(line) {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-tw-arbitrary-hex",
snippet: reTwArbitraryHex.FindString(line),
})
}
if reTwArbitraryRGB.MatchString(line) {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-tw-arbitrary-rgb",
snippet: reTwArbitraryRGB.FindString(line),
})
}
if reInlineHexTemplStyle.MatchString(line) {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-inline-hex-style",
snippet: strings.TrimSpace(reInlineHexTemplStyle.FindString(line)),
})
}
if reInlineRGBTemplStyle.MatchString(line) && !strings.Contains(line, "var(--") {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-inline-rgb-style",
snippet: strings.TrimSpace(reInlineRGBTemplStyle.FindString(line)),
})
}
}
return nil
}); err != nil {
violations = append(violations, colorViolation{
file: filepath.ToSlash(root),
line: 0,
rule: "walk-error",
snippet: err.Error(),
})
}
return violations
}
// Only files that ARE theme definitions may have raw color values.
var colorAllowedDirs []string
var colorAllowedFiles = map[string]bool{
"routes/admin/system.tsx": true, // Theme preset picker — displays theme color swatches using actual HSL values
"components/settings/theme-card.tsx": true, // Orchestrator theme gallery — preview swatches need real HSL ramps
"routes/dashboard/sites/new/step-design.tsx": true, // Template chooser artwork uses explicit preview ramps
}
// Tailwind color names that should use semantic tokens instead
var twColorNames = []string{
"gray", "slate", "zinc", "neutral", "stone",
"red", "orange", "amber", "yellow", "lime",
"green", "emerald", "teal", "cyan", "sky",
"blue", "indigo", "violet", "purple", "fuchsia",
"pink", "rose",
}
var (
// Matches: text-blue-500, bg-red-100, border-green-700, ring-amber-300, etc.
// Also matches hover:, focus:, dark: variants
reTwHardcodedColor *regexp.Regexp
// Matches: #fff, #000000, #3b82f6, etc. in className or JSX strings
reHexInClass = regexp.MustCompile(`className=["'\x60][^"'\x60]*#[0-9a-fA-F]{3,8}`)
// Matches: style={{ color: '#...', backgroundColor: '#...' }} with string literals
reInlineHexStyle = regexp.MustCompile(`style=\{?\{[^}]*(?:color|backgroundColor|background)\s*:\s*['"]#[0-9a-fA-F]{3,8}['"]`)
// Matches: style={{ color: 'rgb(...)', backgroundColor: 'hsl(...)' }} with string literals (not var(--)
reInlineRGBStyle = regexp.MustCompile(`style=\{?\{[^}]*(?:color|backgroundColor|background)\s*:\s*['"](?:rgb|hsl)a?\([^)]*\)['"]`)
// Matches Tailwind arbitrary hex values: bg-[#ff0000], text-[#333], border-[#abc123]
reTwArbitraryHex = regexp.MustCompile(`(?:bg|text|border|ring|fill|stroke)-\[#[0-9a-fA-F]{3,8}\]`)
// Matches Tailwind arbitrary rgb/hsl values: bg-[rgb(255,0,0)], text-[hsl(0,100%,50%)]
reTwArbitraryRGB = regexp.MustCompile(`(?:bg|text|border)-\[(?:rgb|hsl)a?\([^)]+\)\]`)
// Heuristic: skip lines that are defining data/constants (color maps, arrays)
reColorDataKey = regexp.MustCompile(`(?:hex|value|color)\s*[:=]`)
)
func init() {
// Build regex for tailwind hardcoded colors: (dark:|hover:|focus:)?(bg|text|border|ring|shadow|outline|decoration|divide|from|to|via)-(color)-(shade)
var colorAlts []string
colorAlts = append(colorAlts, twColorNames...)
colorGroup := strings.Join(colorAlts, "|")
// Match in className strings — look for the Tailwind class pattern
reTwHardcodedColor = regexp.MustCompile(
`(?:(?:dark|hover|focus|active|group-hover|peer-hover|disabled):)*` +
`(?:bg|text|border|ring|shadow|outline|decoration|divide|from|to|via|fill|stroke|accent|caret|placeholder)-` +
`(?:` + colorGroup + `)-` +
`\d{2,3}`,
)
}
func isColorAllowed(relPath string) bool {
if colorAllowedFiles[relPath] {
return true
}
for _, dir := range colorAllowedDirs {
if strings.HasPrefix(relPath, dir) {
return true
}
}
// Files with "color" in the name are likely color utilities
base := filepath.Base(relPath)
return strings.Contains(strings.ToLower(base), "color")
}
// reNinjaTplHardcodedHSL matches hsl() with literal numeric hue values — not CSS variable references.
// Flags: hsl(168, 82%, 46%), hsl(150 27% 33%)
// Allows: hsl(var(--primary)), hsl(var(--background))
var reNinjaTplHardcodedHSL = regexp.MustCompile(`\bhsl\(\s*\d`)
// reNinjaTplHardcodedRGB matches rgb()/rgba() with literal numeric values.
var reNinjaTplHardcodedRGB = regexp.MustCompile(`\brgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)`)
// reNinjaTplArbitraryColor matches Tailwind arbitrary HSL/RGB/RGBA values in class= attributes.
// Flags: text-[hsl(168,82%,46%)], bg-[rgba(130,70,220,0.1)], border-[rgb(0,205,155)]
var reNinjaTplArbitraryColor = regexp.MustCompile(
`(?:bg|text|border|ring|fill|stroke|from|to|via)-\[(?:hsl|rgb|rgba)\([^)]+\)\]`,
)
// reNinjaTplArbitraryHex matches Tailwind arbitrary hex values: bg-[#ff0000], text-[#333]
var reNinjaTplArbitraryHex = regexp.MustCompile(`(?:bg|text|border|ring|fill|stroke)-\[#[0-9a-fA-F]{3,8}\]`)
// reNinjaTplInlineHex matches hex colors inside style= attributes.
var reNinjaTplInlineHex = regexp.MustCompile(`style=[^>]*#[0-9a-fA-F]{3,8}`)
// reNinjaTplExtractRGB extracts the first three RGB components from an rgb/rgba call.
var reNinjaTplExtractRGB = regexp.MustCompile(`\brgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)`)
// isNeutralRGB reports whether an rgb/rgba match uses only 0 or 255 for all three channels
// (pure black, pure white, or transparent). These are structural overlays, not brand colors.
func isNeutralRGB(match string) bool {
m := reNinjaTplExtractRGB.FindStringSubmatch(match)
if m == nil {
return false
}
for _, v := range m[1:4] {
if v != "0" && v != "255" {
return false
}
}
return true
}
// checkNinjaTplColors scans .ninjatpl block templates under root for hardcoded color violations.
// All colors in .ninjatpl files must use CSS custom properties (hsl(var(--token))) rather than
// literal HSL, RGB, RGBA, or hex values.
func checkNinjaTplColors(root string) []colorViolation {
var violations []colorViolation
if err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if filepath.Ext(path) != ".ninjatpl" {
return nil
}
if strings.Contains(path, "/vendor/") || strings.Contains(path, "/.git/") {
return nil
}
relPath, _ := filepath.Rel(root, path)
if relPath == "" {
relPath = path
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer helpers.LogDeferredError(nil, "close ninjatpl color scan file", f.Close, "path", path)
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
trimmed := strings.TrimSpace(line)
// Skip HTML comments
if strings.HasPrefix(trimmed, "<!--") {
continue
}
// 1. Hardcoded hsl() with literal values (not var(--)
if reNinjaTplHardcodedHSL.MatchString(line) {
for _, m := range reNinjaTplHardcodedHSL.FindAllStringIndex(line, -1) {
ctx := line[m[0]:]
if !strings.HasPrefix(ctx, "hsl(var(") {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-hardcoded-hsl",
snippet: line[m[0]:min(m[0]+30, len(line))],
})
}
}
}
// 2. Hardcoded rgb()/rgba() with non-neutral values
if reNinjaTplHardcodedRGB.MatchString(line) {
for _, m := range reNinjaTplHardcodedRGB.FindAllString(line, -1) {
if !isNeutralRGB(m) {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-hardcoded-rgb",
snippet: m,
})
}
}
}
// 3. Hex colors in style= attributes
if reNinjaTplInlineHex.MatchString(line) {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-inline-hex",
snippet: strings.TrimSpace(reNinjaTplInlineHex.FindString(line)),
})
}
// 4. Tailwind arbitrary hsl/rgb/rgba values: text-[hsl(...)], bg-[rgba(...)]
if reNinjaTplArbitraryColor.MatchString(line) {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-tw-arbitrary-color",
snippet: reNinjaTplArbitraryColor.FindString(line),
})
}
// 5. Tailwind arbitrary hex values: bg-[#333], text-[#ff0000]
if reNinjaTplArbitraryHex.MatchString(line) {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-tw-arbitrary-hex",
snippet: reNinjaTplArbitraryHex.FindString(line),
})
}
// 6. Tailwind named color classes (e.g. text-blue-500, bg-amber-200)
if reTwHardcodedColor.MatchString(line) {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-hardcoded-tw-color",
snippet: reTwHardcodedColor.FindString(line),
})
}
}
return nil
}); err != nil {
violations = append(violations, colorViolation{
file: filepath.ToSlash(root),
line: 0,
rule: "walk-error",
snippet: err.Error(),
})
}
return violations
}
func checkColors(webSrcDir string) []colorViolation {
var violations []colorViolation
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 != ".tsx" && ext != ".ts" && ext != ".jsx" {
return nil
}
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") {
return nil
}
relPath, _ := filepath.Rel(webSrcDir, path)
if relPath == "" {
relPath = path
}
if isColorAllowed(relPath) {
return nil
}
f, err := os.Open(path)
if err != nil {
return nil
}
defer helpers.LogDeferredError(nil, "close frontend color scan file", f.Close, "path", path)
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
trimmed := strings.TrimSpace(line)
// Skip comments
if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "/*") {
continue
}
// Skip SVG fill/stroke attributes (these can't use Tailwind classes)
if strings.Contains(trimmed, "fill=") || strings.Contains(trimmed, "stroke=") {
if !strings.Contains(trimmed, "className") {
continue
}
}
// Skip lines that are just defining data/constants (color maps, arrays)
// Heuristic: if the line contains hex/value/color as an object key, it's data
if reColorDataKey.MatchString(trimmed) &&
!strings.Contains(trimmed, "class=") &&
!strings.Contains(trimmed, "className=") &&
!strings.Contains(trimmed, "style=") {
continue
}
// Check for Tailwind hardcoded color classes
if reTwHardcodedColor.MatchString(line) {
match := reTwHardcodedColor.FindString(line)
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-hardcoded-tw-color",
snippet: match,
})
}
// Check for hex colors in className
if reHexInClass.MatchString(line) {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-hex-in-className",
snippet: strings.TrimSpace(reHexInClass.FindString(line)),
})
}
// Check for inline style hex colors (skip if value is a variable)
if reInlineHexStyle.MatchString(line) {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-inline-hex-style",
snippet: strings.TrimSpace(reInlineHexStyle.FindString(line)),
})
}
// Check for inline style rgb/hsl with literal values (not css variables)
if reInlineRGBStyle.MatchString(line) {
// Skip if it's using css variables: hsl(var(--...))
if !strings.Contains(line, "var(--") {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-inline-rgb-style",
snippet: strings.TrimSpace(reInlineRGBStyle.FindString(line)),
})
}
}
// Check for Tailwind arbitrary hex values: bg-[#ff0000], text-[#333]
if reTwArbitraryHex.MatchString(line) {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-tw-arbitrary-hex",
snippet: strings.TrimSpace(reTwArbitraryHex.FindString(line)),
})
}
// Check for Tailwind arbitrary rgb/hsl values: bg-[rgb(255,0,0)]
if reTwArbitraryRGB.MatchString(line) {
violations = append(violations, colorViolation{
file: relPath, line: lineNum, rule: "no-tw-arbitrary-rgb",
snippet: strings.TrimSpace(reTwArbitraryRGB.FindString(line)),
})
}
}
return nil
}); err != nil {
violations = append(violations, colorViolation{
file: filepath.ToSlash(webSrcDir),
line: 0,
rule: "walk-error",
snippet: err.Error(),
})
}
return violations
}

147
colors_format.go Normal file
View File

@ -0,0 +1,147 @@
package main
import (
"fmt"
"sort"
"strings"
)
// hintForColor returns a likely semantic replacement for a hardcoded color snippet.
// This is intentionally approximate and is used only in the consolidated fix block.
func hintForColor(snippet string) string {
s := strings.ToLower(snippet)
for _, prefix := range []string{
"bg-", "text-", "border-", "ring-", "shadow-", "outline-",
"decoration-", "divide-", "from-", "to-", "via-", "fill-", "stroke-",
"accent-", "caret-", "placeholder-",
} {
_, after, ok := strings.Cut(s, prefix)
if !ok {
continue
}
twPrefix := prefix[:len(prefix)-1]
isLowShadeBg := false
if twPrefix == "bg" || twPrefix == "from" || twPrefix == "to" || twPrefix == "via" {
for _, low := range []string{"-50", "-100", "-200"} {
if strings.Contains(after, low) {
isLowShadeBg = true
break
}
}
}
switch {
case strings.HasPrefix(after, "green") || strings.HasPrefix(after, "emerald") || strings.HasPrefix(after, "lime"):
if isLowShadeBg {
return twPrefix + "-success/10"
}
return twPrefix + "-success"
case strings.HasPrefix(after, "red") || strings.HasPrefix(after, "rose"):
if isLowShadeBg {
return twPrefix + "-destructive/10"
}
return twPrefix + "-destructive"
case strings.HasPrefix(after, "amber") || strings.HasPrefix(after, "yellow") || strings.HasPrefix(after, "orange"):
if isLowShadeBg {
return twPrefix + "-warning/10"
}
return twPrefix + "-warning"
case strings.HasPrefix(after, "blue") || strings.HasPrefix(after, "sky") || strings.HasPrefix(after, "cyan"):
if isLowShadeBg {
return twPrefix + "-info/10"
}
return twPrefix + "-info"
case strings.HasPrefix(after, "indigo"):
return twPrefix + "-primary"
case strings.HasPrefix(after, "purple") || strings.HasPrefix(after, "violet") || strings.HasPrefix(after, "fuchsia") || strings.HasPrefix(after, "pink") || strings.HasPrefix(after, "teal"):
return twPrefix + "-accent"
case strings.HasPrefix(after, "gray") || strings.HasPrefix(after, "slate") || strings.HasPrefix(after, "zinc") || strings.HasPrefix(after, "neutral") || strings.HasPrefix(after, "stone"):
if twPrefix == "text" {
return "text-muted-foreground"
}
return twPrefix + "-muted"
}
}
if strings.Contains(s, "#") || strings.Contains(s, "rgb") || strings.Contains(s, "hsl") {
return "hsl(var(--token))"
}
return ""
}
func formatColorViolations(scope string, violations []colorViolation) string {
if len(violations) == 0 {
return ""
}
sorted := append([]colorViolation(nil), violations...)
sort.Slice(sorted, func(i, j int) bool {
if sorted[i].file != sorted[j].file {
return sorted[i].file < sorted[j].file
}
if sorted[i].line != sorted[j].line {
return sorted[i].line < sorted[j].line
}
if sorted[i].rule != sorted[j].rule {
return sorted[i].rule < sorted[j].rule
}
return sorted[i].snippet < sorted[j].snippet
})
var out strings.Builder
fmt.Fprintf(&out, " FAIL: %d hardcoded color(s) in %s:\n", len(sorted), scope)
currentFile := ""
for _, v := range sorted {
if v.file != currentFile {
fmt.Fprintf(&out, " %s\n", v.file)
currentFile = v.file
}
if v.line > 0 {
fmt.Fprintf(&out, " %d [%s] %s\n", v.line, v.rule, v.snippet)
continue
}
fmt.Fprintf(&out, " [%s] %s\n", v.rule, v.snippet)
}
return strings.TrimRight(out.String(), "\n")
}
func collectColorHints(violationGroups ...[]colorViolation) []string {
seen := make(map[string]bool)
var hints []string
for _, violations := range violationGroups {
for _, v := range violations {
hint := hintForColor(v.snippet)
if hint == "" || seen[hint] {
continue
}
seen[hint] = true
hints = append(hints, hint)
}
}
sort.Strings(hints)
if len(hints) > 5 {
hints = append(hints[:5], "...")
}
return hints
}
func printColorFixInstructions(violationGroups ...[]colorViolation) {
fmt.Println(" Fix:")
if hints := collectColorHints(violationGroups...); len(hints) > 0 {
fmt.Printf(" 1. Likely replacements for this batch: %s\n", strings.Join(hints, ", "))
fmt.Println(" 2. Replace literals with semantic tokens or CSS vars where needed.")
} else {
fmt.Println(" 1. Replace literals with semantic tokens or CSS vars.")
}
fmt.Println(" 3. Tailwind token surface: tools/tailwind-service/input.base.css")
fmt.Println(" 4. Runtime theme variable generation: internal/theme/css.go")
fmt.Println(" 5. Plugin CSS examples: internal/plugins/defaultplugin/assets/style.css")
}

175
colors_test.go Normal file
View File

@ -0,0 +1,175 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestCheckTemplColorsFlagsHardcodedColorsAndInlineStyles(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "sample.templ")
content := `package sample
templ Example() {
<div class="text-blue-600 bg-[#fff]"></div>
<div class="border border-border" style="color: #fff; background: rgb(255, 0, 0)"></div>
<div style={ "background-color: hsl(12 100% 50%)" }></div>
}
`
if err := os.WriteFile(file, []byte(content), 0644); err != nil {
t.Fatalf("write sample templ: %v", err)
}
violations := checkTemplColors(root)
if len(violations) < 5 {
t.Fatalf("checkTemplColors() returned %d violations, want at least 5: %#v", len(violations), violations)
}
rules := map[string]bool{}
for _, violation := range violations {
rules[violation.rule] = true
}
for _, rule := range []string{
"no-hardcoded-tw-color",
"no-hex-in-class",
"no-tw-arbitrary-hex",
"no-inline-hex-style",
"no-inline-rgb-style",
} {
if !rules[rule] {
t.Fatalf("missing rule %q in violations: %#v", rule, violations)
}
}
}
func TestCheckTemplColorsAllowsSemanticTokensAndCSSVariables(t *testing.T) {
root := t.TempDir()
file := filepath.Join(root, "sample.templ")
content := `package sample
templ Example() {
<div class="bg-success/10 text-success border-border"></div>
<div style="background: hsl(var(--background)); color: hsl(var(--foreground))"></div>
<div style={ "background: hsl(var(--primary)); color: hsl(var(--primary-foreground))" }></div>
}
`
if err := os.WriteFile(file, []byte(content), 0644); err != nil {
t.Fatalf("write sample templ: %v", err)
}
violations := checkTemplColors(root)
if len(violations) != 0 {
t.Fatalf("checkTemplColors() returned %d violations, want 0: %#v", len(violations), violations)
}
}
func TestCheckNinjaTplColorsFlagsHardcodedColors(t *testing.T) {
root := t.TempDir()
tplDir := filepath.Join(root, "templates", "blocks")
if err := os.MkdirAll(tplDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
file := filepath.Join(tplDir, "hero.ninjatpl")
content := `<section style="background:hsl(232,55%,4%);min-height:28rem;">
<h1 class="text-[hsl(210,18%,96%)] text-5xl">{{ headline }}</h1>
<p class="text-[rgba(220,232,248,0.75)]">{{ subtext }}</p>
<a style="background:hsl(168,82%,46%)">{{ primary_cta }}</a>
<div style="background:#0a0e24">hardcoded hex</div>
<span class="bg-[rgba(130,70,220,0.1)]">badge</span>
<span class="text-blue-500">named color</span>
</section>
`
if err := os.WriteFile(file, []byte(content), 0644); err != nil {
t.Fatalf("write ninjatpl: %v", err)
}
violations := checkNinjaTplColors(root)
if len(violations) < 6 {
t.Fatalf("checkNinjaTplColors() returned %d violations, want at least 6: %#v", len(violations), violations)
}
rules := map[string]bool{}
for _, v := range violations {
rules[v.rule] = true
}
for _, rule := range []string{
"no-hardcoded-hsl",
"no-tw-arbitrary-color",
"no-inline-hex",
"no-hardcoded-tw-color",
} {
if !rules[rule] {
t.Fatalf("missing rule %q in violations: %#v", rule, violations)
}
}
}
func TestCheckNinjaTplColorsAllowsCSSVariablesAndNeutralOverlays(t *testing.T) {
root := t.TempDir()
tplDir := filepath.Join(root, "templates", "blocks")
if err := os.MkdirAll(tplDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
file := filepath.Join(tplDir, "hero.ninjatpl")
content := `<section class="bg-background text-foreground">
<h1 class="text-primary text-5xl">{{ headline }}</h1>
<p class="text-muted-foreground">{{ subtext }}</p>
<a style="background:hsl(var(--primary));color:hsl(var(--primary-foreground))">{{ cta }}</a>
<div style="background:rgba(0,0,0,0.5)">dark overlay</div>
<div style="background:rgba(255,255,255,0.1)">light overlay</div>
<span class="bg-primary/10 text-accent">badge</span>
</section>
`
if err := os.WriteFile(file, []byte(content), 0644); err != nil {
t.Fatalf("write ninjatpl: %v", err)
}
violations := checkNinjaTplColors(root)
if len(violations) != 0 {
t.Fatalf("checkNinjaTplColors() returned %d violations, want 0: %#v", len(violations), violations)
}
}
func TestFormatColorViolationsGroupsByFile(t *testing.T) {
report := formatColorViolations(".ninjatpl files", []colorViolation{
{file: "b.ninjatpl", line: 12, rule: "no-hardcoded-rgb", snippet: "rgba(1,2,3,0.5)"},
{file: "a.ninjatpl", line: 9, rule: "no-hardcoded-hsl", snippet: "hsl(10,20%,30%)"},
{file: "a.ninjatpl", line: 9, rule: "no-tw-arbitrary-color", snippet: "text-[hsl(10,20%,30%)]"},
{file: "a.ninjatpl", line: 4, rule: "no-inline-hex", snippet: "#fff"},
})
expected := strings.Join([]string{
" FAIL: 4 hardcoded color(s) in .ninjatpl files:",
" a.ninjatpl",
" 4 [no-inline-hex] #fff",
" 9 [no-hardcoded-hsl] hsl(10,20%,30%)",
" 9 [no-tw-arbitrary-color] text-[hsl(10,20%,30%)]",
" b.ninjatpl",
" 12 [no-hardcoded-rgb] rgba(1,2,3,0.5)",
}, "\n")
if report != expected {
t.Fatalf("formatColorViolations() mismatch\nwant:\n%s\n\ngot:\n%s", expected, report)
}
}
func TestCollectColorHintsDeduplicatesAndSorts(t *testing.T) {
hints := collectColorHints(
[]colorViolation{
{snippet: "text-blue-500"},
{snippet: "bg-amber-100"},
},
[]colorViolation{
{snippet: "text-blue-500"},
{snippet: "text-[hsl(38,52%,54%)]"},
},
)
expected := []string{"bg-warning/10", "hsl(var(--token))", "text-info"}
if strings.Join(hints, "|") != strings.Join(expected, "|") {
t.Fatalf("collectColorHints() = %#v, want %#v", hints, expected)
}
}

285
comingsoon.go Normal file
View File

@ -0,0 +1,285 @@
package main
import (
"bufio"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"git.dev.alexdunmow.com/block/check-safety/internal/helpers"
)
type comingSoonViolation struct {
file string
line int
snippet string
}
var comingSoonAllowedFiles = map[string]bool{
"components/pages/system-pages-list.tsx": true, // Real system page type
"components/settings/ai-action-matrix.tsx": true, // AI task for site status copy
"components/settings/site-settings-form.tsx": true, // Settings navigation text
"components/settings/site-status-section.tsx": true, // Legitimate Site Status feature UI
"lib/nav-items.ts": true, // Admin navigation metadata
"routes/admin/pages.tsx": true, // System page type enum wiring
"lib/api/gen/blockninja/v1/pages_pb.d.ts": true, // Generated proto enum SYSTEM_PAGE_TYPE_COMING_SOON
"lib/api/gen/blockninja/v1/settings_pb.d.ts": true, // Generated proto enum SITE_MODE_COMING_SOON
}
var placeholderCopyPatterns = []*regexp.Regexp{
regexp.MustCompile(`(?i)\bcoming[\s_-]+soon\b`),
regexp.MustCompile(`(?i)\bunder[\s_-]+construction\b`),
regexp.MustCompile(`(?i)\bwork[\s_-]+in[\s_-]+progress\b`),
regexp.MustCompile(`(?i)\bnot[\s_-]+yet[\s_-]+available\b`),
regexp.MustCompile(`(?i)\bavailable[\s_-]+soon\b`),
regexp.MustCompile(`(?i)\bcoming[\s_-]+later\b`),
}
var rePlaceholderComment = regexp.MustCompile(`(?i)\bplaceholder\b`)
var rePlaceholderStringLiteral = regexp.MustCompile("`[^`]*\\bplaceholder\\b[^`]*`|\"[^\"]*\\bplaceholder\\b[^\"]*\"|'[^']*\\bplaceholder\\b[^']*'")
func checkComingSoon(webSrcDir string) []comingSoonViolation {
var violations []comingSoonViolation
if err := filepath.Walk(webSrcDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if todoDirSkips[info.Name()] {
return filepath.SkipDir
}
return nil
}
ext := filepath.Ext(path)
if !todoFileExts[ext] {
return nil
}
if strings.HasSuffix(path, ".test.ts") || strings.HasSuffix(path, ".test.tsx") {
return nil
}
relPath, _ := filepath.Rel(webSrcDir, path)
if relPath == "" {
relPath = path
}
relPath = filepath.ToSlash(relPath)
if comingSoonAllowedFiles[relPath] {
return nil
}
f, openErr := os.Open(path)
if openErr != nil {
return nil
}
defer helpers.LogDeferredError(nil, "close coming soon scan file", f.Close, "path", path)
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if isCommentLine(trimmed) || !containsPlaceholderCopy(line) {
continue
}
violations = append(violations, comingSoonViolation{
file: relPath,
line: lineNum,
snippet: strings.TrimSpace(line),
})
}
return nil
}); err != nil {
violations = append(violations, comingSoonViolation{
file: filepath.ToSlash(webSrcDir),
snippet: err.Error(),
})
}
sort.Slice(violations, func(i, j int) bool {
if violations[i].file == violations[j].file {
return violations[i].line < violations[j].line
}
return violations[i].file < violations[j].file
})
return violations
}
func checkPlaceholderLanguage(roots []todoScanRoot) []comingSoonViolation {
var violations []comingSoonViolation
for _, scanRoot := range roots {
if scanRoot.root == "" {
continue
}
if err := filepath.Walk(scanRoot.root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if todoDirSkips[info.Name()] {
return filepath.SkipDir
}
return nil
}
ext := filepath.Ext(path)
if ext != ".go" && ext != ".tsx" {
return nil
}
if strings.HasSuffix(path, "_test.go") || strings.HasSuffix(path, ".test.tsx") {
return nil
}
if strings.Contains(filepath.ToSlash(path), "cmd/check-safety/") {
return nil
}
relPath, _ := filepath.Rel(scanRoot.root, path)
if relPath == "" {
relPath = path
}
relPath = filepath.ToSlash(relPath)
if isAllowedPlaceholderFile(relPath) {
return nil
}
if scanRoot.displayPrefix != "" {
relPath = scanRoot.displayPrefix + "/" + relPath
}
if isAllowedPlaceholderFile(relPath) {
return nil
}
f, openErr := os.Open(path)
if openErr != nil {
return nil
}
defer helpers.LogDeferredError(nil, "close placeholder scan file", f.Close, "path", path)
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if !containsPlaceholderLanguage(line, trimmed) {
continue
}
violations = append(violations, comingSoonViolation{
file: relPath,
line: lineNum,
snippet: trimmed,
})
}
return nil
}); err != nil {
violations = append(violations, comingSoonViolation{
file: filepath.ToSlash(scanRoot.root),
snippet: err.Error(),
})
}
}
sort.Slice(violations, func(i, j int) bool {
if violations[i].file == violations[j].file {
return violations[i].line < violations[j].line
}
return violations[i].file < violations[j].file
})
return violations
}
func containsPlaceholderLanguage(line string, trimmed string) bool {
lowerLine := strings.ToLower(line)
if strings.Contains(lowerLine, "placeholder=") || strings.Contains(lowerLine, "placeholder:") || strings.Contains(lowerLine, "=placeholder") {
return false
}
if strings.Contains(lowerLine, `.placeholder`) ||
strings.Contains(lowerLine, `["placeholder"]`) ||
strings.Contains(lowerLine, `['placeholder']`) ||
strings.Contains(lowerLine, `json:"placeholder`) ||
strings.Contains(lowerLine, `data-[placeholder]`) {
return false
}
if isPlaceholderCommentLine(trimmed) && rePlaceholderComment.MatchString(line) {
lower := strings.ToLower(trimmed)
if strings.Contains(lower, "render a placeholder") ||
strings.Contains(lower, "renders a placeholder") ||
strings.Contains(lower, "show placeholder") ||
strings.Contains(lower, "show empty placeholder") ||
strings.Contains(lower, "slug placeholder") ||
strings.Contains(lower, "variable placeholder") ||
strings.Contains(lower, "placeholder patterns") ||
strings.Contains(lower, `"placeholder"`) ||
strings.Contains(lower, "`placeholder`") ||
strings.Contains(lower, "placeholder with edit button") ||
strings.Contains(lower, "streaming placeholder") ||
strings.Contains(lower, "message placeholder") ||
strings.Contains(lower, "placeholder on error") ||
strings.Contains(lower, "empty placeholder") ||
strings.Contains(lower, "placeholder color") ||
strings.Contains(lower, "placeholder option") {
return false
}
return true
}
for _, match := range rePlaceholderStringLiteral.FindAllString(line, -1) {
literal := strings.TrimSpace(strings.Trim(match, "\"'`"))
if literal == "" {
continue
}
if strings.EqualFold(literal, "placeholder") {
continue
}
if strings.HasPrefix(strings.ToLower(literal), "placeholder-") {
continue
}
if strings.Contains(strings.ToLower(literal), "slot-placeholder") {
continue
}
switch strings.ToLower(literal) {
case "placeholder color", "placeholder text color":
continue
}
switch strings.ToLower(literal) {
case "placeholder content":
continue
}
return true
}
return false
}
func containsPlaceholderCopy(line string) bool {
for _, pattern := range placeholderCopyPatterns {
if pattern.MatchString(line) {
return true
}
}
return false
}
func isPlaceholderCommentLine(trimmed string) bool {
return strings.HasPrefix(trimmed, "//") ||
strings.HasPrefix(trimmed, "/*") ||
strings.HasPrefix(trimmed, "*") ||
strings.HasPrefix(trimmed, "{/*")
}
func isAllowedPlaceholderFile(relPath string) bool {
normalized := strings.TrimPrefix(filepath.ToSlash(relPath), "web/src/")
return comingSoonAllowedFiles[normalized]
}

334
comingsoon_test.go Normal file
View File

@ -0,0 +1,334 @@
package main
import (
"path/filepath"
"testing"
)
// ─── checkComingSoon ──────────────────────────────────────────────────────
func TestCheckComingSoon_ComingSoon_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "hero.tsx", `export function Hero() {
return <div>Coming Soon</div>
}
`)
vs := checkComingSoon(dir)
if len(vs) == 0 {
t.Fatal("expected violation for 'Coming Soon'")
}
if vs[0].file != "hero.tsx" {
t.Fatalf("expected file 'hero.tsx', got %q", vs[0].file)
}
}
func TestCheckComingSoon_UnderConstruction_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "page.tsx", `export function Page() {
return <p>Under Construction</p>
}
`)
vs := checkComingSoon(dir)
if len(vs) == 0 {
t.Fatal("expected violation for 'Under Construction'")
}
}
func TestCheckComingSoon_WorkInProgress_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "dash.tsx", `export function Dash() {
return <span>Work in Progress</span>
}
`)
vs := checkComingSoon(dir)
if len(vs) == 0 {
t.Fatal("expected violation for 'Work in Progress'")
}
}
func TestCheckComingSoon_NotYetAvailable_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "feature.tsx", `export function Feature() {
return <p>Not yet available</p>
}
`)
vs := checkComingSoon(dir)
if len(vs) == 0 {
t.Fatal("expected violation for 'Not yet available'")
}
}
func TestCheckComingSoon_AvailableSoon_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "feature.tsx", `export function Feature() {
return <p>Available soon</p>
}
`)
vs := checkComingSoon(dir)
if len(vs) == 0 {
t.Fatal("expected violation for 'Available soon'")
}
}
func TestCheckComingSoon_ComingLater_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "feature.tsx", `export function Feature() {
return <p>Coming later</p>
}
`)
vs := checkComingSoon(dir)
if len(vs) == 0 {
t.Fatal("expected violation for 'Coming later'")
}
}
func TestCheckComingSoon_CaseInsensitive(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "feature.tsx", `export function Feature() {
return <p>COMING SOON</p>
}
`)
vs := checkComingSoon(dir)
if len(vs) == 0 {
t.Fatal("expected case-insensitive match for 'COMING SOON'")
}
}
func TestCheckComingSoon_CommentLine_NotFlagged(t *testing.T) {
// isCommentLine() skips lines starting with //, *, /*
dir := t.TempDir()
writeFile(t, dir, "feature.tsx", `// TODO: coming soon section
/* coming soon banner */
export function Feature() {
return <div>Real content</div>
}
`)
vs := checkComingSoon(dir)
if len(vs) != 0 {
t.Fatalf("expected comment lines to be skipped, got %d violations: %#v", len(vs), vs)
}
}
func TestCheckComingSoon_GoFile_Flagged(t *testing.T) {
// checkComingSoon uses todoFileExts which includes .go
dir := t.TempDir()
writeFile(t, dir, "handler.go", `package handler
const pageTitle = "Coming Soon"
`)
vs := checkComingSoon(dir)
if len(vs) == 0 {
t.Fatal("expected violation for 'Coming Soon' in .go file")
}
}
func TestCheckComingSoon_TestTSXFile_Skipped(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "comp.test.tsx", `// test: coming soon banner
describe("coming soon", () => {})
`)
vs := checkComingSoon(dir)
if len(vs) != 0 {
t.Fatalf("expected .test.tsx to be skipped, got %d violations", len(vs))
}
}
func TestCheckComingSoon_TestTSFile_Skipped(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "page.test.ts", `describe("coming soon", () => {})
`)
vs := checkComingSoon(dir)
if len(vs) != 0 {
t.Fatalf("expected .test.ts to be skipped, got %d violations", len(vs))
}
}
func TestCheckComingSoon_NonCodeFile_Skipped(t *testing.T) {
// .md not in todoFileExts
dir := t.TempDir()
writeFile(t, dir, "ROADMAP.md", `## Coming Soon
- Feature A
`)
vs := checkComingSoon(dir)
if len(vs) != 0 {
t.Fatalf("expected .md file to be skipped, got %d violations", len(vs))
}
}
func TestCheckComingSoon_AllowedFile_NotFlagged(t *testing.T) {
// comingSoonAllowedFiles contains specific web/src relative paths;
// checkComingSoon strips to relative from webSrcDir — so the file's relPath
// must match the allowlist key exactly.
dir := t.TempDir()
// "components/pages/system-pages-list.tsx" is an allowed file
allowedDir := filepath.Join(dir, "components", "pages")
writeTestFile(t, filepath.Join(allowedDir, "system-pages-list.tsx"), `export function SystemPages() {
return <li>Coming Soon</li>
}
`, 0644)
vs := checkComingSoon(dir)
if len(vs) != 0 {
t.Fatalf("expected allowed file to be exempt, got %d violations: %#v", len(vs), vs)
}
}
func TestCheckComingSoon_NodeModules_Skipped(t *testing.T) {
dir := t.TempDir()
nmDir := filepath.Join(dir, "node_modules", "some-lib")
writeTestFile(t, filepath.Join(nmDir, "index.ts"), `export const msg = "Coming Soon"
`, 0644)
vs := checkComingSoon(dir)
if len(vs) != 0 {
t.Fatalf("expected node_modules to be skipped, got %d violations", len(vs))
}
}
func TestCheckComingSoon_SortedByFileAndLine(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "z.tsx", `export function Z() {
return <p>Coming Soon</p>
}
`)
writeFile(t, dir, "a.tsx", `export function A() {
return <div>Available soon</div>
}
`)
vs := checkComingSoon(dir)
if len(vs) < 2 {
t.Fatalf("expected at least 2 violations, got %d", len(vs))
}
if vs[0].file > vs[1].file {
t.Fatalf("violations should be sorted by file alphabetically, got %q then %q", vs[0].file, vs[1].file)
}
}
// ─── checkPlaceholderLanguage ─────────────────────────────────────────────
func scanRoots(root string) []todoScanRoot {
return []todoScanRoot{{root: root}}
}
func TestCheckPlaceholderLanguage_StringLiteralWithPlaceholder_Flagged(t *testing.T) {
dir := t.TempDir()
// A string literal containing lowercase "placeholder" as meaningful content (not an attribute).
// Note: rePlaceholderStringLiteral is case-sensitive — "placeholder" (lowercase) matches;
// "Placeholder" (initial cap) does NOT match.
writeFile(t, dir, "comp.tsx", `export function Comp() {
const label = "placeholder content here"
return <div>{label}</div>
}
`)
vs := checkPlaceholderLanguage(scanRoots(dir))
if len(vs) == 0 {
t.Fatalf("expected placeholder language violation for lowercase 'placeholder' string literal, got none")
}
}
func TestCheckPlaceholderLanguage_HTMLAttribute_NotFlagged(t *testing.T) {
// placeholder= is an HTML attribute — explicitly excluded
dir := t.TempDir()
writeFile(t, dir, "input.tsx", `export function Input() {
return <input placeholder="Enter your name" />
}
`)
vs := checkPlaceholderLanguage(scanRoots(dir))
if len(vs) != 0 {
t.Fatalf("expected HTML placeholder= attribute to be skipped, got %d violations: %#v", len(vs), vs)
}
}
func TestCheckPlaceholderLanguage_GoFile_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "handler.go", `package handler
// This is a "Placeholder feature"
func PlaceholderHandler() {}
`)
vs := checkPlaceholderLanguage(scanRoots(dir))
if len(vs) == 0 {
t.Fatalf("expected placeholder violation in .go file")
}
}
func TestCheckPlaceholderLanguage_TestGoFile_Skipped(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "handler_test.go", `package handler
// "Placeholder content"
func TestFoo(t *testing.T) {}
`)
vs := checkPlaceholderLanguage(scanRoots(dir))
if len(vs) != 0 {
t.Fatalf("expected _test.go to be skipped, got %d violations", len(vs))
}
}
func TestCheckPlaceholderLanguage_NonGoTSXFile_Skipped(t *testing.T) {
// checkPlaceholderLanguage only scans .go and .tsx
dir := t.TempDir()
writeFile(t, dir, "content.html", `<div>"Placeholder content"</div>
`)
vs := checkPlaceholderLanguage(scanRoots(dir))
if len(vs) != 0 {
t.Fatalf("expected .html file to be skipped by checkPlaceholderLanguage, got %d violations", len(vs))
}
}
func TestCheckPlaceholderLanguage_EmptyRoot_Skipped(t *testing.T) {
vs := checkPlaceholderLanguage([]todoScanRoot{{root: ""}})
if len(vs) != 0 {
t.Fatalf("expected no violations for empty root, got %d", len(vs))
}
}
func TestCheckPlaceholderLanguage_LiteralJustPlaceholder_NotFlagged(t *testing.T) {
// The word "placeholder" alone (without additional content) is excluded
dir := t.TempDir()
writeFile(t, dir, "comp.tsx", `export function Comp() {
const type = "placeholder"
return <div data-type={type} />
}
`)
vs := checkPlaceholderLanguage(scanRoots(dir))
if len(vs) != 0 {
t.Fatalf("bare 'placeholder' string should not be flagged, got %d violations: %#v", len(vs), vs)
}
}
func TestCheckPlaceholderLanguage_PropertyAccess_NotFlagged(t *testing.T) {
// .placeholder (CSS property access) should not be flagged
dir := t.TempDir()
writeFile(t, dir, "styles.tsx", `const styles = {
color: input.placeholder,
}
`)
vs := checkPlaceholderLanguage(scanRoots(dir))
if len(vs) != 0 {
t.Fatalf("expected .placeholder property access to be skipped, got %d violations: %#v", len(vs), vs)
}
}
func TestCheckPlaceholderLanguage_DisplayPrefix(t *testing.T) {
dir := t.TempDir()
// rePlaceholderStringLiteral is case-sensitive — use lowercase "placeholder"
writeFile(t, dir, "comp.tsx", `const x = "placeholder content here"
`)
vs := checkPlaceholderLanguage([]todoScanRoot{{root: dir, displayPrefix: "web/src"}})
if len(vs) == 0 {
t.Fatal("expected violation")
}
if vs[0].file != "web/src/comp.tsx" {
t.Fatalf("expected prefixed path 'web/src/comp.tsx', got %q", vs[0].file)
}
}
func TestCheckPlaceholderLanguage_TestTSXFile_Skipped(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "comp.test.tsx", `// "Placeholder content"
describe("test", () => {})
`)
vs := checkPlaceholderLanguage(scanRoots(dir))
if len(vs) != 0 {
t.Fatalf("expected .test.tsx to be skipped, got %d violations", len(vs))
}
}

128
errleak.go Normal file
View File

@ -0,0 +1,128 @@
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"slices"
"strings"
)
type errLeakViolation struct {
file string
line int
snippet string
}
// checkErrLeak finds places where err.Error() is sent directly to HTTP clients.
// These leak internal details. Use writeInternalServerError/writeServerError instead.
func checkErrLeak(root string) []errLeakViolation {
var violations []errLeakViolation
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
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil
}
relPath, _ := filepath.Rel(root, path)
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
// Pattern 1: http.Error(w, err.Error(), ...)
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
if ident, ok := sel.X.(*ast.Ident); ok && ident.Name == "http" && sel.Sel.Name == "Error" {
if len(call.Args) >= 2 && containsErrorCall(call.Args[1]) {
pos := fset.Position(call.Pos())
violations = append(violations, errLeakViolation{
file: relPath,
line: pos.Line,
snippet: "http.Error(w, err.Error(), ...) \u2014 leaks internal error",
})
}
}
}
// Pattern 2: fmt.Fprintf(w, ... err.Error() ...)
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
if ident, ok := sel.X.(*ast.Ident); ok && ident.Name == "fmt" {
if sel.Sel.Name == "Fprintf" || sel.Sel.Name == "Fprintln" {
if len(call.Args) > 1 {
if slices.ContainsFunc(call.Args[1:], containsErrorCall) {
pos := fset.Position(call.Pos())
violations = append(violations, errLeakViolation{
file: relPath,
line: pos.Line,
snippet: fmt.Sprintf("fmt.%s(w, ...err.Error()...) \u2014 leaks internal error", sel.Sel.Name),
})
}
}
}
}
}
// Pattern 3: w.Write([]byte(err.Error()))
if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
if sel.Sel.Name == "Write" && len(call.Args) == 1 {
if containsErrorCall(call.Args[0]) {
pos := fset.Position(call.Pos())
violations = append(violations, errLeakViolation{
file: relPath,
line: pos.Line,
snippet: "w.Write([]byte(err.Error())) \u2014 leaks internal error",
})
}
}
}
return true
})
return nil
}); err != nil {
violations = append(violations, errLeakViolation{
file: filepath.ToSlash(root),
line: 0,
snippet: err.Error(),
})
}
return violations
}
// containsErrorCall checks if an expression contains a call to .Error() on any receiver identifier.
func containsErrorCall(expr ast.Expr) bool {
found := false
ast.Inspect(expr, func(n ast.Node) bool {
if found {
return false
}
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Error" {
return true
}
if _, ok := sel.X.(*ast.Ident); ok {
found = true
}
return true
})
return found
}

181
errleak_test.go Normal file
View File

@ -0,0 +1,181 @@
package main
import (
"testing"
)
func TestCheckErrLeak_HttpErrorWithErrError(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "handler.go", `package handler
import "net/http"
func handle(w http.ResponseWriter, r *http.Request) {
err := doSomething()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
`)
vs := checkErrLeak(dir)
if len(vs) == 0 {
t.Fatal("expected errleak violation for http.Error(w, err.Error(), ...)")
}
found := false
for _, v := range vs {
if v.file == "handler.go" {
found = true
}
}
if !found {
t.Fatalf("expected violation in handler.go, got %#v", vs)
}
}
func TestCheckErrLeak_FmtFprintfWithErrError(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "handler.go", `package handler
import "fmt"
func handle(w interface{}, err error) {
fmt.Fprintf(w, "error: %s", err.Error())
}
`)
vs := checkErrLeak(dir)
if len(vs) == 0 {
t.Fatal("expected errleak violation for fmt.Fprintf(w, ..., err.Error())")
}
}
func TestCheckErrLeak_FmtFprintlnWithErrError(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "handler.go", `package handler
import "fmt"
func handle(w interface{}, err error) {
fmt.Fprintln(w, err.Error())
}
`)
vs := checkErrLeak(dir)
if len(vs) == 0 {
t.Fatal("expected errleak violation for fmt.Fprintln(w, err.Error())")
}
}
func TestCheckErrLeak_WriteWithErrError(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "handler.go", `package handler
func handle(w interface{ Write([]byte) (int, error) }, err error) {
w.Write([]byte(err.Error()))
}
`)
vs := checkErrLeak(dir)
if len(vs) == 0 {
t.Fatal("expected errleak violation for w.Write([]byte(err.Error()))")
}
}
func TestCheckErrLeak_SafeErrorHelpers_Clean(t *testing.T) {
// Using writeInternalServerError helper — should NOT flag
dir := t.TempDir()
writeFile(t, dir, "handler.go", `package handler
func handle(w http.ResponseWriter, r *http.Request) {
err := doSomething()
if err != nil {
writeInternalServerError(w, err, "handle request")
}
}
`)
vs := checkErrLeak(dir)
if len(vs) != 0 {
t.Fatalf("expected no violations for safe helper usage, got %d: %#v", len(vs), vs)
}
}
func TestCheckErrLeak_HttpErrorWithLiteralMessage_Clean(t *testing.T) {
// http.Error with a literal string — NOT a leak
dir := t.TempDir()
writeFile(t, dir, "handler.go", `package handler
import "net/http"
func handle(w http.ResponseWriter, r *http.Request) {
http.Error(w, "internal server error", http.StatusInternalServerError)
}
`)
vs := checkErrLeak(dir)
if len(vs) != 0 {
t.Fatalf("expected no violations for http.Error with literal message, got %d: %#v", len(vs), vs)
}
}
func TestCheckErrLeak_TestFile_Skipped(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "handler_test.go", `package handler
import "net/http"
func TestHandle(t *testing.T) {
http.Error(w, err.Error(), 500)
}
`)
vs := checkErrLeak(dir)
if len(vs) != 0 {
t.Fatalf("expected test files to be skipped, got %d violations", len(vs))
}
}
func TestCheckErrLeak_RelativeFilePath(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "leak.go", `package handler
import "net/http"
func f(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), 500)
}
`)
vs := checkErrLeak(dir)
if len(vs) == 0 {
t.Fatal("expected violation")
}
if vs[0].file != "leak.go" {
t.Fatalf("expected relative file 'leak.go', got %q", vs[0].file)
}
}
func TestCheckErrLeak_NonGoFile_Skipped(t *testing.T) {
dir := t.TempDir()
// Write something that looks like an errleak pattern but in a .ts file
writeFile(t, dir, "handler.ts", `// err.Error() leaks
http.Error(w, err.Error(), 500)
`)
vs := checkErrLeak(dir)
if len(vs) != 0 {
t.Fatalf("expected no violations for non-.go file, got %d", len(vs))
}
}
func TestCheckErrLeak_SnippetContainsPattern(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "handler.go", `package handler
import "net/http"
func handle(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusBadRequest)
}
`)
vs := checkErrLeak(dir)
if len(vs) == 0 {
t.Fatal("expected violation")
}
// snippet should mention the pattern name
if vs[0].snippet == "" {
t.Fatal("expected non-empty snippet")
}
}

379
frontend.go Normal file
View File

@ -0,0 +1,379 @@
package main
import (
"os"
"path/filepath"
"regexp"
"strings"
)
type frontendViolation struct {
file string
line int
rule string
snippet string
}
// Future enhancement (FUTURE-WO-012-ENFORCE): Add a lint rule that requires
// every string literal in a JSX text node to be wrapped in t(). This was
// intentionally omitted from WO-012 to avoid breaking all in-flight WOs that
// have untranslated strings. The scaffolding (i18next init, en.json catalog,
// t() + useTranslation exports) was shipped in WO-012; strict enforcement is
// deferred to a follow-up work order.
// Files/dirs where fetch() and manual clients are allowed.
// Each entry has a documented reason — add new entries only with justification.
var allowedFrontendFiles = map[string]bool{
"lib/transport.ts": true, // Global transport setup
"lib/api/transport.ts": true, // Orchestrator global transport setup
"lib/token-refresh.ts": true, // Auth bootstrap client (avoids interceptor loop)
"lib/sync/sync-engine.ts": true, // Jotai sync — lives outside React, can't use hooks
"components/media-library/media-upload-area.tsx": true, // Multipart file upload via FormData
"components/html-editor/preview-iframe.tsx": true, // Block HTML preview (returns HTML, not proto)
"lib/editor-bridge/iframe-script.ts": true, // Editor iframe preview
"player/human-proof/index.ts": true, // Standalone player bundle — no React, fetches public REST endpoint
// createClient exceptions — each has a technical reason why useQuery/useMutation can't be used:
"hooks/use-entity-search.ts": true, // useQueries for parallel search — connect-query doesn't support this
"routes/admin/plugins.tsx": true, // Server-streaming (for await...of) — useMutation doesn't support streaming
"hooks/use-restart-operation.ts": true, // Server-streaming + raw Connect envelope for cross-origin orchestrator stream
"routes/admin/menus.tsx": true, // Imperative async callback in useCallback
"components/data-platform/tables/creation-wizard.tsx": true, // useMutation has serialization bug with oneof fields
}
// Plugin web files that are allowed to call plugin-owned REST handlers because
// there is no ConnectRPC surface for them yet. Keep this list narrow.
var allowedPluginRESTFiles = map[string]bool{
"backend/internal/plugins/calcomblock/web/settings.tsx": true, // Plugin settings/test endpoints are mounted via HTTPHandler; no generated hooks exist yet
"backend/internal/plugins/calcomblock/web/editor.tsx": true, // Block editor reads plugin /settings + /event-types via HTTPHandler routes
}
// Specific fetch paths that are non-proto REST endpoints (no ConnectRPC equivalent)
// These get a WARN, not a FAIL
var knownNonProtoFetches = map[string]bool{
"/api/plugins/": true, // Plugin REST APIs
"/api/mcp/": true, // MCP device auth flow
"/api/lists/subscribe": true, // Public subscribe endpoint
"/preview/": true, // Preview HTML endpoints
"/api/ai/chat/stream": true, // AI chat SSE streaming (EventSource/fetch — ConnectRPC doesn't support SSE)
}
var (
// Anti-pattern: importing axios
reAxios = regexp.MustCompile(`(?:import|require).*['"]axios['"]`)
// Anti-pattern: useQueryClient (should use refetch() instead)
reUseQueryClient = regexp.MustCompile(`useQueryClient\s*\(`)
// Anti-pattern in plugins: QueryClientProvider is already mounted by core
reQueryClientProvider = regexp.MustCompile(`\bQueryClientProvider\b`)
// Anti-pattern: createClient from connectrpc (should use generated hooks)
reCreateClient = regexp.MustCompile(`createClient\s*\(`)
// Anti-pattern: fetch() to /api/ or proto paths (should use ConnectRPC)
reFetchAPI = regexp.MustCompile(`fetch\s*\(\s*['\x60"/]`)
// Anti-pattern: createConnectTransport (only allowed in transport.ts)
reCreateTransport = regexp.MustCompile(`createConnectTransport\s*\(`)
// Anti-pattern: new XMLHttpRequest
reXHR = regexp.MustCompile(`new\s+XMLHttpRequest`)
// Anti-pattern: importing from @connectrpc/connect-web directly to create clients
reConnectWebImport = regexp.MustCompile(`from\s+['"]@connectrpc/connect-web['"]`)
// Anti-pattern: browser confirm() instead of the shared confirm dialog
reWindowConfirm = regexp.MustCompile(`\bwindow\.confirm\s*\(`)
reBareConfirm = regexp.MustCompile(`(?:^|[^\w.])confirm\s*\(`)
)
func checkFrontend(webSrcDir string) (violations []frontendViolation, warnings []frontendViolation) {
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" && ext != ".js" && ext != ".jsx" {
return nil
}
// Skip node_modules and build output.
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") {
return nil
}
// Skip test/spec files — they legitimately use createClient/transport and
// fetch mocks to exercise the transport layer and are never shipped UI.
// (Matches the convention the coming-soon and TODO checks already follow.)
if strings.HasSuffix(path, ".test.ts") || strings.HasSuffix(path, ".test.tsx") ||
strings.HasSuffix(path, ".spec.ts") || strings.HasSuffix(path, ".spec.tsx") {
return nil
}
relPath, _ := filepath.Rel(webSrcDir, path)
if relPath == "" {
relPath = path
}
// Check if this file is in the allow list
isAllowed := false
for allowed := range allowedFrontendFiles {
if strings.HasSuffix(relPath, allowed) || relPath == allowed {
isAllowed = true
break
}
}
lines, err := readSourceLines(path)
if err != nil {
return nil
}
for idx, line := range lines {
lineNum := idx + 1
trimmed := strings.TrimSpace(line)
if isCommentLine(trimmed) {
continue
}
// Check axios
if reAxios.MatchString(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-axios",
snippet: strings.TrimSpace(line),
})
}
// Check useQueryClient
if reUseQueryClient.MatchString(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-useQueryClient",
snippet: strings.TrimSpace(line),
})
}
// Check XMLHttpRequest
if reXHR.MatchString(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-xhr",
snippet: strings.TrimSpace(line),
})
}
if isBrowserConfirmCall(lines, idx) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-browser-confirm",
snippet: strings.TrimSpace(line),
})
}
// For allowed files, skip the remaining checks
if isAllowed {
continue
}
// Check createClient (only outside allowed files)
if reCreateClient.MatchString(line) {
// Check if it's a documented exception (streaming, oneof bug, etc.)
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-createClient",
snippet: strings.TrimSpace(line),
})
}
// Check createConnectTransport
if reCreateTransport.MatchString(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-createConnectTransport",
snippet: strings.TrimSpace(line),
})
}
// Check @connectrpc/connect-web imports (manual client creation)
if reConnectWebImport.MatchString(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-connect-web-import",
snippet: strings.TrimSpace(line),
})
}
// Check fetch() calls to /api/ or proto RPC paths
if reFetchAPI.MatchString(line) {
isProtoFetch := strings.Contains(line, "/blockninja.v1.")
isAPIFetch := strings.Contains(line, "/api/")
if isProtoFetch {
// fetch() to a proto RPC endpoint — should always use ConnectRPC client
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-fetch-proto",
snippet: strings.TrimSpace(line),
})
} else if isAPIFetch {
// Check if this is a known non-proto endpoint (warn, not fail)
isKnownNonProto := false
for prefix := range knownNonProtoFetches {
if strings.Contains(line, prefix) {
isKnownNonProto = true
break
}
}
if isKnownNonProto {
warnings = append(warnings, frontendViolation{
file: relPath, line: lineNum, rule: "fetch-non-proto-api",
snippet: strings.TrimSpace(line),
})
} else {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-fetch-api",
snippet: strings.TrimSpace(line),
})
}
}
}
}
return nil
}); err != nil {
warnings = append(warnings, frontendViolation{
file: filepath.ToSlash(webSrcDir),
line: 0,
rule: "walk-error",
snippet: err.Error(),
})
}
return violations, warnings
}
// checkPluginPages scans plugin web page directories for plugin-specific frontend
// anti-patterns. Plugins must use useQuery/useMutation with { transport } options
// (AGENT-GUIDE §7.2c) — createClient+useMemo is forbidden. Plugins must also
// never use raw fetch() and must not mount their own QueryClientProvider because
// core already provides it.
func checkPluginPages(dirs []string) []frontendViolation {
var violations []frontendViolation
for _, dir := range dirs {
if err := filepath.Walk(dir, 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(dir, path)
if relPath == "" {
relPath = path
}
pluginRESTAllowed := pluginRESTFileAllowed(path)
lines, openErr := readSourceLines(path)
if openErr != nil {
return nil
}
for idx, line := range lines {
lineNum := idx + 1
trimmed := strings.TrimSpace(line)
if isCommentLine(trimmed) {
continue
}
if reFetchAPI.MatchString(line) && !pluginRESTAllowed {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-fetch-in-plugin",
snippet: strings.TrimSpace(line),
})
}
if reQueryClientProvider.MatchString(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-queryclientprovider-in-plugin",
snippet: "remove QueryClientProvider; BlockNinja core provides it.",
})
}
if reCreateClient.MatchString(line) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-createClient-in-plugin",
snippet: strings.TrimSpace(line),
})
}
if isBrowserConfirmCall(lines, idx) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-browser-confirm-in-plugin",
snippet: strings.TrimSpace(line),
})
}
}
return nil
}); err != nil {
violations = append(violations, frontendViolation{
file: filepath.ToSlash(dir),
line: 0,
rule: "walk-error",
snippet: err.Error(),
})
}
}
return violations
}
func pluginRESTFileAllowed(path string) bool {
slashPath := filepath.ToSlash(path)
for allowed := range allowedPluginRESTFiles {
if strings.HasSuffix(slashPath, allowed) {
return true
}
}
return false
}
func readSourceLines(path string) ([]string, error) {
content, err := os.ReadFile(path)
if err != nil {
return nil, err
}
return strings.Split(string(content), "\n"), nil
}
func isCommentLine(trimmed string) bool {
return strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "/*")
}
func isBrowserConfirmCall(lines []string, idx int) bool {
line := lines[idx]
if reWindowConfirm.MatchString(line) {
return true
}
if !reBareConfirm.MatchString(line) {
return false
}
start := max(idx-1, 0)
end := min(idx+8, len(lines)-1)
window := strings.Join(lines[start:end+1], " ")
window = strings.Join(strings.Fields(window), " ")
if strings.Contains(window, "await confirm(") {
return false
}
if strings.Contains(window, "confirm(") && strings.Contains(window, ").then(") {
return false
}
return true
}

238
frontend_extra.go Normal file
View File

@ -0,0 +1,238 @@
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/") {
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/") {
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/") {
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
}
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") {
return filepath.SkipDir
}
base := filepath.Base(path)
if base == "package-lock.json" || base == "yarn.lock" {
relPath, _ := filepath.Rel(repoRoot, path)
violations = append(violations, lockfileViolation{file: relPath})
}
return nil
}); err != nil {
violations = append(violations, lockfileViolation{file: filepath.ToSlash(repoRoot)})
}
return violations
}

610
frontend_extra_test.go Normal file
View File

@ -0,0 +1,610 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// ─────────────────────────────────────────────
// checkTabState
// ─────────────────────────────────────────────
// checkTabState only scans files under "routes/" within the webSrcDir.
func TestCheckTabState_FlagsUseStateWithTabInRoutes(t *testing.T) {
dir := t.TempDir()
routesDir := filepath.Join(dir, "routes")
if err := os.MkdirAll(routesDir, 0755); err != nil {
t.Fatalf("mkdir routes: %v", err)
}
writeFile(t, routesDir, "settings.tsx", `
import { useState } from 'react'
export function Settings() {
const [activeTab, setActiveTab] = useState('general')
return <Tabs value={activeTab} />
}
`)
vs := checkTabState(dir)
if len(vs) == 0 {
t.Fatal("expected violation for useState activeTab in routes/, got 0")
}
if vs[0].file != "routes/settings.tsx" {
t.Fatalf("expected file 'routes/settings.tsx', got %q", vs[0].file)
}
}
func TestCheckTabState_FlagsUseStateTabAlt(t *testing.T) {
dir := t.TempDir()
routesDir := filepath.Join(dir, "routes")
if err := os.MkdirAll(routesDir, 0755); err != nil {
t.Fatalf("mkdir routes: %v", err)
}
// Alt pattern: [tabValue, setTabValue] = useState(...)
writeFile(t, routesDir, "page.tsx", `
import { useState } from 'react'
export function Page() {
const [tabValue, setTabValue] = useState('first')
return <div />
}
`)
vs := checkTabState(dir)
if len(vs) == 0 {
t.Fatal("expected violation for tabValue useState alt pattern, got 0")
}
}
func TestCheckTabState_AllowsPreferTabs(t *testing.T) {
dir := t.TempDir()
routesDir := filepath.Join(dir, "routes")
if err := os.MkdirAll(routesDir, 0755); err != nil {
t.Fatalf("mkdir routes: %v", err)
}
// "preferTabs" is in tabStateIgnorePatterns — should be allowed
writeFile(t, routesDir, "settings.tsx", `
import { useState } from 'react'
export function Settings() {
const [preferTabs, setPreferTabs] = useState(true)
return <div />
}
`)
vs := checkTabState(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for preferTabs ignore pattern, got %d: %#v", len(vs), vs)
}
}
func TestCheckTabState_IgnoresNonRoutesFiles(t *testing.T) {
dir := t.TempDir()
// File is in components/ — not routes/, should be skipped
compDir := filepath.Join(dir, "components")
if err := os.MkdirAll(compDir, 0755); err != nil {
t.Fatalf("mkdir components: %v", err)
}
writeFile(t, compDir, "tabs.tsx", `
import { useState } from 'react'
export function MyTabs() {
const [activeTab, setActiveTab] = useState('a')
return <div />
}
`)
vs := checkTabState(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for useState in components/ (not routes/), got %d", len(vs))
}
}
func TestCheckTabState_IgnoresCommentedLines(t *testing.T) {
dir := t.TempDir()
routesDir := filepath.Join(dir, "routes")
if err := os.MkdirAll(routesDir, 0755); err != nil {
t.Fatalf("mkdir routes: %v", err)
}
writeFile(t, routesDir, "page.tsx", `
export function Page() {
// const [activeTab, setActiveTab] = useState('a')
return <div />
}
`)
vs := checkTabState(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for commented-out useState, got %d", len(vs))
}
}
func TestCheckTabState_IgnoresNonTSXFiles(t *testing.T) {
dir := t.TempDir()
routesDir := filepath.Join(dir, "routes")
if err := os.MkdirAll(routesDir, 0755); err != nil {
t.Fatalf("mkdir routes: %v", err)
}
writeFile(t, routesDir, "helpers.ts", `
const [activeTab, setActiveTab] = useState('a')
`)
vs := checkTabState(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for .ts file (not .tsx), got %d", len(vs))
}
}
func TestCheckTabState_CleanRouteFile(t *testing.T) {
dir := t.TempDir()
routesDir := filepath.Join(dir, "routes")
if err := os.MkdirAll(routesDir, 0755); err != nil {
t.Fatalf("mkdir routes: %v", err)
}
// Uses URL search params — correct pattern
writeFile(t, routesDir, "settings.tsx", `
import { useSearch } from '@tanstack/react-router'
export function Settings() {
const { tab } = useSearch({ from: '/settings' })
return <Tabs value={tab ?? 'general'} />
}
`)
vs := checkTabState(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for URL-based tab routing, got %d: %#v", len(vs), vs)
}
}
// ─────────────────────────────────────────────
// checkESLintDisableNextLine
// ─────────────────────────────────────────────
func TestCheckESLintDisableNextLine_FlagsInTSX(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "component.tsx", `
export function Foo() {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const x: any = {}
return <div>{x}</div>
}
`)
vs := checkESLintDisableNextLine(dir)
if len(vs) == 0 {
t.Fatal("expected violation for eslint-disable-next-line in .tsx, got 0")
}
if vs[0].line == 0 {
t.Error("expected non-zero line number")
}
}
func TestCheckESLintDisableNextLine_FlagsInTS(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "utils.ts", `
// eslint-disable-next-line prefer-const
var x = 5
`)
vs := checkESLintDisableNextLine(dir)
if len(vs) == 0 {
t.Fatal("expected violation for eslint-disable-next-line in .ts, got 0")
}
}
func TestCheckESLintDisableNextLine_FlagsInJS(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "script.js", `
// eslint-disable-next-line no-console
console.log('debug')
`)
vs := checkESLintDisableNextLine(dir)
if len(vs) == 0 {
t.Fatal("expected violation for eslint-disable-next-line in .js, got 0")
}
}
func TestCheckESLintDisableNextLine_CleanFile(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "clean.tsx", `
export function Foo() {
const x = 5
return <div>{x}</div>
}
`)
vs := checkESLintDisableNextLine(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for clean file, got %d: %#v", len(vs), vs)
}
}
func TestCheckESLintDisableNextLine_SkipsNodeModules(t *testing.T) {
dir := t.TempDir()
nmDir := filepath.Join(dir, "node_modules", "somepkg")
if err := os.MkdirAll(nmDir, 0755); err != nil {
t.Fatalf("mkdir node_modules: %v", err)
}
writeFile(t, nmDir, "index.ts", `
// eslint-disable-next-line no-console
console.log('pkg')
`)
vs := checkESLintDisableNextLine(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations (node_modules should be skipped), got %d", len(vs))
}
}
func TestCheckESLintDisableNextLine_MultipleViolations(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "messy.tsx", `
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const a: any = 1
// eslint-disable-next-line prefer-const
var b = 2
`)
vs := checkESLintDisableNextLine(dir)
if len(vs) < 2 {
t.Fatalf("expected at least 2 violations, got %d: %#v", len(vs), vs)
}
}
// ─────────────────────────────────────────────
// checkBareImports
// ─────────────────────────────────────────────
func TestCheckBareImports_FlagsBareAPIImport(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "component.tsx", `
import { AdminUser } from '@block-ninja/api'
export function MyComp() {
return <div />
}
`)
vs := checkBareImports(dir)
if len(vs) == 0 {
t.Fatal("expected violation for bare @block-ninja/api import, got 0")
}
if vs[0].line == 0 {
t.Error("expected non-zero line number")
}
}
func TestCheckBareImports_AllowsSubpathImport(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "component.tsx", `
import type { AdminUser } from '@block-ninja/api/types'
import { AdminService } from '@block-ninja/api/services'
import { listAdminUsers } from '@block-ninja/api/queries'
export function MyComp() {
return <div />
}
`)
vs := checkBareImports(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for subpath imports, got %d: %#v", len(vs), vs)
}
}
func TestCheckBareImports_SkipsNonTSTSX(t *testing.T) {
dir := t.TempDir()
// .js file — not scanned by checkBareImports
writeFile(t, dir, "script.js", `
import { something } from '@block-ninja/api'
`)
vs := checkBareImports(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for .js file (not .ts/.tsx), got %d", len(vs))
}
}
func TestCheckBareImports_SkipsNodeModules(t *testing.T) {
dir := t.TempDir()
nmDir := filepath.Join(dir, "node_modules", "@block-ninja", "ui")
if err := os.MkdirAll(nmDir, 0755); err != nil {
t.Fatalf("mkdir node_modules: %v", err)
}
writeFile(t, nmDir, "index.ts", `
import { something } from '@block-ninja/api'
`)
vs := checkBareImports(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations in node_modules, got %d", len(vs))
}
}
func TestCheckBareImports_CleanTSFile(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "util.ts", `
import type { AdminUser } from '@block-ninja/api/types'
export function transform(u: AdminUser): string {
return u.email
}
`)
vs := checkBareImports(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for clean .ts file, got %d: %#v", len(vs), vs)
}
}
// ─────────────────────────────────────────────
// checkLockfiles
// ─────────────────────────────────────────────
func TestCheckLockfiles_FlagsPackageLockJSON(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "package-lock.json", `{"lockfileVersion":2}`)
vs := checkLockfiles(dir)
if len(vs) == 0 {
t.Fatal("expected violation for package-lock.json, got 0")
}
found := false
for _, v := range vs {
if v.file == "package-lock.json" {
found = true
}
}
if !found {
t.Fatalf("expected 'package-lock.json' in violations, got: %#v", vs)
}
}
func TestCheckLockfiles_FlagsYarnLock(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "yarn.lock", `# yarn lockfile v1`)
vs := checkLockfiles(dir)
if len(vs) == 0 {
t.Fatal("expected violation for yarn.lock, got 0")
}
found := false
for _, v := range vs {
if v.file == "yarn.lock" {
found = true
}
}
if !found {
t.Fatalf("expected 'yarn.lock' in violations, got: %#v", vs)
}
}
func TestCheckLockfiles_AllowsPnpmLockYaml(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "pnpm-lock.yaml", `lockfileVersion: '9.0'`)
vs := checkLockfiles(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for pnpm-lock.yaml, got %d: %#v", len(vs), vs)
}
}
func TestCheckLockfiles_SkipsNodeModules(t *testing.T) {
dir := t.TempDir()
nmDir := filepath.Join(dir, "node_modules", "somepkg")
if err := os.MkdirAll(nmDir, 0755); err != nil {
t.Fatalf("mkdir node_modules: %v", err)
}
writeFile(t, nmDir, "package-lock.json", `{}`)
vs := checkLockfiles(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations (node_modules should be skipped), got %d", len(vs))
}
}
func TestCheckLockfiles_NoLockfile(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "package.json", `{"name":"test"}`)
vs := checkLockfiles(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for repo with no lockfiles, got %d", len(vs))
}
}
// ─────────────────────────────────────────────
// checkButtonAutomation
// ─────────────────────────────────────────────
func TestCheckButtonAutomation_FlagsButtonWithoutAction(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "page.tsx", `
export function Page() {
return (
<Button variant="default">Click Me</Button>
)
}
`)
vs := checkButtonAutomation(dir)
if len(vs) == 0 {
t.Fatal("expected violation for <Button> without action=, got 0")
}
}
func TestCheckButtonAutomation_AllowsButtonWithAction(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "page.tsx", `
export function Page() {
return (
<Button action="create" entity="user">Add User</Button>
)
}
`)
vs := checkButtonAutomation(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for <Button action=...>, got %d: %#v", len(vs), vs)
}
}
func TestCheckButtonAutomation_AllowsSubmitType(t *testing.T) {
dir := t.TempDir()
// type="submit" is exempt from the action= requirement
writeFile(t, dir, "form.tsx", `
export function MyForm() {
return (
<form>
<Button type="submit">Submit</Button>
</form>
)
}
`)
vs := checkButtonAutomation(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for type='submit' button, got %d: %#v", len(vs), vs)
}
}
func TestCheckButtonAutomation_AllowsAlertDialogAction(t *testing.T) {
dir := t.TempDir()
// AlertDialogAction doesn't support action= per CLAUDE.md
writeFile(t, dir, "confirm.tsx", `
export function Confirm() {
return (
<AlertDialogAction>Confirm Delete</AlertDialogAction>
)
}
`)
vs := checkButtonAutomation(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for AlertDialogAction, got %d: %#v", len(vs), vs)
}
}
func TestCheckButtonAutomation_SkipsUIComponents(t *testing.T) {
dir := t.TempDir()
uiDir := filepath.Join(dir, "components", "ui")
if err := os.MkdirAll(uiDir, 0755); err != nil {
t.Fatalf("mkdir components/ui: %v", err)
}
// UI primitive definitions — exempt from check
writeFile(t, uiDir, "button.tsx", `
import * as React from 'react'
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, ...props }, ref) => {
return <button className={cn(buttonVariants({ variant }), className)} ref={ref} {...props} />
}
)
Button.displayName = 'Button'
export { Button }
`)
vs := checkButtonAutomation(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for components/ui/ definition file, got %d: %#v", len(vs), vs)
}
}
func TestCheckButtonAutomation_SkipsCommentedButton(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "page.tsx", `
export function Page() {
return (
// <Button variant="default">Old button</Button>
<div>content</div>
)
}
`)
vs := checkButtonAutomation(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for commented-out Button, got %d: %#v", len(vs), vs)
}
}
func TestCheckButtonAutomation_MultiLineActionOnNextLine(t *testing.T) {
dir := t.TempDir()
// action= is on the next line — collectJSXTag lookahead should find it
writeFile(t, dir, "page.tsx", `
export function Page() {
return (
<Button
action="delete"
entity="block"
variant="destructive"
>
Delete
</Button>
)
}
`)
vs := checkButtonAutomation(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for multi-line <Button> with action= on next line, got %d: %#v", len(vs), vs)
}
}
func TestCheckButtonAutomation_SkipsNonTSXFiles(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "component.ts", `
// Not a TSX file — should be skipped
const x = '<Button variant="default">Click</Button>'
`)
vs := checkButtonAutomation(dir)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for non-.tsx file, got %d", len(vs))
}
}
// ─────────────────────────────────────────────
// collectJSXTag
// ─────────────────────────────────────────────
func TestCollectJSXTag_SingleLine(t *testing.T) {
lines := []string{`<Button action="create">Click</Button>`}
result := collectJSXTag(lines, 0)
if result == "" {
t.Fatal("collectJSXTag() returned empty string for single-line tag")
}
}
func TestCollectJSXTag_MultiLinePropExtraction(t *testing.T) {
lines := []string{
`<Button`,
` action="create"`,
` entity="user"`,
`>`,
` Save`,
`</Button>`,
}
result := collectJSXTag(lines, 0)
// Should contain action= because it's prop-level content
if !reButtonAction.MatchString(result) {
t.Fatalf("collectJSXTag() = %q, expected to contain 'action='", result)
}
}
func TestCollectJSXTag_StripsBraceExpressions(t *testing.T) {
lines := []string{
`<Button`,
` onClick={() => { doSomething() }}`,
` action="save"`,
`>`,
}
result := collectJSXTag(lines, 0)
// The brace expression is stripped but action= should still be present
if !reButtonAction.MatchString(result) {
t.Fatalf("collectJSXTag() = %q, expected 'action=' to be preserved outside braces", result)
}
}

109
frontend_test.go Normal file
View File

@ -0,0 +1,109 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestCheckPluginPagesFlagsQueryClientProvider(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "editor.tsx")
if err := os.WriteFile(file, []byte(`import { QueryClientProvider } from '@tanstack/react-query'
export function Editor() {
return <QueryClientProvider client={client}><div /></QueryClientProvider>
}
`), 0644); err != nil {
t.Fatalf("write editor.tsx: %v", err)
}
violations := checkPluginPages([]string{dir})
found := false
for _, v := range violations {
if v.rule != "no-queryclientprovider-in-plugin" {
continue
}
if v.snippet != "remove QueryClientProvider; BlockNinja core provides it." {
t.Fatalf("snippet = %q, want concise remediation", v.snippet)
}
found = true
break
}
if !found {
t.Fatalf("expected QueryClientProvider violation, got %#v", violations)
}
}
func TestCheckFrontendSkipsTestFiles(t *testing.T) {
dir := t.TempDir()
// A transport-layer regression test legitimately drives createClient over the
// real transport to exercise interceptors — it is not shipped UI. The frontend
// pattern checks must skip *.test.* / *.spec.* files, matching the convention
// the coming-soon and TODO checks already follow.
for _, name := range []string{"transport.test.ts", "auth.test.tsx", "util.spec.ts", "view.spec.tsx"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(`import { createClient } from "@connectrpc/connect";
import axios from "axios";
const client = createClient(AuthService, transport);
`), 0644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
violations, _ := checkFrontend(dir)
if len(violations) != 0 {
t.Fatalf("expected test/spec files to be skipped, got %d violation(s): %#v", len(violations), violations)
}
}
func TestIsBrowserConfirmCall(t *testing.T) {
tests := []struct {
lines []string
idx int
want bool
}{
{lines: []string{`if (window.confirm("delete?")) {`}, idx: 0, want: true},
{lines: []string{`if (confirm("delete?")) {`}, idx: 0, want: true},
{lines: []string{`const confirmed = await confirm({ title: "Delete" })`}, idx: 0, want: false},
{lines: []string{`confirm({ title: "Delete" }).then((confirmed) => {`}, idx: 0, want: false},
{
lines: []string{
`const confirmed = await`,
` confirm({`,
` title: "Delete",`,
` })`,
},
idx: 1,
want: false,
},
{
lines: []string{
`confirm({`,
` title: "Leave",`,
`}).then((confirmed) => {`,
},
idx: 0,
want: false,
},
}
for _, tt := range tests {
if got := isBrowserConfirmCall(tt.lines, tt.idx); got != tt.want {
t.Fatalf("isBrowserConfirmCall(%#v, %d) = %v, want %v", tt.lines, tt.idx, got, tt.want)
}
}
}
func TestPluginRESTFileAllowed(t *testing.T) {
if !pluginRESTFileAllowed("/tmp/repo/backend/internal/plugins/calcomblock/web/settings.tsx") {
t.Fatalf("expected documented calcom settings panel to be allowlisted")
}
if !pluginRESTFileAllowed("/tmp/repo/backend/internal/plugins/calcomblock/web/editor.tsx") {
t.Fatalf("expected documented calcom editor panel to be allowlisted")
}
if pluginRESTFileAllowed("/tmp/repo/backend/internal/plugins/calcomblock/web/dashboard.tsx") {
t.Fatalf("unexpected allowlist hit for non-allowlisted plugin file")
}
}

245
frontend_ui.go Normal file
View File

@ -0,0 +1,245 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"strings"
)
// --- Check: Button automation attributes ---
type buttonViolation struct {
file string
line int
snippet string
}
// Matches <Button without an action= prop on the same or adjacent lines
// We check for <Button that does NOT have action= on the same line
var reButtonOpen = regexp.MustCompile(`<Button\b`)
var reButtonAction = regexp.MustCompile(`\baction=`)
// reButtonSelfClose removed — no longer needed with lookahead approach
// Skip buttons that are type="submit" (action="submit" is implicit)
var reButtonSubmit = regexp.MustCompile(`type=["']submit["']`)
// Skip AlertDialogAction (doesn't support action prop per CLAUDE.md)
var reAlertDialogAction = regexp.MustCompile(`<AlertDialogAction`)
func checkButtonAutomation(webSrcDir string) []buttonViolation {
var violations []buttonViolation
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/") {
return nil
}
relPath, _ := filepath.Rel(webSrcDir, path)
// Skip UI primitive definitions (they define Button, not use it)
if strings.HasPrefix(relPath, "components/ui/") {
return nil
}
// Read entire file into lines for lookahead
data, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(string(data), "\n")
for i, line := range lines {
lineNum := i + 1
if !reButtonOpen.MatchString(line) {
continue
}
// Skip AlertDialogAction
if reAlertDialogAction.MatchString(line) {
continue
}
// Skip if line is a comment
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "{/*") {
continue
}
// Skip type="submit" buttons (implicit action)
if reButtonSubmit.MatchString(line) {
continue
}
// Collect the full opening tag into one string, strip {…}
// expressions, then check for action= in the flat props.
hasAction := reButtonAction.MatchString(line)
if !hasAction {
tag := collectJSXTag(lines, i)
hasAction = reButtonAction.MatchString(tag)
}
if hasAction {
continue
}
violations = append(violations, buttonViolation{
file: relPath,
line: lineNum,
snippet: strings.TrimSpace(line),
})
}
return nil
}); err != nil {
violations = append(violations, buttonViolation{
file: webSrcDir,
line: 0,
snippet: err.Error(),
})
}
return violations
}
// --- Check: Services in correct directories ---
type structureViolation struct {
file string
line int
rule string
snippet string
}
// collectJSXTag joins lines starting at idx into a single string,
// strips all {…} brace expressions (nested), and returns the flat
// prop-level content up to the closing >.
func collectJSXTag(lines []string, idx int) string {
var sb strings.Builder
depth := 0
for j := idx; j < len(lines) && j <= idx+50; j++ {
for _, ch := range lines[j] {
switch ch {
case '{':
depth++
case '}':
depth--
default:
if depth == 0 {
sb.WriteRune(ch)
}
}
}
// Check if tag closed at prop level
flat := sb.String()
if depth == 0 && j > idx && strings.Contains(flat, ">") {
break
}
sb.WriteRune(' ')
}
return sb.String()
}
// checkServiceStructure verifies the core backend layout only:
// service implementations live in internal/services/ and concrete handlers in
// internal/handlers/. External plugin repos commonly use a flatter layout, so
// this check intentionally skips roots that do not look like the core backend.
func checkServiceStructure(root string) []structureViolation {
var violations []structureViolation
if !usesCoreInternalLayout(root) {
return violations
}
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
}
if strings.HasSuffix(path, ".connect.go") || strings.HasSuffix(path, ".pb.go") || strings.HasSuffix(path, ".gen.go") {
return nil
}
relPath, _ := filepath.Rel(root, path)
relPath = filepath.ToSlash(relPath)
if strings.HasPrefix(relPath, "internal/api/") || strings.HasPrefix(relPath, "api/") {
return nil
}
// Skip expected locations
isServicesDir := strings.HasPrefix(relPath, "internal/services/")
isHandlersDir := strings.HasPrefix(relPath, "internal/handlers/")
isCmdDir := strings.HasPrefix(relPath, "cmd/")
if isServicesDir || isHandlersDir || isCmdDir {
return nil
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil
}
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.TYPE {
continue
}
for _, spec := range genDecl.Specs {
ts, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
name := ts.Name.Name
// Service types should be in internal/services/
if strings.HasSuffix(name, "Service") && !strings.HasPrefix(relPath, "internal/") {
pos := fset.Position(ts.Pos())
violations = append(violations, structureViolation{
file: relPath,
line: pos.Line,
rule: "service-location",
snippet: "type " + name + " — should be in internal/services/",
})
}
// Handler types should be in internal/handlers/
if strings.HasSuffix(name, "Handler") && !strings.HasPrefix(relPath, "internal/") {
pos := fset.Position(ts.Pos())
violations = append(violations, structureViolation{
file: relPath,
line: pos.Line,
rule: "handler-location",
snippet: "type " + name + " — should be in internal/handlers/",
})
}
}
}
return nil
}); err != nil {
violations = append(violations, structureViolation{
file: root,
line: 0,
rule: "walk-error",
snippet: err.Error(),
})
}
return violations
}
func usesCoreInternalLayout(root string) bool {
return dirExists(filepath.Join(root, "internal", "services")) ||
dirExists(filepath.Join(root, "internal", "handlers"))
}

370
frontend_ui_test.go Normal file
View File

@ -0,0 +1,370 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// ─────────────────────────────────────────────
// usesCoreInternalLayout
// ─────────────────────────────────────────────
// Guard fires when internal/services/ exists.
func TestUsesCoreInternalLayout_TrueWhenServicesExists(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "internal", "services"), 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if !usesCoreInternalLayout(root) {
t.Fatal("expected true when internal/services/ exists, got false")
}
}
// Guard fires when internal/handlers/ exists.
func TestUsesCoreInternalLayout_TrueWhenHandlersExists(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "internal", "handlers"), 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if !usesCoreInternalLayout(root) {
t.Fatal("expected true when internal/handlers/ exists, got false")
}
}
// Guard fires when BOTH directories exist.
func TestUsesCoreInternalLayout_TrueWhenBothExist(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "internal", "services"), 0755); err != nil {
t.Fatalf("mkdir services: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "internal", "handlers"), 0755); err != nil {
t.Fatalf("mkdir handlers: %v", err)
}
if !usesCoreInternalLayout(root) {
t.Fatal("expected true when both internal/services/ and internal/handlers/ exist")
}
}
// Guard returns false when neither directory exists.
func TestUsesCoreInternalLayout_FalseWhenNeitherExists(t *testing.T) {
root := t.TempDir()
// No internal/ tree at all — plugin-style flat layout.
if usesCoreInternalLayout(root) {
t.Fatal("expected false for empty root (no internal/{services,handlers}), got true")
}
}
// Only the exact subdirectories count — internal/models/ alone is not enough.
func TestUsesCoreInternalLayout_FalseForOtherInternalDirs(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "internal", "models"), 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
if usesCoreInternalLayout(root) {
t.Fatal("expected false when only internal/models/ exists (not services/ or handlers/)")
}
}
// A file named "services" is not a directory — should not satisfy the guard.
func TestUsesCoreInternalLayout_FalseWhenServicesIsAFile(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "internal"), 0755); err != nil {
t.Fatalf("mkdir internal: %v", err)
}
// Create a regular file called "services" (not a directory).
if err := os.WriteFile(filepath.Join(root, "internal", "services"), []byte("oops"), 0o644); err != nil {
t.Fatalf("write file: %v", err)
}
if usesCoreInternalLayout(root) {
t.Fatal("expected false when internal/services is a regular file, not a directory")
}
}
// ─────────────────────────────────────────────
// checkServiceStructure
// ─────────────────────────────────────────────
// Helper: builds a minimal fixture with internal/services/ (so the guard passes)
// then writes a .go file at the given relative path inside root.
func buildStructureFixture(t *testing.T, relGoPath, content string) string {
t.Helper()
root := t.TempDir()
// Satisfy the guard: create internal/services/
if err := os.MkdirAll(filepath.Join(root, "internal", "services"), 0755); err != nil {
t.Fatalf("mkdir internal/services: %v", err)
}
// Write the target Go file (create parent dirs as needed).
abs := filepath.Join(root, filepath.FromSlash(relGoPath))
if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(abs), err)
}
if err := os.WriteFile(abs, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", relGoPath, err)
}
return root
}
// A type ending with "Service" at the root (outside internal/) must be flagged.
func TestCheckServiceStructure_FlagsServiceTypeAtRoot(t *testing.T) {
root := buildStructureFixture(t, "myservice.go", `package main
type UserService struct{}
`)
vs := checkServiceStructure(root)
if len(vs) == 0 {
t.Fatal("expected violation for UserService outside internal/, got 0")
}
found := false
for _, v := range vs {
if v.rule == "service-location" {
found = true
}
}
if !found {
t.Fatalf("expected rule='service-location', got violations: %#v", vs)
}
}
// A type ending with "Handler" at the root (outside internal/) must be flagged.
func TestCheckServiceStructure_FlagsHandlerTypeAtRoot(t *testing.T) {
root := buildStructureFixture(t, "handler.go", `package main
type PageHandler struct{}
`)
vs := checkServiceStructure(root)
if len(vs) == 0 {
t.Fatal("expected violation for PageHandler outside internal/, got 0")
}
found := false
for _, v := range vs {
if v.rule == "handler-location" {
found = true
}
}
if !found {
t.Fatalf("expected rule='handler-location', got violations: %#v", vs)
}
}
// A file under internal/services/ is SKIPPED entirely — even a type there generates no violation.
func TestCheckServiceStructure_SkipsInternalServicesDir(t *testing.T) {
root := buildStructureFixture(t, "internal/services/user_service.go", `package services
type UserService struct{}
`)
vs := checkServiceStructure(root)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for type in internal/services/, got %d: %#v", len(vs), vs)
}
}
// A file under internal/handlers/ is SKIPPED entirely.
func TestCheckServiceStructure_SkipsInternalHandlersDir(t *testing.T) {
root := t.TempDir()
// Provide internal/handlers/ as the guard directory.
if err := os.MkdirAll(filepath.Join(root, "internal", "handlers"), 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
abs := filepath.Join(root, "internal", "handlers", "page.go")
if err := os.WriteFile(abs, []byte(`package handlers
type PageHandler struct{}
`), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
vs := checkServiceStructure(root)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for type in internal/handlers/, got %d: %#v", len(vs), vs)
}
}
// A type under internal/ (but NOT under internal/services/ or internal/handlers/)
// is walked and the name check runs — however, since the relPath starts with
// "internal/", the condition `!strings.HasPrefix(relPath, "internal/")` is false
// and no violation is produced. Verify the no-violation outcome.
func TestCheckServiceStructure_NoViolationForTypeInOtherInternalSubdir(t *testing.T) {
root := buildStructureFixture(t, "internal/middleware/auth_service.go", `package middleware
type AuthService struct{}
`)
vs := checkServiceStructure(root)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for type in internal/middleware/ (relPath starts with internal/), got %d: %#v", len(vs), vs)
}
}
// cmd/ files are skipped entirely — no violation even for types named *Service.
func TestCheckServiceStructure_SkipsCmdDir(t *testing.T) {
root := buildStructureFixture(t, "cmd/server/main.go", `package main
type ServerService struct{}
`)
vs := checkServiceStructure(root)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for type in cmd/ (skipped), got %d: %#v", len(vs), vs)
}
}
// Test files (_test.go) are always skipped.
func TestCheckServiceStructure_SkipsTestFiles(t *testing.T) {
root := buildStructureFixture(t, "myservice_test.go", `package main
type FakeUserService struct{}
`)
vs := checkServiceStructure(root)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for type in _test.go (skipped), got %d: %#v", len(vs), vs)
}
}
// Generated files (.connect.go, .pb.go, .gen.go) are skipped.
func TestCheckServiceStructure_SkipsGeneratedFiles(t *testing.T) {
root := buildStructureFixture(t, "rpc.pb.go", `package main
type RPCService struct{}
`)
vs := checkServiceStructure(root)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for .pb.go generated file, got %d: %#v", len(vs), vs)
}
}
// api/ prefix files are skipped.
func TestCheckServiceStructure_SkipsAPIDir(t *testing.T) {
root := buildStructureFixture(t, "api/v1/handler.go", `package v1
type PageHandler struct{}
`)
vs := checkServiceStructure(root)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for type in api/ (skipped), got %d: %#v", len(vs), vs)
}
}
// When guard returns false (no internal layout), checkServiceStructure is a no-op.
func TestCheckServiceStructure_NoOpWhenGuardFalse(t *testing.T) {
// Root with NO internal/services or internal/handlers — guard will return false.
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "svc.go"), []byte(`package main
type UserService struct{}
`), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
vs := checkServiceStructure(root)
if len(vs) != 0 {
t.Fatalf("expected 0 violations (guard=false, check skipped), got %d: %#v", len(vs), vs)
}
}
// Clean case: valid placement — type in internal/services/ and internal/handlers/,
// nothing misplaced anywhere. Zero violations.
func TestCheckServiceStructure_CleanRepo(t *testing.T) {
root := t.TempDir()
// Satisfy guard.
if err := os.MkdirAll(filepath.Join(root, "internal", "services"), 0755); err != nil {
t.Fatalf("mkdir services: %v", err)
}
if err := os.MkdirAll(filepath.Join(root, "internal", "handlers"), 0755); err != nil {
t.Fatalf("mkdir handlers: %v", err)
}
// Correctly placed service.
if err := os.WriteFile(filepath.Join(root, "internal", "services", "user.go"), []byte(`package services
type UserService struct {
db interface{}
}
`), 0o644); err != nil {
t.Fatalf("write user.go: %v", err)
}
// Correctly placed handler.
if err := os.WriteFile(filepath.Join(root, "internal", "handlers", "page.go"), []byte(`package handlers
type PageHandler struct {
svc interface{}
}
`), 0o644); err != nil {
t.Fatalf("write page.go: %v", err)
}
// A non-service/handler type at root — fine.
if err := os.WriteFile(filepath.Join(root, "config.go"), []byte(`package main
type Config struct {
Debug bool
}
`), 0o644); err != nil {
t.Fatalf("write config.go: %v", err)
}
vs := checkServiceStructure(root)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for clean repo layout, got %d: %#v", len(vs), vs)
}
}
// Snippet field encodes the type name in the expected format.
func TestCheckServiceStructure_ViolationSnippetFormat(t *testing.T) {
root := buildStructureFixture(t, "billing.go", `package main
type BillingService struct{}
`)
vs := checkServiceStructure(root)
if len(vs) == 0 {
t.Fatal("expected at least one violation, got 0")
}
v := vs[0]
if v.snippet != "type BillingService — should be in internal/services/" {
t.Fatalf("unexpected snippet: %q", v.snippet)
}
if v.line == 0 {
t.Error("expected non-zero line number in violation")
}
}
// Multiple misplaced types in one file each produce their own violation.
func TestCheckServiceStructure_MultipleViolationsInOneFile(t *testing.T) {
root := buildStructureFixture(t, "mixed.go", `package main
type OrderService struct{}
type OrderHandler struct{}
`)
vs := checkServiceStructure(root)
if len(vs) < 2 {
t.Fatalf("expected at least 2 violations (1 service + 1 handler), got %d: %#v", len(vs), vs)
}
rules := map[string]bool{}
for _, v := range vs {
rules[v.rule] = true
}
if !rules["service-location"] {
t.Error("expected 'service-location' violation")
}
if !rules["handler-location"] {
t.Error("expected 'handler-location' violation")
}
}

8
go.mod Normal file
View File

@ -0,0 +1,8 @@
module git.dev.alexdunmow.com/block/check-safety
go 1.26.4
require (
golang.org/x/mod v0.34.0
gopkg.in/yaml.v3 v3.0.1
)

6
go.sum Normal file
View File

@ -0,0 +1,6 @@
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

72
go_tests.go Normal file
View File

@ -0,0 +1,72 @@
package main
import (
"fmt"
"os/exec"
"path/filepath"
"sort"
"strings"
)
type goTestTarget struct {
label string
moduleRoot string
args []string
}
type goTestFailure struct {
target string
stage string
output string
}
func discoverOrchestratorTestTargets(repoRoot, backendDir string, includeCoreTargets bool) []goTestTarget {
if !includeCoreTargets || !samePath(repoRoot, blockNinjaRepoRoot()) || !samePath(backendDir, filepath.Join(repoRoot, "backend")) {
return nil
}
return discoverOrchestratorTestTargetsFromRoot(orchestratorRepoRoot())
}
func discoverOrchestratorTestTargetsFromRoot(root string) []goTestTarget {
backendDir := filepath.Join(root, "backend")
if !fileExists(filepath.Join(backendDir, "go.mod")) {
return nil
}
return []goTestTarget{{
label: "orchestrator/backend",
moduleRoot: backendDir,
args: []string{"test", "./..."},
}}
}
func runGoTestTargets(targets []goTestTarget) (completed []string, failures []goTestFailure, err error) {
goBin, err := exec.LookPath("go")
if err != nil {
return nil, nil, fmt.Errorf("go not found in PATH")
}
return runGoTestTargetsWithBinary(targets, goBin)
}
func runGoTestTargetsWithBinary(targets []goTestTarget, goBin string) (completed []string, failures []goTestFailure, err error) {
for _, target := range targets {
args := append([]string{}, target.args...)
if len(args) == 0 {
args = []string{"test", "./..."}
}
stage := "go " + strings.Join(args, " ")
output, runErr := runCommand(target.moduleRoot, goBin, args...)
if runErr != nil {
failures = append(failures, goTestFailure{
target: target.label,
stage: stage,
output: output,
})
continue
}
completed = append(completed, target.label+" ["+stage+"]")
}
sort.Strings(completed)
return completed, failures, nil
}

289
golden_test.go Normal file
View File

@ -0,0 +1,289 @@
package main
// Golden characterization test for cmd/check-safety.
//
// Builds the tool once (TestMain), then runs it against small in-memory fixture
// trees created per test case. Normalises volatile tokens (absolute fixture
// path, SDK version) before comparing against committed golden files.
//
// Usage:
//
// go test ./cmd/check-safety/ -run TestGoldenCharacterization # compare
// go test ./cmd/check-safety/ -run TestGoldenCharacterization -update # regenerate
//
// Archetype-B checks (2f codegen, 3 Go lint, 3b orchestrator tests, 4 frontend
// lint) are intentionally excluded from the golden corpus: they shell out to
// external tools and cannot be snapshotted deterministically. Fixtures are
// arranged so those four checks print SKIP (or OK for the `clean` case where
// the minimal Go package is deterministically lint-clean).
import (
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
)
var (
update = flag.Bool("update", false, "regenerate golden files instead of comparing")
checkSafetyBin string // set by TestMain
)
// fixtureDef describes a single fixture tree that is created in a temp dir.
type fixtureDef struct {
// path is relative to the fixture root; content is the file content.
path string
content string
}
// goldenCase is one row in the characterisation matrix.
type goldenCase struct {
name string
fixtures []fixtureDef
// extraArgs are additional CLI args passed after the positional target dir.
extraArgs []string
wantExit int
}
// goldenCases enumerates the test matrix.
var goldenCases = []goldenCase{
{
// nomod: no go.mod present.
// — Check 1 FAIL: os.Getenv(secret) outside config
// — Check 2 SKIP: no proto procedures
// — Check 2b OK
// — Check 2c FAIL: missing go.mod
// — Check 3 SKIP: no go.mod found (external tools NOT invoked)
// — Check 3b SKIP: orchestrator not found
// — Check 2f SKIP: no codegen targets
// — Check 4 SKIP: no frontend sources
// — Check 14 FAIL: raw SQL
// — Check 15 FAIL: err.Error() leaked
// — Check 17 FAIL: TODO marker
name: "nomod",
fixtures: []fixtureDef{
{
path: "internal/service/handler.go",
content: `// Package service provides a service handler.
package service
import (
"database/sql"
"net/http"
"os"
)
// MyService handles requests.
type MyService struct{}
// Init initialises the service.
// TODO: add proper initialisation
func (s *MyService) Init() {
// Leak a secret env var outside config.Load() — triggers Check 1
_ = os.Getenv("JWT_SECRET")
}
// QueryUsers fetches users using raw SQL — triggers Check 14.
func (s *MyService) QueryUsers(db *sql.DB) {
db.QueryContext(nil, "SELECT id, name FROM users WHERE active = true") //nolint
}
// HandleError leaks err.Error() to HTTP client — triggers Check 15.
func (s *MyService) HandleError(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
`,
},
},
wantExit: 1,
},
{
// clean: go.mod present, minimal valid Go package, no violations.
// — All deterministic checks OK or SKIP
// — Check 2c OK: go.mod has no replace directive for block/core
// — Check 3 OK: lint pipeline passes (deterministic: trivial package)
// — Check 3b SKIP
// — Check 2f SKIP
// — Check 4 SKIP: no frontend sources
name: "clean",
fixtures: []fixtureDef{
{
path: "go.mod",
content: "module example.com/goldenclean\n\ngo 1.21\n",
},
{
path: "internal/pkg/util.go",
content: `// Package pkg provides utility functions.
package pkg
// Add returns the sum of a and b.
func Add(a, b int) int {
return a + b
}
`,
},
},
wantExit: 0,
},
}
// ---- TestMain: build the binary once ----------------------------------------
func TestMain(m *testing.M) {
flag.Parse()
// Build check-safety to a temp file.
tmpDir, err := os.MkdirTemp("", "check-safety-golden-bin-*")
if err != nil {
fmt.Fprintf(os.Stderr, "golden_test: failed to create tmpdir for binary: %v\n", err)
os.Exit(2)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
binPath := filepath.Join(tmpDir, "check-safety")
// Build the standalone check-safety binary from its own module.
buildCmd := exec.Command("go", "build", "-o", binPath, ".")
buildCmd.Env = os.Environ() // inherit env (GOPATH, GOMODCACHE, etc.)
if out, buildErr := buildCmd.CombinedOutput(); buildErr != nil {
fmt.Fprintf(os.Stderr, "golden_test: failed to build check-safety binary:\n%s\n", out)
os.Exit(2)
}
checkSafetyBin = binPath
os.Exit(m.Run())
}
// ---- TestGoldenCharacterization -----------------------------------------------
func TestGoldenCharacterization(t *testing.T) {
for _, tc := range goldenCases {
t.Run(tc.name, func(t *testing.T) {
// 1. Create fixture tree in a temp dir.
fixtureDir := t.TempDir()
for _, f := range tc.fixtures {
dst := filepath.Join(fixtureDir, filepath.FromSlash(f.path))
if mkErr := os.MkdirAll(filepath.Dir(dst), 0o755); mkErr != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(dst), mkErr)
}
if writeErr := os.WriteFile(dst, []byte(f.content), 0o644); writeErr != nil {
t.Fatalf("write fixture %s: %v", dst, writeErr)
}
}
// 2. Run check-safety against the fixture.
args := append([]string{fixtureDir}, tc.extraArgs...)
cmd := exec.Command(checkSafetyBin, args...)
raw, _ := cmd.CombinedOutput() // exit code is checked separately
gotExit := 0
if cmd.ProcessState != nil {
gotExit = cmd.ProcessState.ExitCode()
}
// 3. Normalise volatile tokens.
got := normalizeGoldenOutput(string(raw), fixtureDir)
// 4. Golden update mode: write files and return.
if *update {
writeGolden(t, tc.name, got, gotExit)
return
}
// 5. Compare exit code.
if gotExit != tc.wantExit {
t.Errorf("exit code = %d, want %d", gotExit, tc.wantExit)
}
// 6. Compare normalised stdout.
want := readGoldenStdout(t, tc.name)
if got != want {
t.Errorf("golden mismatch for case %q\n\n--- want ---\n%s\n--- got ---\n%s\n",
tc.name, want, got)
}
// 7. Compare exit code from golden file.
wantExitFromFile := readGoldenExit(t, tc.name)
if gotExit != wantExitFromFile {
t.Errorf("exit code = %d, want %d (from golden file)", gotExit, wantExitFromFile)
}
})
}
}
// ---- normalisation ----------------------------------------------------------
// reSDKVersion matches the block/core semver in Check-2c messages.
// Example: "require git.dev.alexdunmow.com/block/core v0.9.0,"
var reSDKVersion = regexp.MustCompile(
`(git\.dev\.alexdunmow\.com/block/core) v\d+\.\d+\.\d+[-\w]*`,
)
// normalizeGoldenOutput replaces volatile tokens with stable placeholders.
// - The absolute fixture directory path → FIXTURE_DIR
// - The SDK semver in Check 2c messages → <SDK_VERSION>
func normalizeGoldenOutput(output, fixtureDir string) string {
// Normalise all occurrences of the absolute fixture path.
out := strings.ReplaceAll(output, fixtureDir, "FIXTURE_DIR")
// Normalise SDK version so golden files survive SDK bumps.
out = reSDKVersion.ReplaceAllString(out, "$1 <SDK_VERSION>")
return out
}
// ---- golden file helpers ----------------------------------------------------
func goldenDir(name string) string {
return filepath.Join("testdata", "golden", name)
}
func goldenStdoutPath(name string) string {
return filepath.Join(goldenDir(name), "expected.stdout")
}
func goldenExitPath(name string) string {
return filepath.Join(goldenDir(name), "expected.exit")
}
func readGoldenStdout(t *testing.T, name string) string {
t.Helper()
data, err := os.ReadFile(goldenStdoutPath(name))
if err != nil {
t.Fatalf("read golden stdout for %q: %v (run with -update to generate)", name, err)
}
return string(data)
}
func readGoldenExit(t *testing.T, name string) int {
t.Helper()
data, err := os.ReadFile(goldenExitPath(name))
if err != nil {
t.Fatalf("read golden exit for %q: %v (run with -update to generate)", name, err)
}
trimmed := strings.TrimSpace(string(data))
if trimmed == "0" {
return 0
}
if trimmed == "1" {
return 1
}
t.Fatalf("unexpected exit code in golden file for %q: %q", name, trimmed)
return -1
}
func writeGolden(t *testing.T, name, stdout string, exitCode int) {
t.Helper()
dir := goldenDir(name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("mkdir golden dir %s: %v", dir, err)
}
if err := os.WriteFile(goldenStdoutPath(name), []byte(stdout), 0o644); err != nil {
t.Fatalf("write golden stdout for %q: %v", name, err)
}
exitStr := fmt.Sprintf("%d\n", exitCode)
if err := os.WriteFile(goldenExitPath(name), []byte(exitStr), 0o644); err != nil {
t.Fatalf("write golden exit for %q: %v", name, err)
}
t.Logf("golden files updated for case %q (exit=%d)", name, exitCode)
}

113
htmlsanitize.go Normal file
View File

@ -0,0 +1,113 @@
package main
import (
"bufio"
"os"
"path/filepath"
"regexp"
"strings"
"git.dev.alexdunmow.com/block/check-safety/internal/helpers"
)
type htmlSanitizeViolation struct {
file string
line int
rule string
snippet string
}
var htmlSanitizePatterns = []struct {
pattern *regexp.Regexp
rule string
}{
{
pattern: regexp.MustCompile(`regexp\.\s*(?:MustCompile|Compile)\s*\(.+<(?:script|style)\b`),
rule: "regex-html-strip",
},
{
pattern: regexp.MustCompile(`regexp\.\s*(?:MustCompile|Compile)\s*\(.+<\[\^`),
rule: "regex-html-strip",
},
{
pattern: regexp.MustCompile(`(?:func\s+(?:strip|Strip)HTML|func\s+stripHTMLTags|func\s+sanitizeHTML|func\s+cleanHTML)\s*\(`),
rule: "hand-rolled-strip-func",
},
{
pattern: regexp.MustCompile(`if\s+r\s*==\s*'<'\s*\{`),
rule: "char-by-char-tag-strip",
},
{
pattern: regexp.MustCompile(`strings\.NewReplacer\(.*"<[^"]*>".*\)`),
rule: "replacer-html-strip",
},
}
var htmlSanitizeAllowedFiles = map[string]bool{
"cmd/check-safety/htmlsanitize.go": true,
"cmd/check-safety/check_htmlsanitize.go": true, // holds the fix-instruction table (example patterns)
"cmd/check-safety/main.go": true,
"internal/helpers/slug.go": true,
}
func isHTMLSanitizeAllowed(relPath string) bool {
if strings.HasSuffix(relPath, "_test.go") {
return true
}
for allowed := range htmlSanitizeAllowedFiles {
if strings.HasSuffix(relPath, allowed) {
return true
}
}
return false
}
func checkHTMLSanitize(root string) []htmlSanitizeViolation {
var violations []htmlSanitizeViolation
_ = 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/") {
return nil
}
relPath, _ := filepath.Rel(root, path)
if isHTMLSanitizeAllowed(relPath) {
return nil
}
f, ferr := os.Open(path)
if ferr != nil {
return nil
}
defer helpers.LogDeferredError(nil, "close html-sanitize 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
}
for _, check := range htmlSanitizePatterns {
if check.pattern.MatchString(line) {
violations = append(violations, htmlSanitizeViolation{
file: relPath,
line: lineNum,
rule: check.rule,
snippet: strings.TrimSpace(line),
})
}
}
}
return nil
})
return violations
}

120
htmlsanitize_test.go Normal file
View File

@ -0,0 +1,120 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestCheckHTMLSanitize_RegexStrip(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "bad.go", `package foo
import "regexp"
func clean(s string) string {
re := regexp.MustCompile(`+"`"+`<[^>]*>`+"`"+`)
return re.ReplaceAllString(s, "")
}
`)
vs := checkHTMLSanitize(dir)
if len(vs) == 0 {
t.Fatal("expected regex-html-strip violation")
}
if vs[0].rule != "regex-html-strip" {
t.Fatalf("expected rule regex-html-strip, got %s", vs[0].rule)
}
}
func TestCheckHTMLSanitize_ScriptRegex(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "bad.go", `package foo
import "regexp"
func clean(s string) string {
re := regexp.MustCompile(`+"`"+`(?s)<script[^>]*>.*?</script>`+"`"+`)
return re.ReplaceAllString(s, "")
}
`)
vs := checkHTMLSanitize(dir)
if len(vs) == 0 {
t.Fatal("expected regex-html-strip violation for <script>")
}
}
func TestCheckHTMLSanitize_HandRolledFunc(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "bad.go", `package foo
func stripHTML(s string) string {
return s
}
`)
vs := checkHTMLSanitize(dir)
if len(vs) == 0 {
t.Fatal("expected hand-rolled-strip-func violation")
}
if vs[0].rule != "hand-rolled-strip-func" {
t.Fatalf("expected rule hand-rolled-strip-func, got %s", vs[0].rule)
}
}
func TestCheckHTMLSanitize_CharByChar(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "bad.go", `package foo
func strip(s string) string {
inTag := false
for _, r := range s {
if r == '<' {
inTag = true
}
}
_ = inTag
return s
}
`)
vs := checkHTMLSanitize(dir)
if len(vs) == 0 {
t.Fatal("expected char-by-char-tag-strip violation")
}
}
func TestCheckHTMLSanitize_BluemondayOK(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "good.go", `package foo
import "github.com/microcosm-cc/bluemonday"
func clean(s string) string {
return bluemonday.StrictPolicy().Sanitize(s)
}
`)
vs := checkHTMLSanitize(dir)
if len(vs) != 0 {
t.Fatalf("expected no violations for bluemonday usage, got %d", len(vs))
}
}
func TestCheckHTMLSanitize_TestFilesAllowed(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "strip_test.go", `package foo
func stripHTML(s string) string {
return s
}
`)
vs := checkHTMLSanitize(dir)
if len(vs) != 0 {
t.Fatalf("expected test files to be allowed, got %d violations", len(vs))
}
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)
if err != nil {
t.Fatal(err)
}
}

View File

@ -0,0 +1,17 @@
package helpers
import "log/slog"
// LogDeferredError runs a deferred cleanup function and logs any error it returns.
func LogDeferredError(logger *slog.Logger, action string, fn func() error, attrs ...any) {
if fn == nil {
return
}
if logger == nil {
logger = slog.Default()
}
if err := fn(); err != nil {
logArgs := append([]any{"action", action, "error", err}, attrs...)
logger.Warn("deferred cleanup failed", logArgs...)
}
}

175
internal/theme/colors.go Normal file
View File

@ -0,0 +1,175 @@
package theme
import (
"fmt"
"math"
"strconv"
"strings"
)
// HSLToHex converts an HSL string like "220 14% 96%" to a hex color like "#f4f5f7".
// This is needed for email templates since email clients don't support CSS variables.
func HSLToHex(hsl string) string {
h, s, l, err := parseHSL(hsl)
if err != nil {
// Return a fallback color on parse error
return "#000000"
}
r, g, b := hslToRGB(h, s, l)
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
}
// parseHSL parses an HSL string like "220 14% 96%" into h (0-360), s (0-1), l (0-1).
func parseHSL(hsl string) (h, s, l float64, err error) {
// Trim and normalize spaces
hsl = strings.TrimSpace(hsl)
parts := strings.Fields(hsl)
if len(parts) != 3 {
return 0, 0, 0, fmt.Errorf("invalid HSL format: expected 3 parts, got %d", len(parts))
}
// Parse hue (0-360 degrees)
h, err = strconv.ParseFloat(parts[0], 64)
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid hue value: %s", parts[0])
}
// Parse saturation (0-100%)
satStr := strings.TrimSuffix(parts[1], "%")
s, err = strconv.ParseFloat(satStr, 64)
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid saturation value: %s", parts[1])
}
s = s / 100.0 // Convert to 0-1 range
// Parse lightness (0-100%)
lightStr := strings.TrimSuffix(parts[2], "%")
l, err = strconv.ParseFloat(lightStr, 64)
if err != nil {
return 0, 0, 0, fmt.Errorf("invalid lightness value: %s", parts[2])
}
l = l / 100.0 // Convert to 0-1 range
return h, s, l, nil
}
// hslToRGB converts HSL values to RGB.
// h: 0-360, s: 0-1, l: 0-1
// Returns r, g, b in 0-255 range.
func hslToRGB(h, s, l float64) (r, g, b uint8) {
// Normalize hue to 0-1 range
h = h / 360.0
var rFloat, gFloat, bFloat float64
if s == 0 {
// Achromatic (gray)
rFloat = l
gFloat = l
bFloat = l
} else {
var q float64
if l < 0.5 {
q = l * (1 + s)
} else {
q = l + s - l*s
}
p := 2*l - q
rFloat = hueToRGB(p, q, h+1.0/3.0)
gFloat = hueToRGB(p, q, h)
bFloat = hueToRGB(p, q, h-1.0/3.0)
}
r = uint8(math.Round(rFloat * 255))
g = uint8(math.Round(gFloat * 255))
b = uint8(math.Round(bFloat * 255))
return r, g, b
}
// hueToRGB is a helper function for HSL to RGB conversion.
func hueToRGB(p, q, t float64) float64 {
if t < 0 {
t += 1
}
if t > 1 {
t -= 1
}
if t < 1.0/6.0 {
return p + (q-p)*6*t
}
if t < 1.0/2.0 {
return q
}
if t < 2.0/3.0 {
return p + (q-p)*(2.0/3.0-t)*6
}
return p
}
// ThemeColorsToEmail converts theme color settings (stored as HSL) to EmailColors (hex).
// This function takes the color map from theme settings and returns hex colors for email inlining.
func ThemeColorsToEmail(colors map[string]string) map[string]string {
result := make(map[string]string)
colorKeys := []string{
"primary", "primary-foreground",
"secondary", "secondary-foreground",
"background", "foreground",
"muted", "muted-foreground",
"border",
"card", "card-foreground",
"accent", "accent-foreground",
"destructive", "destructive-foreground",
}
for _, key := range colorKeys {
if hsl, ok := colors[key]; ok && hsl != "" {
result[key] = HSLToHex(hsl)
}
}
return result
}
// ColorSchemeToEmailColors converts a theme ColorScheme (HSL strings) to hex colors for email inlining.
// Email clients don't support CSS variables, so colors must be inlined as hex.
func ColorSchemeToEmailColors(cs *ColorScheme) EmailColors {
if cs == nil {
return EmailColors{}
}
return EmailColors{
Primary: HSLToHex(cs.Primary),
PrimaryForeground: HSLToHex(cs.PrimaryForeground),
Secondary: HSLToHex(cs.Secondary),
SecondaryForeground: HSLToHex(cs.SecondaryForeground),
Background: HSLToHex(cs.Background),
Foreground: HSLToHex(cs.Foreground),
Muted: HSLToHex(cs.Muted),
MutedForeground: HSLToHex(cs.MutedForeground),
Border: HSLToHex(cs.Border),
Card: HSLToHex(cs.Card),
CardForeground: HSLToHex(cs.CardForeground),
}
}
// EmailColors contains theme colors converted to hex for email inlining.
// Mirrors templates.EmailColors structure but lives in theme package for conversion.
type EmailColors struct {
Primary string
PrimaryForeground string
Secondary string
SecondaryForeground string
Background string
Foreground string
Muted string
MutedForeground string
Border string
Card string
CardForeground string
}

647
internal/theme/css.go Normal file
View File

@ -0,0 +1,647 @@
package theme
import (
"fmt"
"strings"
)
// FontFile represents a font file for CSS generation
type FontFile struct {
Family string
Weight string
Style string
URL string
Format string // "woff2", "woff", "truetype"
}
// GenerateCSS generates CSS variable declarations from theme settings
func GenerateCSS(theme *Theme, googleFontURLs []string, fontFaces []FontFile) string {
var css strings.Builder
// Google Fonts imports
for _, url := range googleFontURLs {
fmt.Fprintf(&css, "@import url('%s');\n", url)
}
// Custom font-face declarations
for _, ff := range fontFaces {
fmt.Fprintf(&css, `@font-face {
font-family: '%s';
src: url('%s') format('%s');
font-weight: %s;
font-style: %s;
font-display: swap;
}
`, ff.Family, ff.URL, ff.Format, ff.Weight, ff.Style)
}
if len(googleFontURLs) > 0 || len(fontFaces) > 0 {
css.WriteString("\n")
}
// Light mode (default) - :root
css.WriteString(":root {\n")
writeColorVars(&css, theme.LightColors)
writeCustomColorVars(&css, theme.CustomColors, "light")
writeTypographyVars(&css, theme.Typography)
writeSpacingVars(&css, theme.Spacing)
writeEffectsVars(&css, theme.Effects)
writeButtonVars(&css, theme.Buttons, theme.Spacing)
css.WriteString("}\n\n")
// Dark mode — two selectors:
// .dark — shadcn convention (used by bidbuddy plugin)
// [data-theme$="-dark"] — convention for DaisyUI-based plugins
// Plugin dark themes MUST use the "<name>-dark" suffix to pick up
// dark color vars (e.g. data-theme="playground-dark"). Names like
// "dark-mode" do NOT match — only a trailing "-dark" does.
css.WriteString(".dark, [data-theme$=\"-dark\"] {\n")
writeColorVars(&css, theme.DarkColors)
writeCustomColorVars(&css, theme.CustomColors, "dark")
css.WriteString("}\n\n")
// Button class definitions
WriteButtonClasses(&css, theme.Buttons)
return css.String()
}
func writeColorVars(css *strings.Builder, colors *ColorScheme) {
if colors == nil {
return
}
writeVar(css, "background", colors.Background)
writeVar(css, "foreground", colors.Foreground)
writeVar(css, "card", colors.Card)
writeVar(css, "card-foreground", colors.CardForeground)
writeVar(css, "popover", colors.Popover)
writeVar(css, "popover-foreground", colors.PopoverForeground)
writeVar(css, "primary", colors.Primary)
writeVar(css, "primary-foreground", colors.PrimaryForeground)
writeVar(css, "secondary", colors.Secondary)
writeVar(css, "secondary-foreground", colors.SecondaryForeground)
writeVar(css, "muted", colors.Muted)
writeVar(css, "muted-foreground", colors.MutedForeground)
writeVar(css, "accent", colors.Accent)
writeVar(css, "accent-foreground", colors.AccentForeground)
writeVar(css, "destructive", colors.Destructive)
writeVar(css, "destructive-foreground", colors.DestructiveForeground)
writeVar(css, "border", colors.Border)
writeVar(css, "input", colors.Input)
writeVar(css, "ring", colors.Ring)
}
// writeCustomColorVars writes CSS variables for user-defined custom colors
func writeCustomColorVars(css *strings.Builder, customColors []*CustomColor, mode string) {
for _, color := range customColors {
if color == nil || color.Name == "" {
continue
}
varName := "color-" + color.Name
var value string
if mode == "dark" {
value = color.DarkValue
} else {
value = color.LightValue
}
if value != "" {
writeVar(css, varName, value)
}
}
}
func writeTypographyVars(css *strings.Builder, typography *ThemeTypography) {
if typography == nil {
return
}
// Font families
headingStack := getFontStack(typography.FontHeading, "sans-serif")
bodyStack := getFontStack(typography.FontBody, "sans-serif")
monoStack := getFontStack(typography.FontMono, "monospace")
writeVar(css, "font-heading", headingStack)
writeVar(css, "font-body", bodyStack)
writeVar(css, "font-sans", bodyStack)
writeVar(css, "font-mono", monoStack)
// Font size base as rem
if typography.FontSizeBase != "" {
writeVar(css, "font-size-base", typography.FontSizeBase+"px")
}
if typography.LineHeightBase != "" {
writeVar(css, "line-height-base", typography.LineHeightBase)
}
// Font weight base
writeVar(css, "font-weight-base", getFontWeight(typography.FontWeightBase))
}
func writeSpacingVars(css *strings.Builder, spacing *ThemeSpacing) {
if spacing == nil {
return
}
// Radius values
if spacing.Radius != "" {
writeVar(css, "radius", spacing.Radius+"rem")
}
// Button radius (or pill mode)
writeVar(css, "button-radius", getButtonRadius(spacing))
// Card radius
if spacing.CardRadius != "" {
writeVar(css, "card-radius", spacing.CardRadius+"rem")
}
// Input radius
if spacing.InputRadius != "" {
writeVar(css, "input-radius", spacing.InputRadius+"rem")
}
// Border width
writeVar(css, "border-width", getBorderWidth(spacing.BorderWidth))
// Container width
writeVar(css, "container-width", getContainerWidth(spacing.ContainerWidth))
// Spacing scale
writeVar(css, "spacing-scale", getSpacingScale(spacing.SpacingScale))
}
func writeEffectsVars(css *strings.Builder, effects *ThemeEffects) {
if effects == nil {
return
}
// Shadow intensity as decimal (0-1)
intensity := float64(effects.ShadowIntensity) / 100.0
writeVar(css, "shadow-intensity", fmt.Sprintf("%.2f", intensity))
// Shadow color (HSL without function)
if effects.ShadowColor != "" {
writeVar(css, "shadow-color", effects.ShadowColor)
}
// Shadow blur multiplier
writeVar(css, "shadow-blur-mult", getShadowBlurMult(effects.ShadowBlur))
// Elevation preset (generates multiple shadow vars)
writeElevationVars(css, effects.ElevationPreset, intensity, effects.ShadowColor)
// Transition speed
writeVar(css, "transition-speed", getTransitionSpeed(effects.TransitionSpeed))
// Hover scale (for transform effects)
writeVar(css, "hover-scale", getHoverScale(effects.HoverIntensity))
// Reduce motion flag
if effects.ReduceMotion {
writeVar(css, "motion-reduce", "reduce")
} else {
writeVar(css, "motion-reduce", "no-preference")
}
}
// getButtonRadius returns button radius - uses pill (9999px) if enabled, else buttonRadius
func getButtonRadius(spacing *ThemeSpacing) string {
if spacing.PillButtons {
return "9999px"
}
if spacing.ButtonRadius != "" {
return spacing.ButtonRadius + "rem"
}
return "0.375rem"
}
// getBorderWidth converts preset to CSS value
func getBorderWidth(preset string) string {
switch preset {
case "thin":
return "1px"
case "thick":
return "3px"
default: // "normal"
return "2px"
}
}
// getContainerWidth converts preset to CSS max-width
func getContainerWidth(preset string) string {
switch preset {
case "narrow":
return "1024px"
case "wide":
return "1536px"
case "full":
return "100%"
default: // "normal"
return "1280px"
}
}
// getSpacingScale converts preset to multiplier
func getSpacingScale(preset string) string {
switch preset {
case "compact":
return "0.875"
case "relaxed":
return "1.25"
default: // "normal"
return "1"
}
}
// getFontWeight converts preset to CSS font-weight
func getFontWeight(preset string) string {
switch preset {
case "light":
return "300"
case "medium":
return "500"
default: // "normal"
return "400"
}
}
// getShadowBlurMult converts blur preset to multiplier
func getShadowBlurMult(preset string) string {
switch preset {
case "sharp":
return "0.5"
case "soft":
return "1.5"
default: // "normal"
return "1"
}
}
// getTransitionSpeed converts preset to duration
func getTransitionSpeed(preset string) string {
switch preset {
case "fast":
return "150ms"
case "slow":
return "500ms"
default: // "normal"
return "300ms"
}
}
// getHoverScale converts intensity to CSS scale value
func getHoverScale(preset string) string {
switch preset {
case "subtle":
return "1.01"
case "bold":
return "1.05"
default: // "normal"
return "1.02"
}
}
// writeElevationVars writes shadow CSS vars based on elevation preset
func writeElevationVars(css *strings.Builder, preset string, intensity float64, shadowColor string) {
if shadowColor == "" {
shadowColor = "0 0% 0%"
}
// Base opacity values that will be multiplied by intensity
var smOp, mdOp, lgOp float64
var smBlur, mdBlur, lgBlur int
var smY, mdY, lgY int
switch preset {
case "flat":
smOp, mdOp, lgOp = 0.02, 0.03, 0.05
smBlur, mdBlur, lgBlur = 1, 2, 4
smY, mdY, lgY = 0, 1, 2
case "floating":
smOp, mdOp, lgOp = 0.08, 0.12, 0.18
smBlur, mdBlur, lgBlur = 4, 8, 16
smY, mdY, lgY = 2, 6, 12
case "hovering":
smOp, mdOp, lgOp = 0.12, 0.18, 0.25
smBlur, mdBlur, lgBlur = 6, 12, 24
smY, mdY, lgY = 4, 10, 20
default: // "raised"
smOp, mdOp, lgOp = 0.05, 0.08, 0.12
smBlur, mdBlur, lgBlur = 2, 4, 8
smY, mdY, lgY = 1, 3, 6
}
// Apply intensity multiplier
smOp *= intensity
mdOp *= intensity
lgOp *= intensity
writeVar(css, "shadow-sm", fmt.Sprintf("0 %dpx %dpx hsl(%s / %.3f)", smY, smBlur, shadowColor, smOp))
writeVar(css, "shadow-md", fmt.Sprintf("0 %dpx %dpx hsl(%s / %.3f)", mdY, mdBlur, shadowColor, mdOp))
writeVar(css, "shadow-lg", fmt.Sprintf("0 %dpx %dpx hsl(%s / %.3f)", lgY, lgBlur, shadowColor, lgOp))
}
func writeVar(css *strings.Builder, name, value string) {
if value != "" {
fmt.Fprintf(css, " --%s: %s;\n", name, value)
}
}
// writeButtonVars writes CSS variables for button types
func writeButtonVars(css *strings.Builder, buttons *ThemeButtons, spacing *ThemeSpacing) {
if buttons == nil {
return
}
// Write built-in button types
writeButtonTypeVars(css, "primary", buttons.Primary, spacing)
writeButtonTypeVars(css, "secondary", buttons.Secondary, spacing)
writeButtonTypeVars(css, "outline", buttons.Outline, spacing)
writeButtonTypeVars(css, "ghost", buttons.Ghost, spacing)
writeButtonTypeVars(css, "link", buttons.Link, spacing)
writeButtonTypeVars(css, "destructive", buttons.Destructive, spacing)
// Write custom button types
for name, config := range buttons.Custom {
writeButtonTypeVars(css, name, config, spacing)
}
}
// WriteButtonClasses writes the button CSS class definitions to be appended after :root
func WriteButtonClasses(css *strings.Builder, buttons *ThemeButtons) {
// Base button class
css.WriteString(`
/* Base button styles */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
font-size: 1rem;
font-weight: 500;
border-radius: var(--button-radius, 0.375rem);
transition: all 150ms ease;
cursor: pointer;
text-decoration: none;
}
/* Button sizes */
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.875rem; }
.btn-md { padding: 0.5rem 1rem; font-size: 1rem; }
.btn-lg { padding: 0.75rem 1.5rem; font-size: 1.125rem; }
/* Theme-aware button types */
.btn-primary {
background: var(--btn-primary-bg, hsl(var(--primary)));
color: var(--btn-primary-text, hsl(var(--primary-foreground)));
border: var(--btn-primary-border-width, 0) solid var(--btn-primary-border, transparent);
border-radius: var(--btn-primary-radius, var(--button-radius, 0.375rem));
box-shadow: var(--btn-primary-shadow, none);
}
.btn-primary:hover {
background: var(--btn-primary-hover-bg, hsl(var(--primary) / 0.9));
opacity: 0.9;
}
.btn-secondary {
background: var(--btn-secondary-bg, hsl(var(--secondary)));
color: var(--btn-secondary-text, hsl(var(--secondary-foreground)));
border: var(--btn-secondary-border-width, 0) solid var(--btn-secondary-border, transparent);
border-radius: var(--btn-secondary-radius, var(--button-radius, 0.375rem));
box-shadow: var(--btn-secondary-shadow, none);
}
.btn-secondary:hover {
background: var(--btn-secondary-hover-bg, hsl(var(--secondary) / 0.8));
}
.btn-outline {
background: var(--btn-outline-bg, transparent);
color: var(--btn-outline-text, hsl(var(--primary)));
border: var(--btn-outline-border-width, 1px) solid var(--btn-outline-border, hsl(var(--border)));
border-radius: var(--btn-outline-radius, var(--button-radius, 0.375rem));
}
.btn-outline:hover {
background: var(--btn-outline-hover-bg, hsl(var(--accent)));
color: var(--btn-outline-hover-text, hsl(var(--accent-foreground)));
}
.btn-ghost {
background: var(--btn-ghost-bg, transparent);
color: var(--btn-ghost-text, hsl(var(--foreground)));
border: var(--btn-ghost-border-width, 0) solid transparent;
border-radius: var(--btn-ghost-radius, var(--button-radius, 0.375rem));
}
.btn-ghost:hover {
background: var(--btn-ghost-hover-bg, hsl(var(--accent)));
color: var(--btn-ghost-hover-text, hsl(var(--accent-foreground)));
}
.btn-link {
background: transparent;
color: var(--btn-link-text, hsl(var(--primary)));
border: none;
padding: 0;
text-decoration: underline;
text-underline-offset: 4px;
}
.btn-link:hover {
opacity: 0.8;
}
.btn-destructive {
background: var(--btn-destructive-bg, hsl(var(--destructive)));
color: var(--btn-destructive-text, hsl(var(--destructive-foreground)));
border: var(--btn-destructive-border-width, 0) solid transparent;
border-radius: var(--btn-destructive-radius, var(--button-radius, 0.375rem));
}
.btn-destructive:hover {
background: var(--btn-destructive-hover-bg, hsl(var(--destructive) / 0.9));
}
`)
// Write custom button type classes
if buttons != nil {
for name := range buttons.Custom {
writeButtonTypeClass(css, name)
}
}
}
// writeButtonTypeClass writes CSS class for a custom button type
func writeButtonTypeClass(css *strings.Builder, typeName string) {
prefix := "btn-" + typeName
fmt.Fprintf(css, `
.%s {
background: var(--%s-bg, hsl(var(--primary)));
color: var(--%s-text, hsl(var(--primary-foreground)));
border: var(--%s-border-width, 0) solid var(--%s-border, transparent);
border-radius: var(--%s-radius, var(--button-radius, 0.375rem));
box-shadow: var(--%s-shadow, none);
}
.%s:hover {
background: var(--%s-hover-bg, hsl(var(--primary) / 0.9));
}
`, prefix, prefix, prefix, prefix, prefix, prefix, prefix, prefix, prefix)
}
// writeButtonTypeVars writes CSS variables for a single button type
func writeButtonTypeVars(css *strings.Builder, typeName string, config *ButtonTypeConfig, spacing *ThemeSpacing) {
if config == nil {
return
}
prefix := "btn-" + typeName
// Background color - resolve var references to hsl()
if config.BgColor != "" {
writeVar(css, prefix+"-bg", resolveButtonColor(config.BgColor))
}
// Text color
if config.TextColor != "" {
writeVar(css, prefix+"-text", resolveButtonColor(config.TextColor))
}
// Border color
if config.BorderColor != "" {
writeVar(css, prefix+"-border", resolveButtonColor(config.BorderColor))
} else {
writeVar(css, prefix+"-border", "transparent")
}
// Border width
if config.BorderWidth != "" && config.BorderWidth != "0" {
writeVar(css, prefix+"-border-width", config.BorderWidth+"px")
} else {
writeVar(css, prefix+"-border-width", "0")
}
// Radius - check for pill mode override from spacing
if config.Radius == "pill" || (spacing != nil && spacing.PillButtons) {
writeVar(css, prefix+"-radius", "9999px")
} else if config.Radius != "" {
writeVar(css, prefix+"-radius", config.Radius+"rem")
} else if spacing != nil && spacing.ButtonRadius != "" {
writeVar(css, prefix+"-radius", spacing.ButtonRadius+"rem")
} else {
writeVar(css, prefix+"-radius", "0.375rem")
}
// Shadow
if config.Shadow != "" && config.Shadow != "none" {
writeVar(css, prefix+"-shadow", "var(--shadow-"+config.Shadow+")")
} else {
writeVar(css, prefix+"-shadow", "none")
}
// Hover background
if config.HoverBg != "" {
writeVar(css, prefix+"-hover-bg", resolveButtonColor(config.HoverBg))
}
// Hover text
if config.HoverText != "" {
writeVar(css, prefix+"-hover-text", resolveButtonColor(config.HoverText))
}
}
// resolveButtonColor converts button color values to CSS
// Handles: "var(--primary)" -> "hsl(var(--primary))"
//
// "var(--primary)/90" -> "hsl(var(--primary) / 0.9)"
// "hsl(var(--primary))" -> "hsl(var(--primary))" (passthrough)
// "transparent" -> "transparent"
// "#ff5500" -> "#ff5500" (passthrough)
// HSL value -> "hsl(value)"
func resolveButtonColor(color string) string {
if color == "" || color == "transparent" {
return color
}
// Already wrapped in hsl() - pass through as-is
if strings.HasPrefix(color, "hsl(") {
return color
}
// Hex colors - pass through as-is
if strings.HasPrefix(color, "#") {
return color
}
// Handle var() references with optional opacity
if strings.HasPrefix(color, "var(") {
// Check for /opacity suffix: "var(--primary)/90"
if idx := strings.LastIndex(color, "/"); idx > 0 {
varPart := strings.TrimSpace(color[:idx])
opacityPart := strings.TrimSpace(color[idx+1:])
// Convert opacity from 0-100 to 0-1 if needed
if len(opacityPart) <= 3 {
// Assume it's a percentage like "90"
return fmt.Sprintf("hsl(%s / %s%%)", varPart, opacityPart)
}
return fmt.Sprintf("hsl(%s / %s)", varPart, opacityPart)
}
// Simple var() reference
return fmt.Sprintf("hsl(%s)", color)
}
// Already an HSL value (e.g., "173 80% 40%")
return fmt.Sprintf("hsl(%s)", color)
}
// getFontStack returns a CSS font stack for the given font reference
// fontRef formats:
// - "system" -> system font stack
// - "google:Inter" -> 'Inter', fallback
// - "template:gotham:Gotham" -> 'Gotham', fallback
// - "upload:uuid" -> looked up from DB, but here we just use the family name
func getFontStack(fontRef string, fallback string) string {
if fontRef == "" || fontRef == "system" {
return getSystemFontStack(fallback)
}
parts := strings.SplitN(fontRef, ":", 2)
if len(parts) < 2 {
return getSystemFontStack(fallback)
}
source := parts[0]
switch source {
case "system":
// System fonts allow specifying the category after the colon
if len(parts) >= 2 && parts[1] != "" {
return getSystemFontStack(parts[1])
}
return getSystemFontStack(fallback)
case "google":
family := parts[1]
return fmt.Sprintf("'%s', %s", family, getSystemFontStack(fallback))
case "template":
// template:gotham:Gotham -> family is "Gotham"
subparts := strings.SplitN(parts[1], ":", 2)
if len(subparts) >= 2 {
return fmt.Sprintf("'%s', %s", subparts[1], getSystemFontStack(fallback))
}
return fmt.Sprintf("'%s', %s", parts[1], getSystemFontStack(fallback))
case "upload":
// Uploaded fonts should already have the family name resolved in the reference
if len(parts) >= 2 && parts[1] != "" {
return fmt.Sprintf("'%s', %s", parts[1], getSystemFontStack(fallback))
}
return getSystemFontStack(fallback)
default:
return getSystemFontStack(fallback)
}
}
func getSystemFontStack(category string) string {
switch category {
case "serif":
return "ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif"
case "monospace":
return "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace"
default: // sans-serif
return "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif"
}
}
// GetFontFormat returns the format string for a font file extension
func GetFontFormat(filename string) string {
lower := strings.ToLower(filename)
switch {
case strings.HasSuffix(lower, ".woff2"):
return "woff2"
case strings.HasSuffix(lower, ".woff"):
return "woff"
case strings.HasSuffix(lower, ".ttf"):
return "truetype"
case strings.HasSuffix(lower, ".otf"):
return "opentype"
default:
return "woff2"
}
}

325
internal/theme/defaults.go Normal file
View File

@ -0,0 +1,325 @@
package theme
// DefaultTheme returns the default theme settings
func DefaultTheme() *Theme {
return &Theme{
LightColors: &ColorScheme{
Background: "0 0% 100%",
Foreground: "0 0% 3.9%",
Card: "0 0% 100%",
CardForeground: "0 0% 3.9%",
Popover: "0 0% 100%",
PopoverForeground: "0 0% 3.9%",
Primary: "0 0% 9%",
PrimaryForeground: "0 0% 98%",
Secondary: "0 0% 96.1%",
SecondaryForeground: "0 0% 9%",
Muted: "0 0% 96.1%",
MutedForeground: "0 0% 45.1%",
Accent: "0 0% 96.1%",
AccentForeground: "0 0% 9%",
Destructive: "0 84.2% 60.2%",
DestructiveForeground: "0 0% 98%",
Border: "0 0% 89.8%",
Input: "0 0% 89.8%",
Ring: "0 0% 3.9%",
},
DarkColors: &ColorScheme{
Background: "0 0% 3.9%",
Foreground: "0 0% 98%",
Card: "0 0% 3.9%",
CardForeground: "0 0% 98%",
Popover: "0 0% 3.9%",
PopoverForeground: "0 0% 98%",
Primary: "0 0% 98%",
PrimaryForeground: "0 0% 9%",
Secondary: "0 0% 14.9%",
SecondaryForeground: "0 0% 98%",
Muted: "0 0% 14.9%",
MutedForeground: "0 0% 63.9%",
Accent: "0 0% 14.9%",
AccentForeground: "0 0% 98%",
Destructive: "0 62.8% 30.6%",
DestructiveForeground: "0 0% 98%",
Border: "0 0% 14.9%",
Input: "0 0% 14.9%",
Ring: "0 0% 83.1%",
},
Typography: &ThemeTypography{
FontHeading: "system",
FontBody: "system",
FontMono: "system",
FontSizeBase: "16",
LineHeightBase: "1.5",
FontWeightBase: "normal",
},
Spacing: &ThemeSpacing{
Radius: "0.5",
ButtonRadius: "0.375",
CardRadius: "0.75",
InputRadius: "0.375",
PillButtons: false,
BorderWidth: "normal",
ContainerWidth: "normal",
SpacingScale: "normal",
},
Effects: &ThemeEffects{
ShadowIntensity: 50,
ShadowColor: "0 0% 0%",
ShadowBlur: "normal",
ElevationPreset: "raised",
TransitionSpeed: "normal",
ReduceMotion: false,
HoverIntensity: "normal",
},
Mode: "system",
Buttons: &ThemeButtons{
Primary: &ButtonTypeConfig{
BgColor: "var(--primary)",
TextColor: "var(--primary-foreground)",
BorderWidth: "0",
Radius: "0.375",
Shadow: "none",
HoverBg: "var(--primary)/90",
},
Secondary: &ButtonTypeConfig{
BgColor: "var(--secondary)",
TextColor: "var(--secondary-foreground)",
BorderWidth: "0",
Radius: "0.375",
Shadow: "none",
HoverBg: "var(--secondary)/80",
},
Outline: &ButtonTypeConfig{
BgColor: "transparent",
TextColor: "var(--primary)",
BorderColor: "var(--border)",
BorderWidth: "1",
Radius: "0.375",
Shadow: "none",
HoverBg: "var(--accent)",
},
Ghost: &ButtonTypeConfig{
BgColor: "transparent",
TextColor: "var(--foreground)",
BorderWidth: "0",
Radius: "0.375",
Shadow: "none",
HoverBg: "var(--accent)",
},
Link: &ButtonTypeConfig{
BgColor: "transparent",
TextColor: "var(--primary)",
BorderWidth: "0",
Radius: "0",
Shadow: "none",
},
Destructive: &ButtonTypeConfig{
BgColor: "var(--destructive)",
TextColor: "var(--destructive-foreground)",
BorderWidth: "0",
Radius: "0.375",
Shadow: "none",
HoverBg: "var(--destructive)/90",
},
},
Inputs: &ThemeInputs{
Default: &InputVariantConfig{
BgColor: "var(--background)",
BorderColor: "var(--input)",
BorderWidth: "1",
TextColor: "var(--foreground)",
FocusRing: "var(--ring)",
FocusBg: "",
FocusBorder: "",
HoverBg: "",
PlaceholderColor: "var(--muted-foreground)",
},
Filled: &InputVariantConfig{
BgColor: "var(--muted)",
BorderColor: "transparent",
BorderWidth: "1",
TextColor: "var(--foreground)",
FocusRing: "var(--ring)",
FocusBg: "var(--background)",
FocusBorder: "var(--input)",
HoverBg: "",
PlaceholderColor: "var(--muted-foreground)",
},
Outlined: &InputVariantConfig{
BgColor: "transparent",
BorderColor: "var(--input)",
BorderWidth: "2",
TextColor: "var(--foreground)",
FocusRing: "var(--ring)",
FocusBg: "",
FocusBorder: "var(--ring)",
HoverBg: "",
PlaceholderColor: "var(--muted-foreground)",
},
Ghost: &InputVariantConfig{
BgColor: "transparent",
BorderColor: "transparent",
BorderWidth: "1",
TextColor: "var(--foreground)",
FocusRing: "var(--ring)",
FocusBg: "var(--background)",
FocusBorder: "var(--input)",
HoverBg: "var(--muted)",
PlaceholderColor: "var(--muted-foreground)",
},
},
Cards: &ThemeCards{
BorderWidth: "1",
BorderStyle: "solid",
BorderColor: "var(--border)",
Shadow: "sm",
ShadowColor: "",
BgOpacity: "100",
BackdropBlur: "none",
HoverEffect: "none",
},
}
}
// Theme represents the complete theme configuration
type Theme struct {
LightColors *ColorScheme `json:"lightColors"`
DarkColors *ColorScheme `json:"darkColors"`
Typography *ThemeTypography `json:"typography"`
Spacing *ThemeSpacing `json:"spacing"`
Effects *ThemeEffects `json:"effects"`
Mode string `json:"mode"`
Buttons *ThemeButtons `json:"buttons,omitempty"`
Inputs *ThemeInputs `json:"inputs,omitempty"`
Cards *ThemeCards `json:"cards,omitempty"`
CustomColors []*CustomColor `json:"customColors,omitempty"`
}
// CustomColor represents a user-defined color variable with light and dark mode values
type CustomColor struct {
Name string `json:"name"` // Variable name (e.g., "brand") - becomes --color-{name}
LightValue string `json:"lightValue"` // HSL value for light mode
DarkValue string `json:"darkValue"` // HSL value for dark mode
Source string `json:"source,omitempty"` // "" = user-created, "pluginname" = plugin-provisioned
}
// ColorScheme represents colors for a single mode (light or dark)
type ColorScheme struct {
Background string `json:"background"`
Foreground string `json:"foreground"`
Card string `json:"card"`
CardForeground string `json:"cardForeground"`
Popover string `json:"popover"`
PopoverForeground string `json:"popoverForeground"`
Primary string `json:"primary"`
PrimaryForeground string `json:"primaryForeground"`
Secondary string `json:"secondary"`
SecondaryForeground string `json:"secondaryForeground"`
Muted string `json:"muted"`
MutedForeground string `json:"mutedForeground"`
Accent string `json:"accent"`
AccentForeground string `json:"accentForeground"`
Destructive string `json:"destructive"`
DestructiveForeground string `json:"destructiveForeground"`
Border string `json:"border"`
Input string `json:"input"`
Ring string `json:"ring"`
}
// ThemeTypography represents typography settings
type ThemeTypography struct {
FontHeading string `json:"fontHeading"`
FontBody string `json:"fontBody"`
FontMono string `json:"fontMono"`
FontSizeBase string `json:"fontSizeBase"`
LineHeightBase string `json:"lineHeightBase"`
FontWeightBase string `json:"fontWeightBase"`
}
// ThemeSpacing represents spacing settings
type ThemeSpacing struct {
Radius string `json:"radius"`
ButtonRadius string `json:"buttonRadius"`
CardRadius string `json:"cardRadius"`
InputRadius string `json:"inputRadius"`
PillButtons bool `json:"pillButtons"`
BorderWidth string `json:"borderWidth"`
ContainerWidth string `json:"containerWidth"`
SpacingScale string `json:"spacingScale"`
}
// ThemeEffects represents shadow, motion, and effect settings
type ThemeEffects struct {
ShadowIntensity int `json:"shadowIntensity"`
ShadowColor string `json:"shadowColor"`
ShadowBlur string `json:"shadowBlur"`
ElevationPreset string `json:"elevationPreset"`
TransitionSpeed string `json:"transitionSpeed"`
ReduceMotion bool `json:"reduceMotion"`
HoverIntensity string `json:"hoverIntensity"`
}
// ButtonTypeConfig represents configuration for a single button type
type ButtonTypeConfig struct {
BgColor string `json:"bgColor"` // CSS value: "var(--primary)" | "transparent" | HSL
TextColor string `json:"textColor"` // CSS value: "var(--primary-foreground)" | HSL
BorderColor string `json:"borderColor"` // CSS value or empty
BorderWidth string `json:"borderWidth"` // "0" | "1" | "2" (px)
Radius string `json:"radius"` // rem value or "pill"
Shadow string `json:"shadow"` // "none" | "sm" | "md" | "lg"
HoverBg string `json:"hoverBg"` // CSS value for hover
HoverText string `json:"hoverText"` // CSS value for hover
Size string `json:"size"` // "sm" | "md" | "lg" - default button size
}
// ThemeButtons represents all button type configurations
type ThemeButtons struct {
Primary *ButtonTypeConfig `json:"primary"`
Secondary *ButtonTypeConfig `json:"secondary"`
Outline *ButtonTypeConfig `json:"outline"`
Ghost *ButtonTypeConfig `json:"ghost"`
Link *ButtonTypeConfig `json:"link"`
Destructive *ButtonTypeConfig `json:"destructive"`
Custom map[string]*ButtonTypeConfig `json:"custom,omitempty"`
}
// InputVariantConfig represents configuration for a single input variant
type InputVariantConfig struct {
BgColor string `json:"bgColor"` // CSS value: "var(--background)" | "var(--muted)" | transparent
BorderColor string `json:"borderColor"` // CSS value: "var(--input)" | "var(--border)"
BorderWidth string `json:"borderWidth"` // "0" | "1" | "2" (px)
TextColor string `json:"textColor"` // CSS value: "var(--foreground)"
FocusRing string `json:"focusRing"` // CSS value: "var(--ring)"
FocusBg string `json:"focusBg"` // Background when focused
FocusBorder string `json:"focusBorder"` // Border when focused
HoverBg string `json:"hoverBg"` // Background on hover (for ghost variant)
PlaceholderColor string `json:"placeholderColor"` // Placeholder text color
}
// ThemeInputs represents all input variant configurations
type ThemeInputs struct {
Default *InputVariantConfig `json:"default"`
Filled *InputVariantConfig `json:"filled"`
Outlined *InputVariantConfig `json:"outlined"`
Ghost *InputVariantConfig `json:"ghost"`
}
// ThemeCards represents card styling configuration
type ThemeCards struct {
// Border
BorderWidth string `json:"borderWidth"` // "0", "1", "2", "4" (px)
BorderStyle string `json:"borderStyle"` // "solid", "dashed", "dotted"
BorderColor string `json:"borderColor"` // CSS var or HSL value
// Shadow
Shadow string `json:"shadow"` // "none", "sm", "md", "lg", "xl"
ShadowColor string `json:"shadowColor"` // HSL for colored shadows
// Glass Effects
BgOpacity string `json:"bgOpacity"` // "100", "90", "80", "70" (percent)
BackdropBlur string `json:"backdropBlur"` // "none", "sm", "md", "lg"
// Hover Effects
HoverEffect string `json:"hoverEffect"` // "none", "lift", "glow", "scale"
}

View File

@ -0,0 +1,134 @@
package theme
import "strings"
// GoogleFont represents a curated Google Font
type GoogleFont struct {
Family string `json:"family"`
Category string `json:"category"`
Variants []string `json:"variants"`
}
// CuratedGoogleFonts returns the list of popular Google Fonts
// These are embedded in the binary - no API key needed
func CuratedGoogleFonts() []GoogleFont {
return []GoogleFont{
// Sans-serif
{Family: "Inter", Category: "sans-serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Roboto", Category: "sans-serif", Variants: []string{"400", "500", "700", "400i", "700i"}},
{Family: "Open Sans", Category: "sans-serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Lato", Category: "sans-serif", Variants: []string{"400", "700", "400i", "700i"}},
{Family: "Montserrat", Category: "sans-serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Poppins", Category: "sans-serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Source Sans 3", Category: "sans-serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Nunito", Category: "sans-serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Raleway", Category: "sans-serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Work Sans", Category: "sans-serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "DM Sans", Category: "sans-serif", Variants: []string{"400", "500", "700", "400i", "700i"}},
{Family: "Manrope", Category: "sans-serif", Variants: []string{"400", "500", "600", "700"}},
{Family: "Plus Jakarta Sans", Category: "sans-serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Outfit", Category: "sans-serif", Variants: []string{"400", "500", "600", "700"}},
{Family: "Lexend", Category: "sans-serif", Variants: []string{"400", "500", "600", "700"}},
{Family: "Figtree", Category: "sans-serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Geist", Category: "sans-serif", Variants: []string{"400", "500", "600", "700"}},
// Serif
{Family: "Playfair Display", Category: "serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Merriweather", Category: "serif", Variants: []string{"400", "700", "400i", "700i"}},
{Family: "Lora", Category: "serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "PT Serif", Category: "serif", Variants: []string{"400", "700", "400i", "700i"}},
{Family: "Source Serif 4", Category: "serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Libre Baskerville", Category: "serif", Variants: []string{"400", "700", "400i"}},
{Family: "Crimson Text", Category: "serif", Variants: []string{"400", "600", "700", "400i", "600i", "700i"}},
{Family: "EB Garamond", Category: "serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Bitter", Category: "serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Cormorant", Category: "serif", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
// Monospace
{Family: "JetBrains Mono", Category: "monospace", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Fira Code", Category: "monospace", Variants: []string{"400", "500", "600", "700"}},
{Family: "Source Code Pro", Category: "monospace", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "IBM Plex Mono", Category: "monospace", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Roboto Mono", Category: "monospace", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Space Mono", Category: "monospace", Variants: []string{"400", "700", "400i", "700i"}},
{Family: "Inconsolata", Category: "monospace", Variants: []string{"400", "500", "600", "700"}},
// Display
{Family: "Oswald", Category: "display", Variants: []string{"400", "500", "600", "700"}},
{Family: "Bebas Neue", Category: "display", Variants: []string{"400"}},
{Family: "Anton", Category: "display", Variants: []string{"400"}},
{Family: "Archivo Black", Category: "display", Variants: []string{"400"}},
{Family: "Righteous", Category: "display", Variants: []string{"400"}},
{Family: "Rubik", Category: "display", Variants: []string{"400", "500", "600", "700", "400i", "700i"}},
{Family: "Space Grotesk", Category: "display", Variants: []string{"400", "500", "600", "700"}},
{Family: "Sora", Category: "display", Variants: []string{"400", "500", "600", "700"}},
// Handwriting
{Family: "Dancing Script", Category: "handwriting", Variants: []string{"400", "500", "600", "700"}},
{Family: "Pacifico", Category: "handwriting", Variants: []string{"400"}},
{Family: "Caveat", Category: "handwriting", Variants: []string{"400", "500", "600", "700"}},
{Family: "Satisfy", Category: "handwriting", Variants: []string{"400"}},
}
}
// BuildGoogleFontURL generates a Google Fonts CSS URL
func BuildGoogleFontURL(family string, variants []string) string {
// Format: https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap
// For italic: family=Inter:ital,wght@0,400;0,700;1,400;1,700
hasItalic := false
weights := []string{}
for _, v := range variants {
if len(v) > 0 && v[len(v)-1] == 'i' {
hasItalic = true
weights = append(weights, v[:len(v)-1])
} else {
weights = append(weights, v)
}
}
// Deduplicate weights
seen := make(map[string]bool)
uniqueWeights := []string{}
for _, w := range weights {
if !seen[w] {
seen[w] = true
uniqueWeights = append(uniqueWeights, w)
}
}
familyEncoded := family
// Replace spaces with +
for i := 0; i < len(familyEncoded); i++ {
if familyEncoded[i] == ' ' {
familyEncoded = familyEncoded[:i] + "+" + familyEncoded[i+1:]
}
}
if hasItalic {
// Use ital,wght axis
var specs []string
for _, w := range uniqueWeights {
specs = append(specs, "0,"+w) // normal
}
for _, w := range uniqueWeights {
specs = append(specs, "1,"+w) // italic
}
return "https://fonts.googleapis.com/css2?family=" + familyEncoded + ":ital,wght@" + joinStrings(specs, ";") + "&display=swap"
}
return "https://fonts.googleapis.com/css2?family=" + familyEncoded + ":wght@" + joinStrings(uniqueWeights, ";") + "&display=swap"
}
func joinStrings(strs []string, sep string) string {
if len(strs) == 0 {
return ""
}
var result strings.Builder
result.WriteString(strs[0])
for i := 1; i < len(strs); i++ {
result.WriteString(sep + strs[i])
}
return result.String()
}

265
lint.go Normal file
View File

@ -0,0 +1,265 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
type goLintTarget struct {
label string
moduleRoot string
}
type goLintFailure struct {
target string
stage string
output string
}
type frontendLintTarget struct {
label string
packageRoot string
sourceDir string
sourceArg string
configPath string
pluginRules bool
managedConfigs bool
tsconfigPath string
hasTypeScript bool
}
type frontendLintFailure struct {
target string
stage string
output string
}
func discoverGoLintTargets(repoRoot string, backendDirs []string, pluginRoots []string) ([]goLintTarget, []string, error) {
var targets []goLintTarget
var skipped []string
seen := make(map[string]bool)
candidates := append(append([]string{}, backendDirs...), pluginRoots...)
for _, candidate := range candidates {
moduleRoot, ok := findNearestGoModuleRoot(candidate)
if !ok {
skipped = append(skipped, fmt.Sprintf("%s (no go.mod found)", displayLintPath(repoRoot, candidate)))
continue
}
if seen[moduleRoot] {
continue
}
seen[moduleRoot] = true
targets = append(targets, goLintTarget{
label: displayLintPath(repoRoot, moduleRoot),
moduleRoot: moduleRoot,
})
}
sort.Slice(targets, func(i, j int) bool {
return targets[i].label < targets[j].label
})
return targets, skipped, nil
}
func discoverFrontendLintTargets(repoRoot string, scanTargets []frontendScanTarget) ([]frontendLintTarget, []string, error) {
var targets []frontendLintTarget
var skipped []string
seen := make(map[string]bool)
for _, scanTarget := range scanTargets {
absCandidate, err := filepath.Abs(scanTarget.dir)
if err != nil {
return nil, nil, err
}
if seen[absCandidate] {
continue
}
seen[absCandidate] = true
info, err := os.Stat(absCandidate)
if err != nil || !info.IsDir() {
continue
}
packageRoot, ok := findNearestPackageRoot(absCandidate)
if !ok {
skipped = append(skipped, fmt.Sprintf("%s (no package.json found)", displayLintPath(repoRoot, absCandidate)))
continue
}
if scanTarget.pluginRules || scanTarget.managedConfigs {
if err := ensureDefaultFrontendConfigs(repoRoot, packageRoot); err != nil {
return nil, nil, err
}
}
configPath, ok := findNearestESLintConfig(absCandidate)
if !ok {
skipped = append(skipped, fmt.Sprintf("%s (no ESLint config found)", displayLintPath(repoRoot, absCandidate)))
continue
}
sourceArg, err := filepath.Rel(packageRoot, absCandidate)
if err != nil {
return nil, nil, err
}
if sourceArg == "" {
sourceArg = "."
}
hasTypeScript := dirContainsTypeScript(absCandidate)
tsconfigPath := ""
if hasTypeScript {
tsconfigPath, _ = findNearestTSConfig(packageRoot)
}
targets = append(targets, frontendLintTarget{
label: displayLintPath(repoRoot, absCandidate),
packageRoot: packageRoot,
sourceDir: absCandidate,
sourceArg: sourceArg,
configPath: configPath,
pluginRules: scanTarget.pluginRules,
managedConfigs: scanTarget.managedConfigs,
tsconfigPath: tsconfigPath,
hasTypeScript: hasTypeScript,
})
}
sort.Slice(targets, func(i, j int) bool {
return targets[i].label < targets[j].label
})
return targets, skipped, nil
}
func usesFlatESLintConfig(configPath string) bool {
base := filepath.Base(configPath)
return strings.HasPrefix(base, "eslint.config.")
}
func displayLintPath(repoRoot, path string) string {
if rel, err := filepath.Rel(repoRoot, path); err == nil && !strings.HasPrefix(rel, "..") {
return rel
}
return path
}
func findNearestPackageRoot(start string) (string, bool) {
for dir := start; ; dir = filepath.Dir(dir) {
info, err := os.Stat(filepath.Join(dir, "package.json"))
if err == nil && !info.IsDir() {
return dir, true
}
parent := filepath.Dir(dir)
if parent == dir {
return "", false
}
}
}
func findNearestGoModuleRoot(start string) (string, bool) {
for dir := start; ; dir = filepath.Dir(dir) {
info, err := os.Stat(filepath.Join(dir, "go.mod"))
if err == nil && !info.IsDir() {
return dir, true
}
parent := filepath.Dir(dir)
if parent == dir {
return "", false
}
}
}
func findNearestESLintConfig(start string) (string, bool) {
for dir := start; ; dir = filepath.Dir(dir) {
for _, name := range []string{
"eslint.config.js",
"eslint.config.mjs",
"eslint.config.cjs",
".eslintrc",
".eslintrc.js",
".eslintrc.cjs",
".eslintrc.json",
".eslintrc.yaml",
".eslintrc.yml",
} {
path := filepath.Join(dir, name)
info, err := os.Stat(path)
if err == nil && !info.IsDir() {
return path, true
}
}
if packageJSONHasESLintConfig(filepath.Join(dir, "package.json")) {
return filepath.Join(dir, "package.json"), true
}
parent := filepath.Dir(dir)
if parent == dir {
return "", false
}
}
}
func findNearestTSConfig(start string) (string, bool) {
for dir := start; ; dir = filepath.Dir(dir) {
for _, name := range []string{
"tsconfig.json",
"tsconfig.app.json",
"tsconfig.build.json",
} {
path := filepath.Join(dir, name)
info, err := os.Stat(path)
if err == nil && !info.IsDir() {
return path, true
}
}
parent := filepath.Dir(dir)
if parent == dir {
return "", false
}
}
}
func dirContainsTypeScript(root string) bool {
found := false
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || found {
return nil
}
if info.IsDir() {
switch info.Name() {
case "node_modules", "dist", "build", ".git":
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(path, ".ts") || strings.HasSuffix(path, ".tsx") {
found = true
}
return nil
})
return found
}
func packageJSONHasESLintConfig(path string) bool {
content, err := os.ReadFile(path)
if err != nil {
return false
}
var payload map[string]json.RawMessage
if err := json.Unmarshal(content, &payload); err != nil {
return false
}
_, ok := payload["eslintConfig"]
return ok
}

522
lint_pipeline.go Normal file
View File

@ -0,0 +1,522 @@
package main
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"git.dev.alexdunmow.com/block/check-safety/internal/helpers"
)
const defaultPrettierConfig = `module.exports = {
arrowParens: "always",
bracketSameLine: true,
bracketSpacing: true,
experimentalTernaries: true,
printWidth: 200,
quoteProps: "consistent",
semi: true,
singleQuote: false,
tabWidth: 4,
trailingComma: "all",
useTabs: true,
};
`
func runStrictGoLint(repoRoot string, backendDirs []string, pluginRoots []string) (completed []string, skipped []string, failures []goLintFailure, err error) {
targets, skipped, err := discoverGoLintTargets(repoRoot, backendDirs, pluginRoots)
if err != nil {
return nil, nil, nil, err
}
goBin, err := exec.LookPath("go")
if err != nil {
return nil, nil, nil, fmt.Errorf("go not found in PATH")
}
golangciLint, err := exec.LookPath("golangci-lint")
if err != nil {
return nil, nil, nil, fmt.Errorf("golangci-lint not found in PATH")
}
return runStrictGoLintWithBinaries(targets, skipped, goBin, golangciLint)
}
func runStrictGoLintWithBinaries(targets []goLintTarget, skippedTargets []string, goBin string, golangciLint string) (completed []string, skipped []string, failures []goLintFailure, err error) {
lintArgs := []string{
"run",
"--default=none",
"--enable=errcheck",
"--enable=govet",
"--enable=ineffassign",
"--enable=staticcheck",
"--enable=unused",
"./...",
}
lintFixArgs := []string{
"run",
"--fix",
"--default=none",
"--enable=errcheck",
"--enable=govet",
"--enable=ineffassign",
"--enable=staticcheck",
"--enable=unused",
"./...",
}
for _, target := range targets {
output, runErr := runCommand(target.moduleRoot, goBin, "fix", "./...")
if runErr != nil {
failures = append(failures, goLintFailure{
target: target.label,
stage: "go fix ./...",
output: output,
})
continue
}
output, runErr = runCommand(target.moduleRoot, golangciLint, lintFixArgs...)
if runErr != nil {
failures = append(failures, goLintFailure{
target: target.label,
stage: "golangci-lint run --fix",
output: output,
})
continue
}
output, runErr = runCommand(target.moduleRoot, goBin, "test", "-run=^$", "./...")
if runErr != nil {
failures = append(failures, goLintFailure{
target: target.label,
stage: "go test -run=^$ ./...",
output: output,
})
continue
}
output, runErr = runCommand(target.moduleRoot, goBin, "vet", "./...")
if runErr != nil {
failures = append(failures, goLintFailure{
target: target.label,
stage: "go vet ./...",
output: output,
})
continue
}
output, runErr = runCommand(target.moduleRoot, golangciLint, lintArgs...)
if runErr != nil {
failures = append(failures, goLintFailure{
target: target.label,
stage: "golangci-lint run",
output: output,
})
continue
}
completed = append(completed, target.label)
}
sort.Strings(completed)
sort.Strings(skippedTargets)
return completed, skippedTargets, failures, nil
}
func runFrontendAutoFixAndLint(repoRoot string, scanTargets []frontendScanTarget) (completed []string, skipped []string, failures []frontendLintFailure, err error) {
targets, skipped, err := discoverFrontendLintTargets(repoRoot, scanTargets)
if err != nil {
return nil, nil, nil, err
}
toolRepoRoot := blockNinjaRepoRoot()
for _, target := range targets {
if target.hasTypeScript {
if target.tsconfigPath == "" {
failures = append(failures, frontendLintFailure{
target: target.label,
stage: "tsc --noEmit",
output: "TypeScript sources found but no tsconfig.json was located for this frontend target",
})
continue
}
tscBin, err := resolveRequiredBinaryWithPath("tsc",
filepath.Join(target.packageRoot, "node_modules", ".bin", "tsc"),
filepath.Join(repoRoot, "web", "node_modules", ".bin", "tsc"),
filepath.Join(repoRoot, "node_modules", ".bin", "tsc"),
filepath.Join(toolRepoRoot, "web", "node_modules", ".bin", "tsc"),
filepath.Join(toolRepoRoot, "node_modules", ".bin", "tsc"),
)
if err != nil {
return nil, nil, nil, err
}
output, runErr := runFrontendTypecheck(repoRoot, toolRepoRoot, target, tscBin)
if runErr != nil {
failures = append(failures, frontendLintFailure{
target: target.label,
stage: "tsc --noEmit",
output: output,
})
continue
}
}
prettierBin, err := resolveRequiredBinaryWithPath("prettier",
filepath.Join(target.packageRoot, "node_modules", ".bin", "prettier"),
filepath.Join(repoRoot, "node_modules", ".bin", "prettier"),
filepath.Join(toolRepoRoot, "node_modules", ".bin", "prettier"),
)
if err != nil {
return nil, nil, nil, err
}
eslintBin, err := resolveRequiredBinaryWithPath("eslint", eslintBinaryCandidates(repoRoot, toolRepoRoot, target)...)
if err != nil {
return nil, nil, nil, err
}
if output, runErr := runCommand(target.packageRoot, prettierBin, "--write", target.sourceArg); runErr != nil {
failures = append(failures, frontendLintFailure{
target: target.label,
stage: "prettier --write",
output: output,
})
continue
}
if output, runErr := runCommand(target.packageRoot, eslintBin, eslintArgs(target, true)...); runErr != nil {
failures = append(failures, frontendLintFailure{
target: target.label,
stage: "eslint --fix",
output: output,
})
continue
}
if output, runErr := runCommand(target.packageRoot, eslintBin, eslintArgs(target, false)...); runErr != nil {
failures = append(failures, frontendLintFailure{
target: target.label,
stage: "eslint",
output: output,
})
continue
}
completed = append(completed, target.label)
}
sort.Strings(completed)
sort.Strings(skipped)
return completed, skipped, failures, nil
}
func eslintArgs(target frontendLintTarget, fix bool) []string {
args := []string{
target.sourceArg,
"--no-error-on-unmatched-pattern",
}
if !usesFlatESLintConfig(target.configPath) {
args = append(args, "--ext", ".js,.jsx,.ts,.tsx")
}
if fix {
args = append(args, "--fix")
} else {
args = append(args, "--max-warnings", "0")
}
return args
}
func eslintBinaryCandidates(repoRoot, toolRepoRoot string, target frontendLintTarget) []string {
workspaceCandidates := []string{
filepath.Join(repoRoot, "web", "node_modules", ".bin", "eslint"),
filepath.Join(repoRoot, "node_modules", ".bin", "eslint"),
filepath.Join(toolRepoRoot, "web", "node_modules", ".bin", "eslint"),
filepath.Join(toolRepoRoot, "node_modules", ".bin", "eslint"),
}
localCandidate := filepath.Join(target.packageRoot, "node_modules", ".bin", "eslint")
if target.managedConfigs {
return append(workspaceCandidates, localCandidate)
}
return append([]string{localCandidate}, workspaceCandidates...)
}
func runFrontendTypecheck(repoRoot, toolRepoRoot string, target frontendLintTarget, tscBin string) (string, error) {
if target.pluginRules {
return runPluginFrontendTypecheck(repoRoot, toolRepoRoot, target, tscBin)
}
tsconfigArg, err := filepath.Rel(target.packageRoot, target.tsconfigPath)
if err != nil {
return "", err
}
return runCommand(
target.packageRoot,
tscBin,
"--noEmit",
"--pretty", "false",
"-p", tsconfigArg,
)
}
func runPluginFrontendTypecheck(repoRoot, toolRepoRoot string, target frontendLintTarget, tscBin string) (string, error) {
hostNodeModules, err := resolvePluginWorkspaceNodeModules(repoRoot, toolRepoRoot)
if err != nil {
return "", err
}
tempRoot, err := os.MkdirTemp("", "check-safety-plugin-tsc-*")
if err != nil {
return "", err
}
defer helpers.LogDeferredError(nil, "remove temporary plugin typecheck root", func() error {
return os.RemoveAll(tempRoot)
}, "path", tempRoot)
tempPackageRoot := filepath.Join(tempRoot, "package")
if err := copyFrontendPackageForTypecheck(target.packageRoot, tempPackageRoot); err != nil {
return "", err
}
tempNodeModules := filepath.Join(tempPackageRoot, "node_modules")
if err := os.MkdirAll(tempNodeModules, 0755); err != nil {
return "", err
}
localNodeModules := filepath.Join(target.packageRoot, "node_modules")
if dirExists(localNodeModules) {
if err := mergeNodeModules(localNodeModules, tempNodeModules); err != nil {
return "", err
}
}
if err := mergeNodeModules(hostNodeModules, tempNodeModules); err != nil {
return "", err
}
relTSConfig, err := filepath.Rel(target.packageRoot, target.tsconfigPath)
if err != nil {
return "", err
}
return runCommand(
tempPackageRoot,
tscBin,
"--noEmit",
"--pretty", "false",
"-p", relTSConfig,
)
}
func resolveRequiredBinaryWithPath(binaryName string, candidates ...string) (string, error) {
for _, candidate := range candidates {
info, err := os.Stat(candidate)
if err == nil && !info.IsDir() {
return candidate, nil
}
}
if binaryName != "" {
if path, err := exec.LookPath(binaryName); err == nil {
return path, nil
}
}
return "", fmt.Errorf("required frontend tool not found: %s", strings.Join(candidates, ", "))
}
func resolvePluginWorkspaceNodeModules(repoRoot, toolRepoRoot string) (string, error) {
for _, candidate := range []string{
filepath.Join(repoRoot, "web", "node_modules"),
filepath.Join(toolRepoRoot, "web", "node_modules"),
} {
info, err := os.Stat(candidate)
if err == nil && info.IsDir() {
return candidate, nil
}
}
return "", fmt.Errorf("plugin frontend typecheck requires a BlockNinja workspace node_modules directory")
}
func mergeNodeModules(srcRoot, dstRoot string) error {
entries, err := os.ReadDir(srcRoot)
if err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(srcRoot, entry.Name())
dstPath := filepath.Join(dstRoot, entry.Name())
if entry.Name() == ".bin" || strings.HasPrefix(entry.Name(), "@") {
if err := os.MkdirAll(dstPath, 0755); err != nil {
return err
}
if err := mergeNodeModules(srcPath, dstPath); err != nil {
return err
}
continue
}
if _, err := os.Lstat(dstPath); err == nil {
continue
} else if !os.IsNotExist(err) {
return err
}
if err := os.Symlink(srcPath, dstPath); err != nil {
return err
}
}
return nil
}
func copyFrontendPackageForTypecheck(srcRoot, dstRoot string) error {
return filepath.Walk(srcRoot, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(srcRoot, path)
if err != nil {
return err
}
if relPath == "." {
return os.MkdirAll(dstRoot, 0755)
}
if info.IsDir() {
switch info.Name() {
case "node_modules", "dist", "build", ".git":
return filepath.SkipDir
}
return os.MkdirAll(filepath.Join(dstRoot, relPath), info.Mode())
}
dstPath := filepath.Join(dstRoot, relPath)
return copyFile(path, dstPath, info.Mode())
})
}
func copyFile(srcPath, dstPath string, mode os.FileMode) error {
src, err := os.Open(srcPath)
if err != nil {
return err
}
defer helpers.LogDeferredError(nil, "close lint temp source file", src.Close, "path", srcPath)
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
return err
}
dst, err := os.OpenFile(dstPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
if err != nil {
return err
}
defer helpers.LogDeferredError(nil, "close lint temp destination file", dst.Close, "path", dstPath)
if _, err := io.Copy(dst, src); err != nil {
return err
}
return nil
}
func blockNinjaRepoRoot() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, "src", "blockninja", "cms")
}
func orchestratorRepoRoot() string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, "src", "blockninja", "orchestrator")
}
func ensureDefaultFrontendConfigs(repoRoot, packageRoot string) error {
if err := ensureDefaultESLintConfig(repoRoot, packageRoot); err != nil {
return err
}
if err := ensureDefaultPrettierConfig(repoRoot, packageRoot); err != nil {
return err
}
return nil
}
func ensureDefaultESLintConfig(repoRoot, packageRoot string) error {
if _, ok := findNearestESLintConfig(packageRoot); ok {
return nil
}
configPath := filepath.Join(packageRoot, "eslint.config.js")
sourceConfig := filepath.Join(repoRoot, "web", "eslint.config.js")
if !fileExists(sourceConfig) {
sourceConfig = filepath.Join(blockNinjaRepoRoot(), "web", "eslint.config.js")
}
relImport, err := filepath.Rel(packageRoot, sourceConfig)
if err != nil {
return err
}
relImport = filepath.ToSlash(relImport)
if !strings.HasPrefix(relImport, ".") {
relImport = "./" + relImport
}
content := fmt.Sprintf("import config from %q\n\nexport default config\n", relImport)
return os.WriteFile(configPath, []byte(content), 0644)
}
func ensureDefaultPrettierConfig(repoRoot, packageRoot string) error {
for _, name := range []string{
"prettier.config.js",
"prettier.config.mjs",
"prettier.config.cjs",
".prettierrc",
".prettierrc.js",
".prettierrc.cjs",
".prettierrc.json",
".prettierrc.yaml",
".prettierrc.yml",
} {
if info, err := os.Stat(filepath.Join(packageRoot, name)); err == nil && !info.IsDir() {
return nil
}
}
configPath := filepath.Join(packageRoot, "prettier.config.cjs")
return os.WriteFile(configPath, []byte(defaultPrettierConfig), 0644)
}
func runCommand(workdir string, name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
cmd.Dir = workdir
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
output := strings.TrimSpace(stdout.String())
errOutput := strings.TrimSpace(stderr.String())
switch {
case output == "" && errOutput == "":
return "", err
case output == "":
return errOutput, err
case errOutput == "":
return output, err
default:
return output + "\n" + errOutput, err
}
}

717
lint_test.go Normal file
View File

@ -0,0 +1,717 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestCheckSafetySkipsRPCCheckForPluginWithoutRPCs(t *testing.T) {
pluginRoot := t.TempDir()
sdkVersion, err := currentCMSCoreSDKVersion()
if err != nil {
t.Fatalf("currentCMSCoreSDKVersion: %v", err)
}
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/core "+sdkVersion+"\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), "package example\n", 0644)
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
cmd := exec.Command("go", "run", ".", pluginRoot)
cmd.Dir = cwd
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go run check-safety: %v\n%s", err, output)
}
got := string(output)
if !strings.Contains(got, "=== Check 2: RPC methods registered in RBAC interceptor ===") {
t.Fatalf("output missing Check 2 header:\n%s", got)
}
if !strings.Contains(got, "SKIP: no proto-backed RPC procedures found in scanned target(s)") {
t.Fatalf("output missing no-RPC plugin skip:\n%s", got)
}
}
func TestCheckSafetyTypechecksExternalPluginFrontendWithWorkspaceDeps(t *testing.T) {
pluginRoot := t.TempDir()
pluginWebDir := filepath.Join(pluginRoot, "web")
sdkVersion, err := currentCMSCoreSDKVersion()
if err != nil {
t.Fatalf("currentCMSCoreSDKVersion: %v", err)
}
realRepoRoot := blockNinjaRepoRoot()
relESLintConfig, err := filepath.Rel(pluginWebDir, filepath.Join(realRepoRoot, "web", "eslint.config.js"))
if err != nil {
t.Fatalf("rel eslint config: %v", err)
}
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/core "+sdkVersion+"\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), "package example\n", 0644)
writeTestFile(t, filepath.Join(pluginWebDir, "package.json"), "{\n \"name\": \"external-plugin-test\",\n \"private\": true,\n \"type\": \"module\",\n \"dependencies\": {\n \"@block-ninja/ui\": \"workspace:*\"\n }\n}\n", 0644)
writeTestFile(t, filepath.Join(pluginWebDir, "tsconfig.json"), "{\n \"compilerOptions\": {\n \"target\": \"ES2020\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\",\n \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n \"jsx\": \"react-jsx\",\n \"strict\": true,\n \"skipLibCheck\": true,\n \"noEmit\": true\n },\n \"include\": [\"*.tsx\", \"*.ts\"]\n}\n", 0644)
writeTestFile(t, filepath.Join(pluginWebDir, "eslint.config.js"), "import config from "+strconv.Quote(filepath.ToSlash(relESLintConfig))+"\n\nexport default config\n", 0644)
writeTestFile(t, filepath.Join(pluginWebDir, "editor.tsx"), "import { Separator } from '@block-ninja/ui'\n\nexport function Editor() {\n\treturn <Separator />\n}\n", 0644)
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
cmd := exec.Command("go", "run", ".", pluginRoot)
cmd.Dir = cwd
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("go run check-safety: %v\n%s", err, output)
}
got := string(output)
if !strings.Contains(got, "=== Check 4: Frontend auto-format, lint, and typecheck ===") {
t.Fatalf("output missing Check 4 header:\n%s", got)
}
if !strings.Contains(got, "OK: Prettier, ESLint, and TypeScript checks clean for 1 frontend target") {
t.Fatalf("output missing successful frontend typecheck result:\n%s", got)
}
if strings.Contains(got, "Cannot find module '@block-ninja/ui'") {
t.Fatalf("output still shows unresolved workspace dependency:\n%s", got)
}
}
func TestCheckSafetyFailsStandalonePluginImportsFromBlockNinjaCMS(t *testing.T) {
pluginRoot := t.TempDir()
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), `package example
import "git.dev.alexdunmow.com/block/ninja/internal/helpers"
func Example() string {
return helpers.Slugify("danger")
}
`, 0644)
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
cmd := exec.Command("go", "run", ".", pluginRoot)
cmd.Dir = cwd
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("expected go run check-safety to fail\n%s", output)
}
got := string(output)
if !strings.Contains(got, "=== Check 2c: Standalone plugin SDK import boundaries ===") {
t.Fatalf("output missing Check 2c header:\n%s", got)
}
if !strings.Contains(got, "imports BlockNinja CMS package") {
t.Fatalf("output missing forbidden BlockNinja import message:\n%s", got)
}
if !strings.Contains(got, "use git.dev.alexdunmow.com/block/core") {
t.Fatalf("output missing remediation text:\n%s", got)
}
}
func TestCheckSafetyFailsStandalonePluginReplaceDirective(t *testing.T) {
pluginRoot := t.TempDir()
sdkVersion, err := currentCMSCoreSDKVersion()
if err != nil {
t.Fatalf("currentCMSCoreSDKVersion: %v", err)
}
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/core "+sdkVersion+"\n\nreplace git.dev.alexdunmow.com/block/core => ../block-core\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), "package example\n", 0644)
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
cmd := exec.Command("go", "run", ".", pluginRoot)
cmd.Dir = cwd
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("expected go run check-safety to fail\n%s", output)
}
got := string(output)
if !strings.Contains(got, "no replace directives") {
t.Fatalf("output missing no replace guidance:\n%s", got)
}
if !strings.Contains(got, "replace directive present") {
t.Fatalf("output missing replace violation detail:\n%s", got)
}
}
func TestCheckSafetyFailsStandalonePluginSQLCUUIDStringOverrides(t *testing.T) {
pluginRoot := t.TempDir()
sdkVersion, err := currentCMSCoreSDKVersion()
if err != nil {
t.Fatalf("currentCMSCoreSDKVersion: %v", err)
}
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/core "+sdkVersion+"\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), "package example\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "sqlc.yaml"), `version: "2"
sql:
- engine: "postgresql"
gen:
go:
overrides:
- db_type: "uuid"
go_type: "string"
- db_type: "uuid"
nullable: true
go_type:
type: "string"
pointer: true
`, 0644)
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
cmd := exec.Command("go", "run", ".", pluginRoot)
cmd.Dir = cwd
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("expected go run check-safety to fail\n%s", output)
}
got := string(output)
if !strings.Contains(got, "=== Check 2d: sqlc UUID overrides in plugins and cmd ===") {
t.Fatalf("output missing Check 2d header:\n%s", got)
}
if !strings.Contains(got, "github.com/google/uuid.UUID") {
t.Fatalf("output missing UUID remediation:\n%s", got)
}
if !strings.Contains(got, "github.com/google/uuid.NullUUID") {
t.Fatalf("output missing NullUUID remediation:\n%s", got)
}
}
func TestCheckSafetyWarnsOnStandalonePluginGoAnyUsage(t *testing.T) {
pluginRoot := t.TempDir()
sdkVersion, err := currentCMSCoreSDKVersion()
if err != nil {
t.Fatalf("currentCMSCoreSDKVersion: %v", err)
}
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/core "+sdkVersion+"\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), `package example
type Payload struct {
Value any
}
`, 0644)
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
cmd := exec.Command("go", "run", ".", pluginRoot)
cmd.Dir = cwd
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("expected go run check-safety to pass with warning: %v\n%s", err, output)
}
got := string(output)
if !strings.Contains(got, "=== Check 2e: Warn on any usage in Go and TypeScript ===") {
t.Fatalf("output missing Check 2e header:\n%s", got)
}
if !strings.Contains(got, "WARN:") || !strings.Contains(got, "[go]") {
t.Fatalf("output missing Go any warning:\n%s", got)
}
}
func TestCheckSafetyFailsStandalonePluginSQLCCompile(t *testing.T) {
pluginRoot := t.TempDir()
sdkVersion, err := currentCMSCoreSDKVersion()
if err != nil {
t.Fatalf("currentCMSCoreSDKVersion: %v", err)
}
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/core "+sdkVersion+"\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), "package example\n", 0644)
writeTestFile(t, filepath.Join(pluginRoot, "sqlc.yaml"), `version: "2"
sql:
- engine: "postgresql"
queries: "sql/queries"
schema: "sql/schema.sql"
gen:
go:
package: "db"
out: "db"
`, 0644)
cwd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
cmd := exec.Command("go", "run", ".", pluginRoot)
cmd.Dir = cwd
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("expected go run check-safety to fail\n%s", output)
}
got := string(output)
if !strings.Contains(got, "=== Check 2f: sqlc compile and buf generate ===") {
t.Fatalf("output missing Check 2f header:\n%s", got)
}
if !strings.Contains(got, "[sqlc compile]") {
t.Fatalf("output missing sqlc compile stage:\n%s", got)
}
}
func TestRunFrontendAutoFixAndLintFailsTypeScriptTargetsAtTSC(t *testing.T) {
repoRoot, sourceDir := setupFrontendLintTestPackage(t, frontendLintTestOptions{
withTSConfig: true,
tscScript: "#!/bin/sh\n" +
"echo \"src/index.ts(1,1): error TS2322: Type 'string' is not assignable to type 'number'.\" >&2\n" +
"exit 1\n",
})
completed, skipped, failures, err := runFrontendAutoFixAndLint(repoRoot, []frontendScanTarget{
{dir: sourceDir, label: "frontend/src"},
})
if err != nil {
t.Fatalf("runFrontendAutoFixAndLint() error = %v", err)
}
if len(completed) != 0 {
t.Fatalf("completed = %v, want none", completed)
}
if len(skipped) != 0 {
t.Fatalf("skipped = %v, want none", skipped)
}
if len(failures) != 1 {
t.Fatalf("failures = %d, want 1", len(failures))
}
if failures[0].stage != "tsc --noEmit" {
t.Fatalf("failure stage = %q, want %q", failures[0].stage, "tsc --noEmit")
}
if !strings.Contains(failures[0].output, "TS2322") {
t.Fatalf("failure output = %q, want TS2322", failures[0].output)
}
}
func TestRunFrontendAutoFixAndLintFailsWhenTypeScriptTargetHasNoTSConfig(t *testing.T) {
repoRoot, sourceDir := setupFrontendLintTestPackage(t, frontendLintTestOptions{})
completed, skipped, failures, err := runFrontendAutoFixAndLint(repoRoot, []frontendScanTarget{
{dir: sourceDir, label: "frontend/src"},
})
if err != nil {
t.Fatalf("runFrontendAutoFixAndLint() error = %v", err)
}
if len(completed) != 0 {
t.Fatalf("completed = %v, want none", completed)
}
if len(skipped) != 0 {
t.Fatalf("skipped = %v, want none", skipped)
}
if len(failures) != 1 {
t.Fatalf("failures = %d, want 1", len(failures))
}
if failures[0].stage != "tsc --noEmit" {
t.Fatalf("failure stage = %q, want %q", failures[0].stage, "tsc --noEmit")
}
if !strings.Contains(failures[0].output, "no tsconfig.json was located") {
t.Fatalf("failure output = %q, want missing tsconfig message", failures[0].output)
}
}
func TestRunFrontendAutoFixAndLintRunsTSCBeforePrettierAndESLint(t *testing.T) {
repoRoot, sourceDir := setupFrontendLintTestPackage(t, frontendLintTestOptions{
withTSConfig: true,
tscScript: "#!/bin/sh\n" +
"echo \"src/index.ts(1,1): error TS9999: forced type failure.\" >&2\n" +
"exit 1\n",
})
packageRoot := filepath.Dir(sourceDir)
prettierMarker := filepath.Join(packageRoot, "prettier-ran")
eslintMarker := filepath.Join(packageRoot, "eslint-ran")
writeExecutable(t, filepath.Join(packageRoot, "node_modules", ".bin", "prettier"), "#!/bin/sh\n"+
"echo ran > "+shellQuote(prettierMarker)+"\n"+
"exit 0\n")
writeExecutable(t, filepath.Join(packageRoot, "node_modules", ".bin", "eslint"), "#!/bin/sh\n"+
"echo ran > "+shellQuote(eslintMarker)+"\n"+
"exit 0\n")
_, _, failures, err := runFrontendAutoFixAndLint(repoRoot, []frontendScanTarget{
{dir: sourceDir, label: "frontend/src"},
})
if err != nil {
t.Fatalf("runFrontendAutoFixAndLint() error = %v", err)
}
if len(failures) != 1 {
t.Fatalf("failures = %d, want 1", len(failures))
}
if failures[0].stage != "tsc --noEmit" {
t.Fatalf("failure stage = %q, want %q", failures[0].stage, "tsc --noEmit")
}
if _, err := os.Stat(prettierMarker); !os.IsNotExist(err) {
t.Fatalf("prettier marker exists, want TypeScript failure to short-circuit before prettier")
}
if _, err := os.Stat(eslintMarker); !os.IsNotExist(err) {
t.Fatalf("eslint marker exists, want TypeScript failure to short-circuit before eslint")
}
}
func TestRunFrontendAutoFixAndLintUsesSyntheticWorkspaceForPluginTargets(t *testing.T) {
repoRoot, sourceDir := setupFrontendLintTestPackage(t, frontendLintTestOptions{
withTSConfig: true,
})
hostNodeModules := filepath.Join(repoRoot, "web", "node_modules")
if err := os.MkdirAll(hostNodeModules, 0755); err != nil {
t.Fatalf("mkdir host node_modules: %v", err)
}
hostBinDir := filepath.Join(hostNodeModules, ".bin")
if err := os.MkdirAll(hostBinDir, 0755); err != nil {
t.Fatalf("mkdir host bin dir: %v", err)
}
writeExecutable(t, filepath.Join(hostBinDir, "tsc"), "#!/bin/sh\n"+
"if [ ! -e node_modules/react ]; then echo \"missing plugin dep\" >&2; exit 1; fi\n"+
"if [ ! -e node_modules/@block-ninja/api ]; then echo \"missing blockninja api\" >&2; exit 1; fi\n"+
"if [ ! -e package.json ]; then echo \"missing package\" >&2; exit 1; fi\n"+
"exit 0\n")
writeExecutable(t, filepath.Join(hostBinDir, "prettier"), "#!/bin/sh\nexit 0\n")
writeExecutable(t, filepath.Join(hostBinDir, "eslint"), "#!/bin/sh\nexit 0\n")
if err := os.MkdirAll(filepath.Join(hostNodeModules, "@block-ninja", "api"), 0755); err != nil {
t.Fatalf("mkdir host @block-ninja/api: %v", err)
}
packageRoot := filepath.Dir(sourceDir)
if err := os.MkdirAll(filepath.Join(packageRoot, "node_modules"), 0755); err != nil {
t.Fatalf("mkdir local node_modules: %v", err)
}
writeExecutable(t, filepath.Join(packageRoot, "node_modules", "react"), "#!/bin/sh\nexit 0\n")
completed, skipped, failures, err := runFrontendAutoFixAndLint(repoRoot, []frontendScanTarget{
{dir: sourceDir, label: "plugin/web/src", pluginRules: true},
})
if err != nil {
t.Fatalf("runFrontendAutoFixAndLint() error = %v", err)
}
if len(skipped) != 0 {
t.Fatalf("skipped = %v, want none", skipped)
}
if len(failures) != 0 {
t.Fatalf("failures = %v, want none", failures)
}
if len(completed) != 1 {
t.Fatalf("completed = %v, want one completed target", completed)
}
}
func TestRunFrontendAutoFixAndLintCreatesDefaultConfigsForManagedStandaloneTargets(t *testing.T) {
repoRoot, sourceDir := setupFrontendLintTestPackage(t, frontendLintTestOptions{
withTSConfig: true,
})
packageRoot := filepath.Dir(sourceDir)
if err := os.Remove(filepath.Join(packageRoot, "eslint.config.js")); err != nil {
t.Fatalf("remove eslint config: %v", err)
}
completed, skipped, failures, err := runFrontendAutoFixAndLint(repoRoot, []frontendScanTarget{
{dir: sourceDir, label: "managed/frontend/src", managedConfigs: true},
})
if err != nil {
t.Fatalf("runFrontendAutoFixAndLint() error = %v", err)
}
if len(skipped) != 0 {
t.Fatalf("skipped = %v, want none", skipped)
}
if len(failures) != 0 {
t.Fatalf("failures = %v, want none", failures)
}
if len(completed) != 1 {
t.Fatalf("completed = %v, want one completed target", completed)
}
if _, err := os.Stat(filepath.Join(packageRoot, "eslint.config.js")); err != nil {
t.Fatalf("expected generated eslint config: %v", err)
}
if _, err := os.Stat(filepath.Join(packageRoot, "prettier.config.cjs")); err != nil {
t.Fatalf("expected generated prettier config: %v", err)
}
}
func TestRunFrontendAutoFixAndLintOmitsExtForFlatConfigTargets(t *testing.T) {
repoRoot, sourceDir := setupFrontendLintTestPackage(t, frontendLintTestOptions{
withTSConfig: true,
tscScript: "#!/bin/sh\nexit 0\n",
})
packageRoot := filepath.Dir(sourceDir)
writeExecutable(t, filepath.Join(packageRoot, "node_modules", ".bin", "eslint"), "#!/bin/sh\n"+
"for arg in \"$@\"; do\n"+
" if [ \"$arg\" = \"--ext\" ]; then\n"+
" echo \"unexpected --ext\" >&2\n"+
" exit 1\n"+
" fi\n"+
"done\n"+
"exit 0\n")
completed, skipped, failures, err := runFrontendAutoFixAndLint(repoRoot, []frontendScanTarget{
{dir: sourceDir, label: "frontend/src"},
})
if err != nil {
t.Fatalf("runFrontendAutoFixAndLint() error = %v", err)
}
if len(skipped) != 0 {
t.Fatalf("skipped = %v, want none", skipped)
}
if len(failures) != 0 {
t.Fatalf("failures = %v, want none", failures)
}
if len(completed) != 1 {
t.Fatalf("completed = %v, want one completed target", completed)
}
}
func TestRunFrontendAutoFixAndLintPrefersWorkspaceESLintForManagedTargets(t *testing.T) {
repoRoot, sourceDir := setupFrontendLintTestPackage(t, frontendLintTestOptions{
withTSConfig: true,
tscScript: "#!/bin/sh\nexit 0\n",
})
packageRoot := filepath.Dir(sourceDir)
if err := os.Remove(filepath.Join(packageRoot, "eslint.config.js")); err != nil {
t.Fatalf("remove eslint config: %v", err)
}
writeExecutable(t, filepath.Join(packageRoot, "node_modules", ".bin", "eslint"), "#!/bin/sh\n"+
"echo local-eslint-should-not-run >&2\n"+
"exit 1\n")
workspaceBinDir := filepath.Join(repoRoot, "web", "node_modules", ".bin")
if err := os.MkdirAll(workspaceBinDir, 0755); err != nil {
t.Fatalf("mkdir workspace bin dir: %v", err)
}
writeExecutable(t, filepath.Join(workspaceBinDir, "eslint"), "#!/bin/sh\nexit 0\n")
completed, skipped, failures, err := runFrontendAutoFixAndLint(repoRoot, []frontendScanTarget{
{dir: sourceDir, label: "managed/frontend/src", managedConfigs: true},
})
if err != nil {
t.Fatalf("runFrontendAutoFixAndLint() error = %v", err)
}
if len(skipped) != 0 {
t.Fatalf("skipped = %v, want none", skipped)
}
if len(failures) != 0 {
t.Fatalf("failures = %v, want none", failures)
}
if len(completed) != 1 {
t.Fatalf("completed = %v, want one completed target", completed)
}
}
func TestRunStrictGoLintFailsCompileCheck(t *testing.T) {
_, moduleRoot := setupGoLintTestModule(t)
vetMarker := filepath.Join(t.TempDir(), "vet-ran")
goBin := writeExecutableFile(t, filepath.Join(t.TempDir(), "go"), "#!/bin/sh\n"+
"case \"$1\" in\n"+
" fix)\n"+
" exit 0\n"+
" ;;\n"+
" test)\n"+
" echo \"compile failure: undefined: brokenSymbol\" >&2\n"+
" exit 1\n"+
" ;;\n"+
" vet)\n"+
" echo ran > "+shellQuote(vetMarker)+"\n"+
" exit 0\n"+
" ;;\n"+
"esac\n"+
"exit 0\n")
lintBin := writeExecutableFile(t, filepath.Join(t.TempDir(), "golangci-lint"), "#!/bin/sh\nexit 0\n")
completed, skipped, failures, err := runStrictGoLintWithBinaries([]goLintTarget{
{label: "backend", moduleRoot: moduleRoot},
}, nil, goBin, lintBin)
if err != nil {
t.Fatalf("runStrictGoLintWithBinaries() error = %v", err)
}
if len(completed) != 0 {
t.Fatalf("completed = %v, want none", completed)
}
if len(skipped) != 0 {
t.Fatalf("skipped = %v, want none", skipped)
}
if len(failures) != 1 {
t.Fatalf("failures = %d, want 1", len(failures))
}
if failures[0].stage != "go test -run=^$ ./..." {
t.Fatalf("failure stage = %q, want compile stage", failures[0].stage)
}
if !strings.Contains(failures[0].output, "brokenSymbol") {
t.Fatalf("failure output = %q, want compile error", failures[0].output)
}
if _, err := os.Stat(vetMarker); !os.IsNotExist(err) {
t.Fatalf("vet marker exists, want compile failure to stop before go vet")
}
}
func TestRunStrictGoLintRunsCompileBeforeVetAndFinalLint(t *testing.T) {
_, moduleRoot := setupGoLintTestModule(t)
logPath := filepath.Join(t.TempDir(), "go-lint-order.log")
goBin := writeExecutableFile(t, filepath.Join(t.TempDir(), "go"), "#!/bin/sh\n"+
"echo go-$1 >> "+shellQuote(logPath)+"\n"+
"exit 0\n")
lintBin := writeExecutableFile(t, filepath.Join(t.TempDir(), "golangci-lint"), "#!/bin/sh\n"+
"if [ \"$2\" = \"--fix\" ]; then\n"+
" echo golangci-fix >> "+shellQuote(logPath)+"\n"+
" exit 0\n"+
"fi\n"+
"echo golangci >> "+shellQuote(logPath)+"\n"+
"exit 0\n")
completed, skipped, failures, err := runStrictGoLintWithBinaries([]goLintTarget{
{label: "backend", moduleRoot: moduleRoot},
}, nil, goBin, lintBin)
if err != nil {
t.Fatalf("runStrictGoLintWithBinaries() error = %v", err)
}
if len(skipped) != 0 {
t.Fatalf("skipped = %v, want none", skipped)
}
if len(failures) != 0 {
t.Fatalf("failures = %v, want none", failures)
}
if len(completed) != 1 {
t.Fatalf("completed = %v, want one completed target", completed)
}
content, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read log: %v", err)
}
got := strings.Fields(string(content))
want := []string{"go-fix", "golangci-fix", "go-test", "go-vet", "golangci"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("command order = %v, want %v", got, want)
}
}
func TestRunGoTestTargetsRunsFullPackageTests(t *testing.T) {
moduleRoot := t.TempDir()
logPath := filepath.Join(t.TempDir(), "go-test.log")
goBin := writeExecutableFile(t, filepath.Join(t.TempDir(), "go"), "#!/bin/sh\n"+
"pwd > "+shellQuote(logPath)+"\n"+
"printf ' %s' \"$@\" >> "+shellQuote(logPath)+"\n"+
"exit 0\n")
completed, failures, err := runGoTestTargetsWithBinary([]goTestTarget{
{label: "orchestrator/backend", moduleRoot: moduleRoot, args: []string{"test", "./..."}},
}, goBin)
if err != nil {
t.Fatalf("runGoTestTargetsWithBinary() error = %v", err)
}
if len(failures) != 0 {
t.Fatalf("failures = %v, want none", failures)
}
if len(completed) != 1 || completed[0] != "orchestrator/backend [go test ./...]" {
t.Fatalf("completed = %v, want orchestrator/backend go test", completed)
}
content, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("read log: %v", err)
}
if !strings.Contains(string(content), moduleRoot+"\n test ./...") {
t.Fatalf("go command log = %q, want module root and full test args", content)
}
}
type frontendLintTestOptions struct {
withTSConfig bool
tscScript string
}
func setupFrontendLintTestPackage(t *testing.T, opts frontendLintTestOptions) (repoRoot string, sourceDir string) {
t.Helper()
repoRoot = t.TempDir()
packageRoot := filepath.Join(repoRoot, "frontend")
sourceDir = filepath.Join(packageRoot, "src")
binDir := filepath.Join(packageRoot, "node_modules", ".bin")
for _, dir := range []string{sourceDir, binDir} {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
writeTestFile(t, filepath.Join(packageRoot, "package.json"), "{\n \"name\": \"frontend-test\"\n}\n", 0644)
writeTestFile(t, filepath.Join(packageRoot, "eslint.config.js"), "export default []\n", 0644)
writeTestFile(t, filepath.Join(sourceDir, "index.ts"), "export const value: number = 1\n", 0644)
writeExecutable(t, filepath.Join(binDir, "prettier"), "#!/bin/sh\nexit 0\n")
writeExecutable(t, filepath.Join(binDir, "eslint"), "#!/bin/sh\nexit 0\n")
if opts.withTSConfig {
writeTestFile(t, filepath.Join(packageRoot, "tsconfig.json"), "{\n \"compilerOptions\": {\n \"target\": \"ES2020\"\n },\n \"include\": [\"src/**/*\"]\n}\n", 0644)
}
if opts.tscScript != "" {
writeExecutable(t, filepath.Join(binDir, "tsc"), opts.tscScript)
}
return repoRoot, sourceDir
}
func setupGoLintTestModule(t *testing.T) (repoRoot string, moduleRoot string) {
t.Helper()
repoRoot = t.TempDir()
moduleRoot = filepath.Join(repoRoot, "backend")
if err := os.MkdirAll(moduleRoot, 0755); err != nil {
t.Fatalf("mkdir module root: %v", err)
}
writeTestFile(t, filepath.Join(moduleRoot, "go.mod"), "module example.com/checksafetytest\n\ngo 1.24.0\n", 0644)
writeTestFile(t, filepath.Join(moduleRoot, "main.go"), "package main\n\nfunc main() {}\n", 0644)
return repoRoot, moduleRoot
}
func writeExecutable(t *testing.T, path string, content string) {
t.Helper()
writeTestFile(t, path, content, 0755)
}
func writeExecutableFile(t *testing.T, path string, content string) string {
t.Helper()
writeExecutable(t, path, content)
return path
}
func writeTestFile(t *testing.T, path string, content string, mode os.FileMode) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
}
if err := os.WriteFile(path, []byte(content), mode); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func shellQuote(path string) string {
return "'" + strings.ReplaceAll(path, "'", `'"'"'`) + "'"
}

194
main.go Normal file
View File

@ -0,0 +1,194 @@
// check-safety verifies critical backend and frontend safety invariants.
//
// Run: go run ./cmd/check-safety .
//
// Checks:
// 1. No secret env var reads outside config.Load()
// 2. All RPC methods registered in RBAC interceptor
// 3. Go code compiles and passes go fix, golangci-lint --fix, go vet, and strict lint checks
// 4. Frontend code is auto-formatted, lint-clean, and typecheck-clean
// 5. Frontend uses generated ConnectRPC hooks
// 6. No hardcoded colors in frontend
// 7. No useState for tab state in route files
// 8. Import @block-ninja/api from subpaths only
// 9. No npm/yarn lockfiles (pnpm only)
//
// 10. Button automation attributes
//
// 11. Plugin frontend discovery
// 12. No placeholder code; only shipped features
// 13. No reinvented utilities (use helpers)
// 14. RPC query/mutation error handling
// 15. No raw SQL outside sqlc/Bob
// 16. No err.Error() leaked to HTTP clients
// 17. Services/handlers in correct directories
// 18. No TODO markers in production code
// 19. Plugin segmentation (safety-rules.yml)
// 19. Bearer token precedence over cookies in auth middleware
// 20. Tailwind v4 configuration (PostCSS plugin, CSS directives, @config)
// 21. Plugin presets.json validation (type-safe unmarshal against theme.Theme)
// 22. Standalone plugins must stay on the published SDK boundary (imports, go.mod, version)
// 23. sqlc uuid overrides in standalone plugins and cmd configs must use github.com/google/uuid
// 24. Warn on any usage in Go and TypeScript
// 25. sqlc compile and buf generate must succeed for scanned roots that define them
// 26. Orchestrator backend tests must pass when checking the core BlockNinja repo
// 27. No hand-rolled HTML sanitization — use bluemonday
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
var secretEnvVars = map[string]bool{
"JWT_SECRET": true,
"PUBLIC_JWT_SECRET": true,
"PLATFORM_SECRETS_KEY": true,
"AI_KEY_ENCRYPTION_KEY": true,
"STORAGE_ENCRYPTION_KEY": true,
"COMM_KEY_ENCRYPTION_KEY": true,
"ORCHESTRATOR_SECRET": true,
"SMTP_PASSWORD": true,
"CMS_BUILDER_SECRET": true,
"VAULT_TOKEN": true,
"VAULT_ROLE_ID": true,
"VAULT_SECRET_ID": true,
"CALCOM_API_KEY": true,
}
var exemptSecretEnvVars = map[string]bool{
"VAULT_TOKEN": true, // OpenBao/Vault clients may need ambient token access outside startup.
}
// Files allowed to read secret env vars
func isAllowedEnvReader(path string) bool {
return strings.HasSuffix(path, "config/config.go") || strings.HasSuffix(path, "_test.go")
}
// Services that are internal (not routed through the RBAC interceptor)
// Services that use their own auth middleware instead of the RBAC interceptor
var excludedServices = map[string]bool{
"ManagementService": true, // Orchestrator-to-CMS, uses X-Orchestrator-Secret
// Plugin-managed services: RBAC entries provided via MergeMethodRoles at runtime
"CommunityReviewsService": true,
"CommunityModerationService": true,
"UserEngagementService": true,
"DataSuggestionsService": true,
"WikiService": true, // Symposium plugin
}
const maxAnyWarningsPrinted = 120
func main() {
ctx := buildScanContext()
rep := &Reporter{}
// Checks self-register via init(); run them in declared Seq order.
sort.Slice(registry, func(i, j int) bool { return registry[i].Seq < registry[j].Seq })
for i := range registry {
registry[i].Run(ctx, rep)
}
os.Exit(rep.exitCode)
}
func buildScanContext() *ScanContext {
targetDir := "."
var pluginPageDirs []string
var pluginRoots []string
orchestratorOnly := false
// Parse args: first positional arg is backendDir. Plugin frontend paths may be passed
// as direct source dirs via --plugin-pages or as plugin roots via --plugin-dir.
args := os.Args[1:]
for i := 0; i < len(args); i++ {
if args[i] == "--orchestrator" {
orchestratorOnly = true
} else if args[i] == "--plugin-pages" {
// Consume all following args until next flag or end
for i++; i < len(args) && !strings.HasPrefix(args[i], "--"); i++ {
pluginPageDirs = append(pluginPageDirs, args[i])
}
i-- // back up so outer loop's i++ doesn't skip
} else if args[i] == "--plugin-dir" || args[i] == "--plugin-dirs" {
for i++; i < len(args) && !strings.HasPrefix(args[i], "--"); i++ {
pluginRoots = append(pluginRoots, args[i])
}
i--
} else if !strings.HasPrefix(args[i], "--") && targetDir == "." {
targetDir = args[i]
}
}
cwd, err := os.Getwd()
if err != nil {
fmt.Printf(" ERROR: failed to resolve working directory: %v\n", err)
os.Exit(2)
}
targetDir = defaultScanTargetDir(targetDir, pluginRoots, cwd)
// If the target is a plugin directory (has plugin.mod) and no explicit plugin roots were
// passed, register it automatically so ninjatpl and other plugin-specific checks run.
if len(pluginRoots) == 0 {
if abs, absErr := filepath.Abs(targetDir); absErr == nil && fileExists(filepath.Join(abs, "plugin.mod")) {
pluginRoots = append(pluginRoots, abs)
}
}
backendDir, repoRoot, err := resolveScanRoots(targetDir)
if err != nil {
fmt.Printf(" ERROR: failed to resolve scan roots: %v\n", err)
os.Exit(2)
}
if orchestratorOnly {
orchRoot := orchestratorRepoRoot()
orchBackend := filepath.Join(orchRoot, "backend")
if orchRoot == "" || !dirExists(orchBackend) {
fmt.Printf(" ERROR: orchestrator repo not found at %s\n", orchRoot)
os.Exit(2)
}
backendDir = orchBackend
repoRoot = orchRoot
}
scanPath, err := filepath.Abs(targetDir)
if err != nil {
scanPath = targetDir
}
if orchestratorOnly {
fmt.Printf("(running checks in %s [orchestrator only])\n", repoRoot)
} else {
fmt.Printf("(running checks in %s)\n", scanPath)
}
if !orchestratorOnly && len(pluginRoots) == 0 && len(pluginPageDirs) == 0 {
blockNinjaRepo := blockNinjaRepoRoot()
if samePath(scanPath, blockNinjaRepo) || samePath(scanPath, filepath.Join(blockNinjaRepo, "backend")) {
fmt.Println("(for a plugin: use --plugin-dir <path> or run in the plugin directory)")
}
}
fmt.Println()
includeCoreTargets := shouldIncludeCoreTargets(targetDir, backendDir, pluginRoots, pluginPageDirs)
backendTargets := collectBackendScanTargets(repoRoot, backendDir, includeCoreTargets)
pluginTargets, unresolvedPluginRoots := resolvePluginTargets(repoRoot, pluginRoots)
resolvedPluginTargets := pluginTargets
pluginTargets = filterPluginTargets(pluginTargets, backendDir)
frontendTargets := collectFrontendScanTargets(repoRoot, backendDir, pluginTargets, includeCoreTargets)
frontendTargets = appendManualFrontendTargets(repoRoot, frontendTargets, pluginPageDirs)
return &ScanContext{
repoRoot: repoRoot,
backendDir: backendDir,
orchestratorOnly: orchestratorOnly,
includeCoreTargets: includeCoreTargets,
backendTargets: backendTargets,
pluginTargets: pluginTargets,
resolvedPluginTargets: resolvedPluginTargets,
frontendTargets: frontendTargets,
unresolvedPluginRoots: unresolvedPluginRoots,
pluginPageDirs: pluginPageDirs,
}
}

191
main_test.go Normal file
View File

@ -0,0 +1,191 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDefaultScanTargetDirUsesCWDPlugin(t *testing.T) {
cwd := t.TempDir()
pluginMod := filepath.Join(cwd, "plugin.mod")
if err := os.WriteFile(pluginMod, []byte("[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n"), 0644); err != nil {
t.Fatalf("write plugin.mod: %v", err)
}
got := defaultScanTargetDir(".", nil, cwd)
if got != cwd {
t.Fatalf("defaultScanTargetDir() = %q, want %q", got, cwd)
}
}
func TestDefaultScanTargetDirFallsBackToCoreBackend(t *testing.T) {
cwd := t.TempDir()
got := defaultScanTargetDir(".", nil, cwd)
want := filepath.Join(blockNinjaRepoRoot(), "backend")
if got != want {
t.Fatalf("defaultScanTargetDir() = %q, want %q", got, want)
}
}
func TestShouldIncludeCoreTargetsUsesBackendPathAsToolHostForPluginScans(t *testing.T) {
targetDir := filepath.Join(blockNinjaRepoRoot(), "backend")
if !shouldIncludeCoreTargets(targetDir, targetDir, nil, nil) {
t.Fatalf("expected core targets to be included when no plugin flags are present")
}
if shouldIncludeCoreTargets(targetDir, targetDir, []string{"/tmp/plugin"}, nil) {
t.Fatalf("expected core targets to be skipped when plugin roots are passed with the checker backend path")
}
if shouldIncludeCoreTargets(targetDir, targetDir, nil, []string{"/tmp/plugin/web/src"}) {
t.Fatalf("expected core targets to be skipped when plugin pages are passed with the checker backend path")
}
}
func TestCollectTargetsSkipsCoreWhenIncludeCoreTargetsFalse(t *testing.T) {
repoRoot := t.TempDir()
backendDir := filepath.Join(repoRoot, "backend")
pluginFrontendDir := filepath.Join(repoRoot, "plugins", "example", "web", "src")
for _, dir := range []string{backendDir, pluginFrontendDir} {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
if targets := collectBackendScanTargets(repoRoot, backendDir, false); len(targets) != 0 {
t.Fatalf("collectBackendScanTargets(..., false) returned %d targets, want 0", len(targets))
}
frontendTargets := collectFrontendScanTargets(repoRoot, backendDir, []pluginScanTarget{{
root: filepath.Join(repoRoot, "plugins", "example"),
frontendDir: pluginFrontendDir,
display: "plugins/example",
frontendLabel: "plugins/example/web/src",
}}, false)
if len(frontendTargets) != 1 {
t.Fatalf("collectFrontendScanTargets(..., false) returned %d targets, want 1", len(frontendTargets))
}
if frontendTargets[0].dir != pluginFrontendDir {
t.Fatalf("frontend target dir = %q, want %q", frontendTargets[0].dir, pluginFrontendDir)
}
if !frontendTargets[0].pluginRules {
t.Fatalf("frontend target pluginRules = false, want true")
}
if !frontendTargets[0].managedConfigs {
t.Fatalf("frontend target managedConfigs = false, want true")
}
}
func TestDiscoverOrchestratorTestTargetsRequiresCoreScan(t *testing.T) {
repoRoot := blockNinjaRepoRoot()
backendDir := filepath.Join(repoRoot, "backend")
if targets := discoverOrchestratorTestTargets(repoRoot, backendDir, false); len(targets) != 0 {
t.Fatalf("discoverOrchestratorTestTargets(..., false) returned %d targets, want 0", len(targets))
}
pluginRoot := filepath.Join(t.TempDir(), "plugin")
if targets := discoverOrchestratorTestTargets(pluginRoot, pluginRoot, true); len(targets) != 0 {
t.Fatalf("discoverOrchestratorTestTargets(plugin root) returned %d targets, want 0", len(targets))
}
}
func TestDiscoverOrchestratorTestTargetsFromRoot(t *testing.T) {
root := t.TempDir()
backendDir := filepath.Join(root, "backend")
if err := os.MkdirAll(backendDir, 0755); err != nil {
t.Fatalf("mkdir backend: %v", err)
}
if targets := discoverOrchestratorTestTargetsFromRoot(root); len(targets) != 0 {
t.Fatalf("targets without go.mod = %d, want 0", len(targets))
}
if err := os.WriteFile(filepath.Join(backendDir, "go.mod"), []byte("module example.com/orchestrator\n"), 0644); err != nil {
t.Fatalf("write go.mod: %v", err)
}
targets := discoverOrchestratorTestTargetsFromRoot(root)
if len(targets) != 1 {
t.Fatalf("targets = %d, want 1", len(targets))
}
if targets[0].label != "orchestrator/backend" {
t.Fatalf("label = %q, want orchestrator/backend", targets[0].label)
}
if targets[0].moduleRoot != backendDir {
t.Fatalf("moduleRoot = %q, want %q", targets[0].moduleRoot, backendDir)
}
if got := strings.Join(targets[0].args, " "); got != "test ./..." {
t.Fatalf("args = %q, want test ./...", got)
}
}
func TestResolvePluginTargetsUsesRootFolderNameForDisplay(t *testing.T) {
repoRoot := t.TempDir()
pluginRoot := filepath.Join(t.TempDir(), "messenger")
pluginFrontend := filepath.Join(pluginRoot, "web")
for _, dir := range []string{pluginRoot, pluginFrontend} {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
if err := os.WriteFile(filepath.Join(pluginFrontend, "index.tsx"), []byte("export const x = 1\n"), 0644); err != nil {
t.Fatalf("write frontend file: %v", err)
}
targets, unresolved := resolvePluginTargets(repoRoot, []string{pluginRoot})
if len(unresolved) != 0 {
t.Fatalf("unresolved = %v, want none", unresolved)
}
if len(targets) != 1 {
t.Fatalf("targets = %d, want 1", len(targets))
}
if targets[0].display != "messenger" {
t.Fatalf("display = %q, want messenger", targets[0].display)
}
if targets[0].frontendLabel != "messenger" {
t.Fatalf("frontendLabel = %q, want messenger", targets[0].frontendLabel)
}
}
func TestExtractRBACMethodRolesParsesRoleValues(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "interceptor.go")
if err := os.WriteFile(file, []byte(`package rbac
type Role string
const (
RoleViewer Role = "viewer"
RoleAdmin Role = "admin"
)
var MethodRoles = map[string]Role{
"/example.v1.Service/Public": "",
"/example.v1.Service/View": RoleViewer,
"/example.v1.Service/Admin": RoleAdmin,
}
`), 0644); err != nil {
t.Fatalf("write interceptor.go: %v", err)
}
methods, err := extractRBACMethodRoles(file)
if err != nil {
t.Fatalf("extractRBACMethodRoles() error = %v", err)
}
if methods["/example.v1.Service/Public"] != "public" {
t.Fatalf("public role = %q, want public", methods["/example.v1.Service/Public"])
}
if methods["/example.v1.Service/View"] != "viewer" {
t.Fatalf("viewer role = %q, want viewer", methods["/example.v1.Service/View"])
}
if methods["/example.v1.Service/Admin"] != "admin" {
t.Fatalf("admin role = %q, want admin", methods["/example.v1.Service/Admin"])
}
}

155
pathutil.go Normal file
View File

@ -0,0 +1,155 @@
package main
import (
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
)
func expandPath(path string) (string, error) {
if path == "~" || strings.HasPrefix(path, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
if path == "~" {
return home, nil
}
return filepath.Join(home, strings.TrimPrefix(path, "~/")), nil
}
return path, nil
}
func dirLooksLikeFrontendSource(path string) bool {
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return false
}
entries, err := os.ReadDir(path)
if err != nil {
return false
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasSuffix(name, ".ts") || strings.HasSuffix(name, ".tsx") || strings.HasSuffix(name, ".js") || strings.HasSuffix(name, ".jsx") {
return true
}
}
return false
}
func dirExists(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
func samePath(a, b string) bool {
if a == "" || b == "" {
return false
}
absA, err := filepath.Abs(a)
if err != nil {
return false
}
absB, err := filepath.Abs(b)
if err != nil {
return false
}
return absA == absB
}
func normalizeDisplayLabel(label string) string {
if label == "." {
return ""
}
return label
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func prefixDisplayPath(prefix, path string) string {
if prefix == "" || prefix == "." {
return path
}
if path == "" {
return prefix
}
return prefix + "/" + filepath.ToSlash(path)
}
func shortPluginLabel(path string) string {
cleaned := filepath.Clean(path)
base := filepath.Base(cleaned)
if base == "." || base == string(filepath.Separator) || base == "" {
return path
}
return base
}
func isPluginModuleRoot(root string) bool {
modulePath, err := readModulePath(root)
if err != nil {
return fileExists(filepath.Join(root, "plugin.mod"))
}
return strings.Contains(modulePath, "/internal/plugins/") || fileExists(filepath.Join(root, "plugin.mod"))
}
func containsString(values []string, target string) bool {
return slices.Contains(values, target)
}
func uniqueSortedStrings(values []string) []string {
seen := make(map[string]bool)
var out []string
for _, value := range values {
if value == "" || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
sort.Strings(out)
return out
}
func printPerTargetOKLines(labels []string) {
labels = uniqueSortedStrings(labels)
for _, label := range labels {
if label == "" || label == "." {
continue
}
fmt.Printf(" OK: %s\n", label)
}
}
func frontendTargetLabels(targets []frontendScanTarget) []string {
labels := make([]string, 0, len(targets))
for _, target := range targets {
labels = append(labels, target.label)
}
return labels
}
func pluginTargetLabels(targets []pluginScanTarget) []string {
labels := make([]string, 0, len(targets))
for _, target := range targets {
labels = append(labels, target.display)
}
return labels
}

126
plugin_imports.go Normal file
View File

@ -0,0 +1,126 @@
package main
import (
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
const (
blockCoreImportPrefix = "git.dev.alexdunmow.com/block/core"
blockNinjaImportPrefix = "git.dev.alexdunmow.com/block/ninja"
)
type pluginImportViolation struct {
file string
line int
importPath string
}
var templImportPattern = regexp.MustCompile(`"([^"]+)"`)
func shouldCheckStandalonePluginImports(root string) bool {
return isPluginModuleRoot(root) && !isBundledPluginRoot(root)
}
func isBundledPluginRoot(root string) bool {
bundledPluginsDir := filepath.Join(blockNinjaRepoRoot(), "backend", "internal", "plugins")
absRoot, err := filepath.Abs(root)
if err != nil {
return false
}
absBundledPluginsDir, err := filepath.Abs(bundledPluginsDir)
if err != nil {
return false
}
rel, err := filepath.Rel(absBundledPluginsDir, absRoot)
if err != nil {
return false
}
if rel == "." {
return false
}
return !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && rel != ".."
}
func checkStandalonePluginImports(root string) []pluginImportViolation {
var violations []pluginImportViolation
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
switch info.Name() {
case ".git", "vendor", "node_modules":
return filepath.SkipDir
}
return nil
}
relPath, _ := filepath.Rel(root, path)
if strings.HasSuffix(path, ".templ") {
data, readErr := os.ReadFile(path)
if readErr != nil {
return nil
}
lines := strings.Split(string(data), "\n")
for i, line := range lines {
if !strings.Contains(line, blockNinjaImportPrefix) {
continue
}
matches := templImportPattern.FindAllStringSubmatch(line, -1)
for _, match := range matches {
importPath := match[1]
if !strings.HasPrefix(importPath, blockNinjaImportPrefix) {
continue
}
violations = append(violations, pluginImportViolation{
file: relPath,
line: i + 1,
importPath: importPath,
})
}
}
return nil
}
if !strings.HasSuffix(path, ".go") {
return nil
}
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
if err != nil {
return nil
}
for _, spec := range file.Imports {
importPath, err := strconv.Unquote(spec.Path.Value)
if err != nil {
continue
}
if !strings.HasPrefix(importPath, blockNinjaImportPrefix) {
continue
}
pos := fset.Position(spec.Pos())
violations = append(violations, pluginImportViolation{
file: relPath,
line: pos.Line,
importPath: importPath,
})
}
return nil
})
return violations
}

76
plugin_imports_test.go Normal file
View File

@ -0,0 +1,76 @@
package main
import (
"path/filepath"
"testing"
)
func TestCheckStandalonePluginImportsFlagsBlockNinjaCMSImports(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
writeTestFile(t, filepath.Join(root, "main.go"), `package example
import (
"git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/ninja/internal/helpers"
)
func Example() {
_ = plugin.PluginRegistration{}
_ = helpers.Slugify("x")
}
`, 0644)
violations := checkStandalonePluginImports(root)
if len(violations) != 1 {
t.Fatalf("checkStandalonePluginImports() returned %d violations, want 1: %#v", len(violations), violations)
}
if violations[0].importPath != "git.dev.alexdunmow.com/block/ninja/internal/helpers" {
t.Fatalf("importPath = %q, want forbidden BlockNinja import", violations[0].importPath)
}
}
func TestCheckStandalonePluginImportsAllowsCoreSDKImports(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
writeTestFile(t, filepath.Join(root, "main.go"), `package example
import "git.dev.alexdunmow.com/block/core/plugin"
func Example() {
_ = plugin.PluginRegistration{}
}
`, 0644)
violations := checkStandalonePluginImports(root)
if len(violations) != 0 {
t.Fatalf("checkStandalonePluginImports() returned %d violations, want 0: %#v", len(violations), violations)
}
}
func TestCheckStandalonePluginImportsFlagsBlockNinjaCMSImportsInTemplFiles(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
writeTestFile(t, filepath.Join(root, "page.templ"), `package example
import (
"git.dev.alexdunmow.com/block/core/templates/bn"
"git.dev.alexdunmow.com/block/ninja/internal/templates"
)
templ Page() {
<div></div>
}
`, 0644)
violations := checkStandalonePluginImports(root)
if len(violations) != 1 {
t.Fatalf("checkStandalonePluginImports() returned %d violations, want 1: %#v", len(violations), violations)
}
if violations[0].importPath != "git.dev.alexdunmow.com/block/ninja/internal/templates" {
t.Fatalf("importPath = %q, want forbidden BlockNinja templ import", violations[0].importPath)
}
if violations[0].line != 5 {
t.Fatalf("line = %d, want 5", violations[0].line)
}
}

161
plugin_sdk_versions.go Normal file
View File

@ -0,0 +1,161 @@
package main
import (
"fmt"
"os"
"path/filepath"
"golang.org/x/mod/modfile"
)
type pluginGoModViolation struct {
file string
line int
rule string
detail string
}
func currentCMSCoreSDKVersion() (string, error) {
return readRequiredModuleVersion(filepath.Join(blockNinjaRepoRoot(), "backend", "go.mod"), blockCoreImportPrefix)
}
func parseGoModForSafety(goModPath string) (*modfile.File, []pluginGoModViolation) {
if !fileExists(goModPath) {
return nil, []pluginGoModViolation{{
file: filepath.Base(goModPath),
rule: "missing-go-mod",
detail: fmt.Sprintf("missing %s", filepath.Base(goModPath)),
}}
}
data, err := os.ReadFile(goModPath)
if err != nil {
return nil, []pluginGoModViolation{{
file: filepath.Base(goModPath),
rule: "read-go-mod",
detail: fmt.Sprintf("failed to read go.mod: %v", err),
}}
}
parsed, err := modfile.Parse(goModPath, data, nil)
if err != nil {
return nil, []pluginGoModViolation{{
file: filepath.Base(goModPath),
rule: "parse-go-mod",
detail: fmt.Sprintf("failed to parse go.mod: %v", err),
}}
}
return parsed, nil
}
func newGoModReplaceViolation(parsed *modfile.File, replace *modfile.Replace, rule string) pluginGoModViolation {
detail := fmt.Sprintf("replace directive present for %s", replace.Old.Path)
if replace.New.Path != "" {
detail += fmt.Sprintf(" -> %s", replace.New.Path)
}
if replace.New.Version != "" {
detail += fmt.Sprintf(" %s", replace.New.Version)
}
line := 0
if replace.Syntax != nil {
line = replace.Syntax.Start.Line
}
return pluginGoModViolation{
file: filepath.Base(parsed.Syntax.Name),
line: line,
rule: rule,
detail: detail,
}
}
func checkGoModForAnyReplaceDirectives(goModPath string) []pluginGoModViolation {
parsed, violations := parseGoModForSafety(goModPath)
if parsed == nil {
return violations
}
for _, replace := range parsed.Replace {
violations = append(violations, newGoModReplaceViolation(parsed, replace, "no-replace-directives"))
}
return violations
}
func checkGoModForModuleReplaceDirective(goModPath, modulePath string) []pluginGoModViolation {
parsed, violations := parseGoModForSafety(goModPath)
if parsed == nil {
return violations
}
for _, replace := range parsed.Replace {
if replace.Old.Path != modulePath {
continue
}
violations = append(violations, newGoModReplaceViolation(parsed, replace, "no-local-block-core-replace"))
}
return violations
}
func readRequiredModuleVersion(goModPath, modulePath string) (string, error) {
data, err := os.ReadFile(goModPath)
if err != nil {
return "", err
}
parsed, err := modfile.Parse(goModPath, data, nil)
if err != nil {
return "", err
}
for _, req := range parsed.Require {
if req.Mod.Path == modulePath {
return req.Mod.Version, nil
}
}
return "", fmt.Errorf("%s does not require %s", goModPath, modulePath)
}
func checkCMSCoreSDKGoMod(root string) []pluginGoModViolation {
return checkGoModForModuleReplaceDirective(filepath.Join(root, "go.mod"), blockCoreImportPrefix)
}
func checkStandalonePluginGoMod(root, requiredSDKVersion string) []pluginGoModViolation {
goModPath := filepath.Join(root, "go.mod")
parsed, violations := parseGoModForSafety(goModPath)
if parsed == nil {
if len(violations) == 1 && violations[0].rule == "missing-go-mod" {
violations[0].detail = "standalone plugins must be standalone Go modules with a repo-root go.mod"
}
return violations
}
violations = append(violations, checkGoModForAnyReplaceDirectives(goModPath)...)
currentSDKVersion := ""
for _, req := range parsed.Require {
if req.Mod.Path == blockCoreImportPrefix {
currentSDKVersion = req.Mod.Version
break
}
}
if currentSDKVersion == "" {
violations = append(violations, pluginGoModViolation{
file: "go.mod",
rule: "missing-block-core-require",
detail: fmt.Sprintf("missing required module %s", blockCoreImportPrefix),
})
return violations
}
if requiredSDKVersion != "" && currentSDKVersion != requiredSDKVersion {
violations = append(violations, pluginGoModViolation{
file: "go.mod",
rule: "block-core-version-mismatch",
detail: fmt.Sprintf("requires %s, want %s", currentSDKVersion, requiredSDKVersion),
})
}
return violations
}

104
plugin_sdk_versions_test.go Normal file
View File

@ -0,0 +1,104 @@
package main
import (
"path/filepath"
"strings"
"testing"
)
func TestCheckCMSCoreSDKGoModFlagsLocalBlockCoreReplace(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "go.mod"), `module git.dev.alexdunmow.com/block/ninja
go 1.26.2
require git.dev.alexdunmow.com/block/core v0.5.0
replace git.dev.alexdunmow.com/block/core => ../core
`, 0644)
violations := checkCMSCoreSDKGoMod(root)
if len(violations) != 1 {
t.Fatalf("checkCMSCoreSDKGoMod() returned %d violations, want 1: %#v", len(violations), violations)
}
if violations[0].rule != "no-local-block-core-replace" {
t.Fatalf("rule = %q, want no-local-block-core-replace", violations[0].rule)
}
if violations[0].line != 7 {
t.Fatalf("line = %d, want 7", violations[0].line)
}
}
func TestCheckCMSCoreSDKGoModAllowsPublishedBlockCoreRequire(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "go.mod"), `module git.dev.alexdunmow.com/block/ninja
go 1.26.2
require git.dev.alexdunmow.com/block/core v0.5.0
`, 0644)
violations := checkCMSCoreSDKGoMod(root)
if len(violations) != 0 {
t.Fatalf("checkCMSCoreSDKGoMod() returned %d violations, want 0: %#v", len(violations), violations)
}
}
func TestCheckStandalonePluginGoModFlagsReplaceDirectives(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "go.mod"), `module example.com/plugin
go 1.26.2
require git.dev.alexdunmow.com/block/core v0.2.1
replace git.dev.alexdunmow.com/block/core => ../block-core
`, 0644)
violations := checkStandalonePluginGoMod(root, "v0.2.1")
if len(violations) != 1 {
t.Fatalf("checkStandalonePluginGoMod() returned %d violations, want 1: %#v", len(violations), violations)
}
if violations[0].rule != "no-replace-directives" {
t.Fatalf("rule = %q, want no-replace-directives", violations[0].rule)
}
if violations[0].line != 7 {
t.Fatalf("line = %d, want 7", violations[0].line)
}
}
func TestCheckStandalonePluginGoModFlagsOutdatedSDKVersion(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "go.mod"), `module example.com/plugin
go 1.26.2
require git.dev.alexdunmow.com/block/core v0.2.0
`, 0644)
violations := checkStandalonePluginGoMod(root, "v0.2.1")
if len(violations) != 1 {
t.Fatalf("checkStandalonePluginGoMod() returned %d violations, want 1: %#v", len(violations), violations)
}
if violations[0].rule != "block-core-version-mismatch" {
t.Fatalf("rule = %q, want block-core-version-mismatch", violations[0].rule)
}
if !strings.Contains(violations[0].detail, "want v0.2.1") {
t.Fatalf("detail = %q, want target version", violations[0].detail)
}
}
func TestCheckStandalonePluginGoModAllowsMatchingSDKVersionWithoutReplace(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "go.mod"), `module example.com/plugin
go 1.26.2
require git.dev.alexdunmow.com/block/core v0.2.1
`, 0644)
violations := checkStandalonePluginGoMod(root, "v0.2.1")
if len(violations) != 0 {
t.Fatalf("checkStandalonePluginGoMod() returned %d violations, want 0: %#v", len(violations), violations)
}
}

115
presets.go Normal file
View File

@ -0,0 +1,115 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"git.dev.alexdunmow.com/block/check-safety/internal/theme"
)
type presetViolation struct {
file string
message string
}
type presetJSON struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Theme theme.Theme `json:"theme"`
}
func checkPresets(root string) []presetViolation {
var violations []presetViolation
presetsFiles := findPresetsFiles(root)
for _, path := range presetsFiles {
relPath, _ := filepath.Rel(root, path)
if relPath == "" {
relPath = path
}
data, err := os.ReadFile(path)
if err != nil {
violations = append(violations, presetViolation{
file: relPath,
message: fmt.Sprintf("cannot read file: %v", err),
})
continue
}
// Strict unmarshal: catches type mismatches (e.g., int where string expected)
var presets []presetJSON
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&presets); err != nil {
// Distinguish between unknown fields (warning-worthy) and hard parse errors
if isUnknownFieldError(err) {
// Re-try without strict mode to get the actual parse result
var lenient []presetJSON
if lenientErr := json.Unmarshal(data, &lenient); lenientErr != nil {
violations = append(violations, presetViolation{
file: relPath,
message: fmt.Sprintf("invalid JSON: %v", lenientErr),
})
} else {
violations = append(violations, presetViolation{
file: relPath,
message: fmt.Sprintf("unknown fields (stale preset data): %v", err),
})
}
} else {
violations = append(violations, presetViolation{
file: relPath,
message: fmt.Sprintf("invalid JSON: %v", err),
})
}
continue
}
for i, p := range presets {
if p.ID == "" {
violations = append(violations, presetViolation{
file: relPath,
message: fmt.Sprintf("preset[%d]: missing \"id\" field", i),
})
}
if p.Name == "" {
violations = append(violations, presetViolation{
file: relPath,
message: fmt.Sprintf("preset[%d]: missing \"name\" field", i),
})
}
}
}
return violations
}
func findPresetsFiles(root string) []string {
var files []string
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
if info != nil && info.IsDir() {
switch info.Name() {
case ".git", "vendor", "node_modules", "web", "dist":
return filepath.SkipDir
}
}
return nil
}
if info.Name() == "presets.json" {
files = append(files, path)
}
return nil
})
return files
}
func isUnknownFieldError(err error) bool {
return strings.Contains(err.Error(), "unknown field")
}

283
presets_test.go Normal file
View File

@ -0,0 +1,283 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// minimalValidPreset returns a presets.json that satisfies the strict decoder
// (all known fields, correct types, required id+name).
const minimalValidPresetJSON = `[
{
"id": "test-preset",
"name": "Test Preset",
"description": "A minimal test preset",
"theme": {
"lightColors": {
"background": "0 0% 100%",
"foreground": "240 10% 3.9%",
"card": "0 0% 100%",
"cardForeground": "240 10% 3.9%",
"popover": "0 0% 100%",
"popoverForeground": "240 10% 3.9%",
"primary": "240 5.9% 10%",
"primaryForeground": "0 0% 98%",
"secondary": "240 4.8% 95.9%",
"secondaryForeground": "240 5.9% 10%",
"muted": "240 4.8% 95.9%",
"mutedForeground": "240 3.8% 46.1%",
"accent": "240 4.8% 95.9%",
"accentForeground": "240 5.9% 10%",
"destructive": "0 84.2% 60.2%",
"destructiveForeground": "0 0% 98%",
"border": "240 5.9% 90%",
"input": "240 5.9% 90%",
"ring": "240 5.9% 10%"
},
"darkColors": {
"background": "240 10% 3.9%",
"foreground": "0 0% 98%",
"card": "240 10% 3.9%",
"cardForeground": "0 0% 98%",
"popover": "240 10% 3.9%",
"popoverForeground": "0 0% 98%",
"primary": "0 0% 98%",
"primaryForeground": "240 5.9% 10%",
"secondary": "240 3.7% 15.9%",
"secondaryForeground": "0 0% 98%",
"muted": "240 3.7% 15.9%",
"mutedForeground": "240 5% 64.9%",
"accent": "240 3.7% 15.9%",
"accentForeground": "0 0% 98%",
"destructive": "0 62.8% 30.6%",
"destructiveForeground": "0 0% 98%",
"border": "240 3.7% 15.9%",
"input": "240 3.7% 15.9%",
"ring": "240 4.9% 83.9%"
},
"typography": {
"fontHeading": "Inter",
"fontBody": "Inter",
"fontMono": "JetBrains Mono",
"fontSizeBase": "1rem",
"lineHeightBase": "1.5",
"fontWeightBase": "400"
},
"spacing": {
"radius": "0.5rem",
"buttonRadius": "0.5rem",
"cardRadius": "0.5rem",
"inputRadius": "0.5rem",
"pillButtons": false,
"borderWidth": "1px",
"containerWidth": "1280px",
"spacingScale": "1"
},
"effects": {
"shadowIntensity": 20,
"shadowColor": "220 3% 15%",
"shadowBlur": "8px",
"elevationPreset": "soft",
"transitionSpeed": "200ms",
"reduceMotion": false,
"hoverIntensity": "medium"
},
"mode": "light"
}
}
]`
func TestCheckPresets_ValidFile(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "presets.json", minimalValidPresetJSON)
violations := checkPresets(dir)
if len(violations) != 0 {
t.Fatalf("checkPresets() returned %d violations for valid presets.json, want 0: %#v", len(violations), violations)
}
}
func TestCheckPresets_EmptyArray(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "presets.json", `[]`)
violations := checkPresets(dir)
if len(violations) != 0 {
t.Fatalf("checkPresets() returned %d violations for empty array, want 0", len(violations))
}
}
func TestCheckPresets_MissingID(t *testing.T) {
dir := t.TempDir()
// Preset with no "id" field (empty string after decode)
writeFile(t, dir, "presets.json", `[
{
"id": "",
"name": "My Preset",
"theme": {}
}
]`)
violations := checkPresets(dir)
if len(violations) == 0 {
t.Fatal("checkPresets() returned 0 violations for preset with empty id, want >= 1")
}
found := false
for _, v := range violations {
if containsStr(v.message, "missing \"id\"") {
found = true
}
}
if !found {
t.Fatalf("expected 'missing id' violation, got: %#v", violations)
}
}
func TestCheckPresets_MissingName(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "presets.json", `[
{
"id": "my-id",
"name": "",
"theme": {}
}
]`)
violations := checkPresets(dir)
if len(violations) == 0 {
t.Fatal("checkPresets() returned 0 violations for preset with empty name, want >= 1")
}
found := false
for _, v := range violations {
if containsStr(v.message, "missing \"name\"") {
found = true
}
}
if !found {
t.Fatalf("expected 'missing name' violation, got: %#v", violations)
}
}
func TestCheckPresets_BothIDAndNameMissing(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "presets.json", `[
{
"id": "",
"name": "",
"theme": {}
}
]`)
violations := checkPresets(dir)
if len(violations) < 2 {
t.Fatalf("checkPresets() returned %d violations, want at least 2 (id+name): %#v", len(violations), violations)
}
}
func TestCheckPresets_InvalidJSON(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "presets.json", `{ this is not valid json`)
violations := checkPresets(dir)
if len(violations) == 0 {
t.Fatal("checkPresets() returned 0 violations for invalid JSON, want >= 1")
}
found := false
for _, v := range violations {
if containsStr(v.message, "invalid JSON") {
found = true
}
}
if !found {
t.Fatalf("expected 'invalid JSON' violation, got: %#v", violations)
}
}
func TestCheckPresets_UnknownField(t *testing.T) {
dir := t.TempDir()
// Valid except for an extra unknown field — triggers "stale preset data" path
writeFile(t, dir, "presets.json", `[
{
"id": "test",
"name": "Test",
"unknownExtraField": "oops",
"theme": {}
}
]`)
violations := checkPresets(dir)
if len(violations) == 0 {
t.Fatal("checkPresets() returned 0 violations for unknown field, want >= 1")
}
found := false
for _, v := range violations {
if containsStr(v.message, "unknown fields") || containsStr(v.message, "stale preset data") {
found = true
}
}
if !found {
t.Fatalf("expected 'unknown fields' violation, got: %#v", violations)
}
}
func TestCheckPresets_NoPresetsFileReturnsClean(t *testing.T) {
dir := t.TempDir()
// No presets.json at all — should return 0 violations
violations := checkPresets(dir)
if len(violations) != 0 {
t.Fatalf("checkPresets() returned %d violations for dir without presets.json, want 0", len(violations))
}
}
func TestFindPresetsFiles_SkipsVendorAndWeb(t *testing.T) {
root := t.TempDir()
// Legitimate presets.json at root level
writeFile(t, root, "presets.json", `[]`)
// Should be skipped (vendor/)
vendorDir := filepath.Join(root, "vendor", "somelib")
if err := os.MkdirAll(vendorDir, 0755); err != nil {
t.Fatalf("mkdir vendor: %v", err)
}
writeFile(t, vendorDir, "presets.json", `[]`)
// Should be skipped (web/)
webDir := filepath.Join(root, "web", "src")
if err := os.MkdirAll(webDir, 0755); err != nil {
t.Fatalf("mkdir web: %v", err)
}
writeFile(t, webDir, "presets.json", `[]`)
files := findPresetsFiles(root)
if len(files) != 1 {
t.Fatalf("findPresetsFiles() returned %d files, want 1 (only root-level): %v", len(files), files)
}
}
func TestFindPresetsFiles_FindsInSubdir(t *testing.T) {
root := t.TempDir()
pluginDir := filepath.Join(root, "internal", "plugins", "myplugin")
if err := os.MkdirAll(pluginDir, 0755); err != nil {
t.Fatalf("mkdir plugin: %v", err)
}
writeFile(t, pluginDir, "presets.json", `[]`)
files := findPresetsFiles(root)
if len(files) != 1 {
t.Fatalf("findPresetsFiles() returned %d files, want 1: %v", len(files), files)
}
}
// containsStr is a helper to test substring containment.
func containsStr(s, substr string) bool {
return len(s) > 0 && len(substr) > 0 && func() bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}()
}

747
proto_rbac.go Normal file
View File

@ -0,0 +1,747 @@
package main
import (
"bufio"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"git.dev.alexdunmow.com/block/check-safety/internal/helpers"
"golang.org/x/mod/modfile"
)
type envViolation struct {
file string
line int
envVar string
}
type protoOwnershipViolation struct {
root string
namespace string
coreProto string
}
var (
bufModulePathPattern = regexp.MustCompile(`^(?:-\s*)?path:\s*"?([^"\s]+)"?\s*$`)
protoPackagePattern = regexp.MustCompile(`^\s*package\s+([A-Za-z0-9_.]+)\s*;\s*$`)
protoServicePattern = regexp.MustCompile(`^\s*service\s+([A-Za-z0-9_]+)\s*\{?\s*$`)
protoRPCPattern = regexp.MustCompile(`^\s*rpc\s+([A-Za-z0-9_]+)\s*\(`)
)
func checkEnvReads(root string) []envViolation {
var violations []envViolation
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 isAllowedEnvReader(path) {
return nil
}
// Skip vendor
if strings.Contains(path, "/vendor/") {
return nil
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil // skip unparseable files
}
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
// Match os.Getenv("SECRET_VAR") and os.LookupEnv("SECRET_VAR")
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ident, ok := sel.X.(*ast.Ident)
if !ok || ident.Name != "os" || (sel.Sel.Name != "Getenv" && sel.Sel.Name != "LookupEnv") {
return true
}
if len(call.Args) != 1 {
return true
}
lit, ok := call.Args[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
envVar := strings.Trim(lit.Value, `"`)
if secretEnvVars[envVar] && !exemptSecretEnvVars[envVar] {
pos := fset.Position(call.Pos())
relPath, _ := filepath.Rel(root, pos.Filename)
if relPath == "" {
relPath = pos.Filename
}
violations = append(violations, envViolation{
file: relPath,
line: pos.Line,
envVar: envVar,
})
}
return true
})
return nil
}); err != nil {
violations = append(violations, envViolation{file: root, line: 0, envVar: err.Error()})
}
return violations
}
func checkPluginProtoOwnership(root string, display string) ([]protoOwnershipViolation, error) {
if !isPluginModuleRoot(root) {
return nil, nil
}
if hasLocalProtoSources(root) {
return nil, nil
}
coreRepoRoot, err := resolveBlockNinjaRepoRootFromModule(root)
if err != nil || coreRepoRoot == "" {
return nil, nil
}
packages, err := extractProcedurePackages(root)
if err != nil {
return nil, err
}
if len(packages) == 0 {
return nil, nil
}
corePackages, err := extractProcedurePackages(coreRepoRoot)
if err != nil {
return nil, err
}
corePackageSet := make(map[string]bool, len(corePackages))
for _, pkg := range corePackages {
corePackageSet[pkg] = true
}
var violations []protoOwnershipViolation
for _, pkg := range packages {
if strings.HasPrefix(pkg, "blockninja.") {
continue
}
if !corePackageSet[pkg] {
continue
}
nsRoot := strings.Split(pkg, ".")[0]
coreProto := filepath.Join(coreRepoRoot, "proto", nsRoot)
if !dirExists(coreProto) {
coreProto = filepath.Join(coreRepoRoot, "proto")
}
violations = append(violations, protoOwnershipViolation{
root: display,
namespace: pkg,
coreProto: coreProto,
})
}
return violations, nil
}
func extractBufProcedures(root string, allowedPackagePrefixes ...string) ([]string, error) {
moduleDirs, err := resolveBufModuleDirs(root)
if err != nil {
return nil, err
}
procedureSet := make(map[string]bool)
for _, moduleDir := range moduleDirs {
err := filepath.Walk(moduleDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if info.Name() == "vendor" || info.Name() == ".git" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".proto") {
return nil
}
procedures, err := extractProtoProcedures(path)
if err != nil {
return err
}
for _, procedure := range procedures {
if !procedureMatchesPackagePrefixes(procedure, allowedPackagePrefixes) {
continue
}
procedureSet[procedure] = true
}
return nil
})
if err != nil {
return nil, err
}
}
procedures := make([]string, 0, len(procedureSet))
for procedure := range procedureSet {
procedures = append(procedures, procedure)
}
if len(procedures) == 0 {
return extractConnectProceduresRecursive(root, allowedPackagePrefixes...)
}
sort.Strings(procedures)
return procedures, nil
}
func extractProcedurePackages(root string) ([]string, error) {
procedures, err := extractBufProcedures(root)
if err != nil {
return nil, err
}
pkgs := make(map[string]bool)
for _, procedure := range procedures {
trimmed := strings.TrimPrefix(procedure, "/")
parts := strings.SplitN(trimmed, "/", 2)
if len(parts) != 2 || parts[0] == "" {
continue
}
serviceName := parts[0]
lastDot := strings.LastIndex(serviceName, ".")
if lastDot <= 0 {
continue
}
pkgs[serviceName[:lastDot]] = true
}
out := make([]string, 0, len(pkgs))
for pkg := range pkgs {
out = append(out, pkg)
}
sort.Strings(out)
return out, nil
}
func procedureMatchesPackagePrefixes(procedure string, allowedPackagePrefixes []string) bool {
if len(allowedPackagePrefixes) == 0 {
return true
}
trimmed := strings.TrimPrefix(procedure, "/")
parts := strings.SplitN(trimmed, "/", 2)
if len(parts) != 2 {
return false
}
pkg := parts[0]
for _, prefix := range allowedPackagePrefixes {
if strings.HasPrefix(pkg, prefix) {
return true
}
}
return false
}
func resolveBufModuleDirs(root string) ([]string, error) {
if !dirExists(root) {
return nil, fmt.Errorf("root does not exist: %s", root)
}
bufPath := filepath.Join(root, "buf.yaml")
if fileExists(bufPath) {
paths, err := parseBufModulePaths(bufPath)
if err != nil {
return nil, err
}
if len(paths) > 0 {
var dirs []string
for _, relPath := range paths {
moduleDir := filepath.Join(root, filepath.Clean(relPath))
if dirExists(moduleDir) {
dirs = append(dirs, moduleDir)
}
}
if len(dirs) > 0 {
return dirs, nil
}
}
}
fallback := filepath.Join(root, "proto")
if dirExists(fallback) {
return []string{fallback}, nil
}
return []string{root}, nil
}
func hasLocalProtoSources(root string) bool {
moduleDirs, err := resolveBufModuleDirs(root)
if err != nil {
return false
}
for _, moduleDir := range moduleDirs {
found := false
if err := filepath.Walk(moduleDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
switch info.Name() {
case ".git", "vendor", "node_modules":
return filepath.SkipDir
}
return nil
}
if strings.HasSuffix(path, ".proto") {
found = true
return filepath.SkipAll
}
return nil
}); err != nil {
continue
}
if found {
return true
}
}
return false
}
func readModulePath(root string) (string, error) {
modPath, ok := findNearestGoModuleRoot(root)
if !ok {
return "", fmt.Errorf("no go.mod found for %s", root)
}
data, err := os.ReadFile(filepath.Join(modPath, "go.mod"))
if err != nil {
return "", err
}
file, err := modfile.Parse("go.mod", data, nil)
if err != nil {
return "", err
}
if file.Module == nil {
return "", fmt.Errorf("go.mod missing module declaration")
}
return file.Module.Mod.Path, nil
}
func resolveBlockNinjaRepoRootFromModule(root string) (string, error) {
moduleRoot, ok := findNearestGoModuleRoot(root)
if !ok {
return "", fmt.Errorf("no go.mod found for %s", root)
}
data, err := os.ReadFile(filepath.Join(moduleRoot, "go.mod"))
if err != nil {
return "", err
}
file, err := modfile.Parse("go.mod", data, nil)
if err != nil {
return "", err
}
for _, replace := range file.Replace {
if replace.Old.Path != "git.dev.alexdunmow.com/block/ninja" {
continue
}
if replace.New.Version != "" {
continue
}
corePath := replace.New.Path
if !filepath.IsAbs(corePath) {
corePath = filepath.Join(moduleRoot, corePath)
}
corePath = filepath.Clean(corePath)
if dirExists(filepath.Join(corePath, "cmd", "check-safety")) {
return filepath.Clean(filepath.Join(corePath, "..")), nil
}
if dirExists(filepath.Join(corePath, "proto")) {
return corePath, nil
}
}
return "", nil
}
func parseBufModulePaths(path string) ([]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var paths []string
inModules := false
modulesIndent := 0
for rawLine := range strings.SplitSeq(string(data), "\n") {
line := strings.TrimRight(rawLine, "\r")
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
indent := len(line) - len(strings.TrimLeft(line, " "))
if !inModules {
if trimmed == "modules:" {
inModules = true
modulesIndent = indent
}
continue
}
if indent <= modulesIndent && !strings.HasPrefix(trimmed, "-") {
break
}
if match := bufModulePathPattern.FindStringSubmatch(trimmed); len(match) == 2 {
paths = append(paths, match[1])
}
}
return paths, nil
}
func extractProtoProcedures(path string) ([]string, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer helpers.LogDeferredError(nil, "close proto source file", file.Close, "path", path)
var (
pkg string
service string
serviceDepth int
procedures []string
)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := stripProtoLineComment(scanner.Text())
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
if pkg == "" {
if match := protoPackagePattern.FindStringSubmatch(trimmed); len(match) == 2 {
pkg = match[1]
}
}
if service == "" {
if match := protoServicePattern.FindStringSubmatch(trimmed); len(match) == 2 {
service = match[1]
serviceDepth = braceDelta(line)
if serviceDepth <= 0 {
serviceDepth = 1
}
}
continue
}
if pkg != "" {
if match := protoRPCPattern.FindStringSubmatch(trimmed); len(match) == 2 {
procedures = append(procedures, fmt.Sprintf("/%s.%s/%s", pkg, service, match[1]))
}
}
serviceDepth += braceDelta(line)
if serviceDepth <= 0 {
service = ""
serviceDepth = 0
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
return procedures, nil
}
func stripProtoLineComment(line string) string {
if before, _, ok := strings.Cut(line, "//"); ok {
return before
}
return line
}
func braceDelta(line string) int {
return strings.Count(line, "{") - strings.Count(line, "}")
}
func extractPluginRoleMethods(root string) (map[string]string, error) {
methods := make(map[string]string)
fset := token.NewFileSet()
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
switch info.Name() {
case ".git", "vendor", "node_modules":
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
file, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil
}
ast.Inspect(file, func(n ast.Node) bool {
comp, ok := n.(*ast.CompositeLit)
if !ok || !isRoleMapType(comp.Type) {
return true
}
for _, elt := range comp.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
lit, ok := kv.Key.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
method := strings.Trim(lit.Value, `"`)
if strings.HasPrefix(method, "/") && strings.Contains(method[1:], "/") {
methods[method] = normalizeRoleName(roleExprString(kv.Value))
}
}
return true
})
return nil
})
if err != nil {
return nil, err
}
return methods, nil
}
func isRoleMapType(expr ast.Expr) bool {
mapType, ok := expr.(*ast.MapType)
if !ok {
return false
}
keyIdent, ok := mapType.Key.(*ast.Ident)
if !ok || keyIdent.Name != "string" {
return false
}
switch valueType := mapType.Value.(type) {
case *ast.Ident:
return valueType.Name == "Role"
case *ast.SelectorExpr:
return valueType.Sel != nil && valueType.Sel.Name == "Role"
default:
return false
}
}
// extractConnectProceduresRecursive walks a tree, parses *.connect.go files, and extracts
// procedure string constants. This is a fallback for plugin repos that ship generated Connect
// stubs but not local proto sources.
func extractConnectProceduresRecursive(root string, allowedPackagePrefixes ...string) ([]string, error) {
procedureSet := make(map[string]bool)
fset := token.NewFileSet()
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
switch info.Name() {
case ".git", "vendor", "node_modules":
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".connect.go") {
return nil
}
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil
}
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.CONST {
continue
}
for _, spec := range genDecl.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok || len(vs.Names) == 0 || len(vs.Values) == 0 {
continue
}
name := vs.Names[0].Name
if !strings.HasSuffix(name, "Procedure") {
continue
}
lit, ok := vs.Values[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
val := strings.Trim(lit.Value, `"`)
if procedureMatchesPackagePrefixes(val, allowedPackagePrefixes) {
procedureSet[val] = true
}
}
}
return nil
})
if err != nil {
return nil, err
}
procedures := make([]string, 0, len(procedureSet))
for procedure := range procedureSet {
procedures = append(procedures, procedure)
}
sort.Strings(procedures)
return procedures, nil
}
// extractRBACMethodRoles parses interceptor.go and extracts all keys and role values from the MethodRoles map.
func extractRBACMethodRoles(file string) (map[string]string, error) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, file, nil, 0)
if err != nil {
return nil, err
}
methods := make(map[string]string)
ast.Inspect(f, func(n ast.Node) bool {
// Find: var MethodRoles = map[string]Role{ ... }
vs, ok := n.(*ast.ValueSpec)
if !ok || len(vs.Names) == 0 || vs.Names[0].Name != "MethodRoles" {
return true
}
if len(vs.Values) == 0 {
return true
}
comp, ok := vs.Values[0].(*ast.CompositeLit)
if !ok {
return true
}
for _, elt := range comp.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
lit, ok := kv.Key.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
method := strings.Trim(lit.Value, `"`)
methods[method] = normalizeRoleName(roleExprString(kv.Value))
}
return false
})
return methods, nil
}
func roleExprString(expr ast.Expr) string {
switch value := expr.(type) {
case *ast.BasicLit:
if value.Kind == token.STRING {
return strings.Trim(value.Value, `"`)
}
case *ast.Ident:
return value.Name
case *ast.SelectorExpr:
if value.Sel != nil {
return value.Sel.Name
}
}
return ""
}
func normalizeRoleName(raw string) string {
switch raw {
case "", "RolePublic":
return "public"
}
if after, ok := strings.CutPrefix(raw, "Role"); ok {
raw = after
}
return strings.ToLower(raw)
}
func isExcludedServiceMethod(method string) bool {
parts := strings.Split(method, "/")
if len(parts) < 3 {
return false
}
svcParts := strings.Split(parts[1], ".")
svcName := svcParts[len(svcParts)-1]
return excludedServices[svcName]
}
func countRBACRoles(protoMethods []string, roleByMethod map[string]string) map[string]int {
counts := make(map[string]int)
for _, method := range protoMethods {
if isExcludedServiceMethod(method) {
continue
}
role, ok := roleByMethod[method]
if !ok {
continue
}
counts[role]++
}
return counts
}
func printRoleBreakdown(roleCounts map[string]int) {
orderedRoles := []string{"public", "viewer", "user", "admin", "superadmin", "cms"}
printed := make(map[string]bool)
for _, role := range orderedRoles {
if count, ok := roleCounts[role]; ok {
fmt.Printf(" role %-10s %d\n", role, count)
printed[role] = true
}
}
var extraRoles []string
for role := range roleCounts {
if printed[role] {
continue
}
extraRoles = append(extraRoles, role)
}
sort.Strings(extraRoles)
for _, role := range extraRoles {
fmt.Printf(" role %-10s %d\n", role, roleCounts[role])
}
}

133
rawsql.go Normal file
View File

@ -0,0 +1,133 @@
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"regexp"
"strings"
)
type rawSQLViolation struct {
file string
line int
snippet string
}
// SQL keywords that indicate raw SQL usage
var sqlKeywordRe = regexp.MustCompile(`(?i)\b(INSERT\s+INTO|UPDATE\s+\w+\s+SET|DELETE\s+FROM|SELECT\s+.+\s+FROM|ALTER\s+TABLE|DROP\s+TABLE|CREATE\s+TABLE)\b`)
// Directories where raw SQL is expected (migrations, sqlc queries, seeder, test fixtures)
func isRawSQLAllowed(path string) bool {
return strings.Contains(path, "/sql/") ||
strings.Contains(path, "/migrations/") ||
strings.Contains(path, "/internal/db/") ||
strings.Contains(path, "/seeder/") ||
strings.HasSuffix(path, "_test.go") ||
strings.Contains(path, "/cmd/admincli/") ||
strings.Contains(path, "/datasources/") || // Bob query builder files
strings.Contains(path, "internal/plugins/") // Plugin tables are not in core schema
}
// Specific SQL patterns that are allowed (postgres admin commands, not data queries)
var allowedSQLPatterns = []string{
"pg_terminate_backend", // backup_service.go — kills connections before restore
"FROM pg_database", // Administrative catalog lookup for tenant database existence
}
func isSQLAllowed(sql string) bool {
for _, pattern := range allowedSQLPatterns {
if strings.Contains(sql, pattern) {
return true
}
}
return false
}
// checkRawSQL finds raw SQL string literals passed to Exec/Query methods outside allowed dirs.
func checkRawSQL(root string) []rawSQLViolation {
var violations []rawSQLViolation
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/") {
return nil
}
if isRawSQLAllowed(path) {
return nil
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil
}
relPath, _ := filepath.Rel(root, path)
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
// Match method calls: *.ExecContext, *.QueryContext, *.QueryRowContext, *.Exec, *.Query, *.QueryRow
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
method := sel.Sel.Name
isDBMethod := method == "ExecContext" || method == "QueryContext" || method == "QueryRowContext" ||
method == "Exec" || method == "Query" || method == "QueryRow"
if !isDBMethod {
return true
}
// Check if any string argument contains SQL keywords
for _, arg := range call.Args {
lit, ok := arg.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
continue
}
val := strings.Trim(lit.Value, "`\"")
if sqlKeywordRe.MatchString(val) && !isSQLAllowed(val) {
pos := fset.Position(call.Pos())
// Truncate the SQL for display
display := val
if len(display) > 80 {
display = display[:77] + "..."
}
violations = append(violations, rawSQLViolation{
file: relPath,
line: pos.Line,
snippet: display,
})
}
}
return true
})
return nil
}); err != nil {
violations = append(violations, rawSQLViolation{file: root, line: 0, snippet: err.Error()})
}
return violations
}
func printRawSQLHelp() {
fmt.Println(`
Use sqlc for static queries or Bob for dynamic queries:
Need Use
Static queries sqlc (sql/queries/*.sql internal/db/*.go)
Dynamic queries Bob (github.com/stephenafamo/bob)
Raw SQL causes bugs when schema changes. sqlc catches these at compile time.`)
}

208
rawsql_test.go Normal file
View File

@ -0,0 +1,208 @@
package main
import (
"path/filepath"
"testing"
)
func TestCheckRawSQL_ExecContextWithInsert(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "bad.go", `package service
func (s *Service) Create(ctx context.Context, db *sql.DB) error {
_, err := db.ExecContext(ctx, "INSERT INTO users (name) VALUES ($1)", name)
return err
}
`)
vs := checkRawSQL(dir)
if len(vs) == 0 {
t.Fatal("expected rawsql violation for ExecContext with INSERT INTO")
}
}
func TestCheckRawSQL_QueryContextWithSelect(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "bad.go", `package service
func (s *Service) List(ctx context.Context) {
rows, _ := db.QueryContext(ctx, "SELECT id FROM pages WHERE active = true")
_ = rows
}
`)
vs := checkRawSQL(dir)
if len(vs) == 0 {
t.Fatal("expected rawsql violation for QueryContext with SELECT FROM")
}
}
func TestCheckRawSQL_QueryRowContextWithSelect(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "bad.go", `package service
func (s *Service) Get(ctx context.Context) {
row := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id = $1", id)
_ = row
}
`)
vs := checkRawSQL(dir)
if len(vs) == 0 {
t.Fatal("expected rawsql violation for QueryRowContext with SELECT FROM")
}
}
func TestCheckRawSQL_UpdateStatement(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "bad.go", `package service
func (s *Service) Update(ctx context.Context) {
db.Exec("UPDATE users SET name = $1 WHERE id = $2", name, id)
}
`)
vs := checkRawSQL(dir)
if len(vs) == 0 {
t.Fatal("expected rawsql violation for Exec with UPDATE ... SET")
}
}
func TestCheckRawSQL_DeleteStatement(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "bad.go", `package service
func (s *Service) Delete(ctx context.Context) {
db.QueryRow("DELETE FROM sessions WHERE expired = true")
}
`)
vs := checkRawSQL(dir)
if len(vs) == 0 {
t.Fatal("expected rawsql violation for QueryRow with DELETE FROM")
}
}
func TestCheckRawSQL_NoSQLKeyword_Clean(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "good.go", `package service
func (s *Service) DoSomething(ctx context.Context) error {
_, err := db.ExecContext(ctx, "NOTIFY channel, 'payload'")
return err
}
`)
vs := checkRawSQL(dir)
if len(vs) != 0 {
t.Fatalf("expected no violations for NOTIFY statement, got %d", len(vs))
}
}
func TestCheckRawSQL_SqlcGeneratedFile_AllowedByPath(t *testing.T) {
// Files under /internal/db/ are allowed
dir := t.TempDir()
dbDir := filepath.Join(dir, "internal", "db")
writeTestFile(t, filepath.Join(dbDir, "queries.go"), `package db
func GetUser(ctx context.Context) {
db.QueryContext(ctx, "SELECT id FROM users WHERE id = $1", id)
}
`, 0644)
vs := checkRawSQL(dir)
if len(vs) != 0 {
t.Fatalf("expected no violations for file under /internal/db/, got %d", len(vs))
}
}
func TestCheckRawSQL_MigrationFile_AllowedByPath(t *testing.T) {
dir := t.TempDir()
migDir := filepath.Join(dir, "sql", "migrations")
writeTestFile(t, filepath.Join(migDir, "001_init.go"), `package migrations
func up() {
db.Exec("CREATE TABLE users (id UUID PRIMARY KEY)")
}
`, 0644)
vs := checkRawSQL(dir)
if len(vs) != 0 {
t.Fatalf("expected no violations for file under /sql/, got %d", len(vs))
}
}
func TestCheckRawSQL_TestFile_AllowedByPath(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "service_test.go", `package service
func TestCreate(t *testing.T) {
db.ExecContext(ctx, "INSERT INTO users (name) VALUES ($1)", "test")
}
`)
vs := checkRawSQL(dir)
if len(vs) != 0 {
t.Fatalf("expected no violations for _test.go file, got %d", len(vs))
}
}
func TestCheckRawSQL_AllowedAdminPattern(t *testing.T) {
// pg_terminate_backend is in the allowlist
dir := t.TempDir()
writeFile(t, dir, "backup.go", `package backup
func killConnections(ctx context.Context) {
db.ExecContext(ctx, "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1", name)
}
`)
vs := checkRawSQL(dir)
if len(vs) != 0 {
t.Fatalf("expected no violations for allowed pg_terminate_backend pattern, got %d", len(vs))
}
}
func TestCheckRawSQL_SnippetTruncatedAt80(t *testing.T) {
dir := t.TempDir()
longSQL := "SELECT id, name, email, created_at, updated_at, status, role FROM users WHERE active = true AND deleted_at IS NULL"
writeFile(t, dir, "bad.go", "package service\n\nfunc f(ctx interface{}, db interface{}) {\n\tdb.QueryContext(ctx, \""+longSQL+"\")\n}\n")
vs := checkRawSQL(dir)
if len(vs) == 0 {
t.Fatal("expected violation for long SELECT statement")
}
if len(vs[0].snippet) > 80 {
t.Fatalf("snippet should be truncated to <=80 chars, got %d: %q", len(vs[0].snippet), vs[0].snippet)
}
}
func TestCheckRawSQL_RelativeFilePathRecorded(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "bad.go", `package service
func f(ctx interface{}, db interface{}) {
db.QueryContext(ctx, "SELECT id FROM users WHERE id = $1", 1)
}
`)
vs := checkRawSQL(dir)
if len(vs) == 0 {
t.Fatal("expected violation")
}
if vs[0].file != "bad.go" {
t.Fatalf("expected relative file path 'bad.go', got %q", vs[0].file)
}
}
func TestCheckRawSQL_NonGoFile_Skipped(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "query.sql", "SELECT id FROM users WHERE id = $1;\n")
vs := checkRawSQL(dir)
if len(vs) != 0 {
t.Fatalf("expected no violations for .sql file (only .go is scanned), got %d", len(vs))
}
}
func TestCheckRawSQL_PluginDir_Allowed(t *testing.T) {
dir := t.TempDir()
pluginDir := filepath.Join(dir, "internal", "plugins", "myplugin")
writeTestFile(t, filepath.Join(pluginDir, "service.go"), `package myplugin
func init() {
db.ExecContext(ctx, "INSERT INTO myplugin_rows (id) VALUES ($1)", id)
}
`, 0644)
vs := checkRawSQL(dir)
if len(vs) != 0 {
t.Fatalf("expected no violations for internal/plugins/ dir, got %d", len(vs))
}
}

37
registry.go Normal file
View File

@ -0,0 +1,37 @@
package main
// ScanContext holds all resolved scan state, built once before any check runs.
// It is read-only to checks.
type ScanContext struct {
repoRoot string
backendDir string
orchestratorOnly bool
includeCoreTargets bool
backendTargets []backendScanTarget
pluginTargets []pluginScanTarget // filtered
resolvedPluginTargets []pluginScanTarget // pre-filter
frontendTargets []frontendScanTarget
unresolvedPluginRoots []string
pluginPageDirs []string
}
// Reporter accumulates the process exit code across checks.
type Reporter struct {
exitCode int
}
// Fail marks the overall run as failed (exit code 1).
func (r *Reporter) Fail() { r.exitCode = 1 }
// Check is one registered safety check. Run prints its own header and results
// and calls rep.Fail() on violations, exactly as the original inline block did.
type Check struct {
Seq int
ID string
Title string
Run func(ctx *ScanContext, rep *Reporter)
}
var registry []Check
func register(c Check) { registry = append(registry, c) }

43
registry_test.go Normal file
View File

@ -0,0 +1,43 @@
package main
import (
"sort"
"testing"
)
// TestRegistryOrder locks the run order of all registered checks.
//
// main() runs checks sorted by Seq, so the Seq values ARE the contract for
// output order. The golden characterization test cannot guard every position:
// the plugin-discovery check ("10disc") prints nothing when no plugin roots are
// scanned (as in the golden fixtures), so a Seq mistake that reordered it past
// Check 10b would slip through golden silently. Assert the full canonical order
// here, and reject duplicate Seq values (which would make order nondeterministic).
func TestRegistryOrder(t *testing.T) {
want := []string{
"1", "2", "2b", "2c", "2d", "2e", "2f", "3", "3b", "4",
"5", "6", "7", "8", "9", "10", "10b", "10disc", "11", "12",
"13", "14", "15", "16", "17", "18", "19", "20", "21", "22",
}
ordered := make([]Check, len(registry))
copy(ordered, registry)
sort.Slice(ordered, func(i, j int) bool { return ordered[i].Seq < ordered[j].Seq })
if len(ordered) != len(want) {
t.Fatalf("registry has %d checks, want %d", len(ordered), len(want))
}
for i, c := range ordered {
if c.ID != want[i] {
t.Errorf("position %d: got check %q (Seq %d), want %q", i, c.ID, c.Seq, want[i])
}
}
seen := map[int]string{}
for _, c := range registry {
if prev, ok := seen[c.Seq]; ok {
t.Errorf("duplicate Seq %d on checks %q and %q", c.Seq, prev, c.ID)
}
seen[c.Seq] = c.ID
}
}

383
reinvented.go Normal file
View File

@ -0,0 +1,383 @@
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
}
func printReinventedHelp() {
fmt.Println(`
Don't reinvent utilities use the existing helpers:
Instead of Use
m["key"].(string) helpers.GetStringOr(m, "key", "")
m["key"].(bool) helpers.GetBoolOr(m, "key", false)
m["key"].(int) helpers.GetIntOr(m, "key", 0)
m["key"].(float64) helpers.GetFloat64Or(m, "key", 0)
*ptr (nil-unsafe deref) helpers.DerefStringOr(ptr, "")
strings.ToLower + regex replace helpers.Slugify(s)
regex strip <tags> helpers.StripHTML(s)
html excerpt helpers.GenerateExcerpt(html, words)
r.Header.Get("X-Forwarded-For") helpers.GetRealIP(r)
Frontend
classnames() / clsx() cn() from @/lib/utils
custom debounce function debounce from @/lib/utils
custom formatBytes function formatBytes from @/lib/utils
regex strip HTML tags stripHtml from @/lib/seo
new URLSearchParams(window.location) Route.useSearch() from TanStack Router
`)
}

455
reinvented_test.go Normal file
View File

@ -0,0 +1,455 @@
package main
import (
"path/filepath"
"testing"
)
// ─── checkReinventedBackend ────────────────────────────────────────────────
func TestCheckReinventedBackend_TimestamptzDuplicate(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "util.go", `package util
func timestamptz(t time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: t, Valid: true}
}
`)
vs := checkReinventedBackend(dir)
found := false
for _, v := range vs {
if v.rule == "dup-timestamptz" {
found = true
}
}
if !found {
t.Fatalf("expected dup-timestamptz violation, got %#v", vs)
}
}
func TestCheckReinventedBackend_ToPgTimestamptzDuplicate(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "util.go", `package util
func toPgTimestamptz(t time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: t, Valid: true}
}
`)
vs := checkReinventedBackend(dir)
found := false
for _, v := range vs {
if v.rule == "dup-toPgTimestamptz" {
found = true
}
}
if !found {
t.Fatalf("expected dup-toPgTimestamptz violation, got %#v", vs)
}
}
func TestCheckReinventedBackend_TimestampFromTimeDuplicate(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "util.go", `package util
func timestampFromTime(t time.Time) *timestamppb.Timestamp {
return timestamppb.New(t)
}
`)
vs := checkReinventedBackend(dir)
found := false
for _, v := range vs {
if v.rule == "dup-timestampFromTime" {
found = true
}
}
if !found {
t.Fatalf("expected dup-timestampFromTime violation, got %#v", vs)
}
}
func TestCheckReinventedBackend_TestFile_Skipped(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "util_test.go", `package util
func timestamptz(t time.Time) pgtype.Timestamptz {
return pgtype.Timestamptz{Time: t, Valid: true}
}
`)
vs := checkReinventedBackend(dir)
for _, v := range vs {
if v.rule == "dup-timestamptz" {
t.Fatal("test files should be skipped for dup-timestamptz check")
}
}
}
func TestCheckReinventedBackend_TypeAssertionWithHelpersImport_Flagged(t *testing.T) {
// A file that imports internal/helpers AND does m["key"].(string) should be flagged
dir := t.TempDir()
writeFile(t, dir, "service.go", `package service
import "git.dev.alexdunmow.com/block/ninja/internal/helpers"
func render(m map[string]interface{}) string {
_ = helpers.GetStringOr(m, "key", "")
return m["title"].(string)
}
`)
vs := checkReinventedBackend(dir)
found := false
for _, v := range vs {
if v.rule == "use-helper-accessor" {
found = true
}
}
if !found {
t.Fatalf("expected use-helper-accessor violation for m[\"key\"].(string) with helpers imported, got %#v", vs)
}
}
func TestCheckReinventedBackend_TypeAssertionWithoutHelpersImport_NotFlagged(t *testing.T) {
// A file that does NOT import helpers should NOT be flagged for type assertions
dir := t.TempDir()
writeFile(t, dir, "service.go", `package service
func render(m map[string]interface{}) string {
return m["title"].(string)
}
`)
vs := checkReinventedBackend(dir)
for _, v := range vs {
if v.rule == "use-helper-accessor" {
t.Fatal("files without helpers import should NOT trigger use-helper-accessor")
}
}
}
func TestCheckReinventedBackend_HelpersDirSelf_NotFlagged(t *testing.T) {
// The helpers package itself is allowed to define these functions
dir := t.TempDir()
helpersDir := filepath.Join(dir, "internal", "helpers")
writeTestFile(t, filepath.Join(helpersDir, "map.go"), `package helpers
import "git.dev.alexdunmow.com/block/ninja/internal/helpers"
func GetStringOr(m map[string]interface{}, key, def string) string {
if v, ok := m[key]; ok {
return v.(string)
}
return def
}
`, 0644)
vs := checkReinventedBackend(dir)
for _, v := range vs {
if v.rule == "use-helper-accessor" {
t.Fatal("internal/helpers/ itself should be exempt from use-helper-accessor check")
}
}
}
func TestCheckReinventedBackend_CleanFile_NoViolation(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "service.go", `package service
func doWork() string {
return "hello"
}
`)
vs := checkReinventedBackend(dir)
if len(vs) != 0 {
t.Fatalf("expected no violations for clean Go file, got %d: %#v", len(vs), vs)
}
}
func TestCheckReinventedBackend_VendorDir_Skipped(t *testing.T) {
dir := t.TempDir()
vendorDir := filepath.Join(dir, "vendor", "somelib")
writeTestFile(t, filepath.Join(vendorDir, "util.go"), `package somelib
func timestamptz(t interface{}) interface{} {
return t
}
`, 0644)
vs := checkReinventedBackend(dir)
for _, v := range vs {
if v.rule == "dup-timestamptz" {
t.Fatal("vendor/ files should be skipped")
}
}
}
func TestCheckReinventedBackend_BoolTypeAssertion_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "service.go", `package service
import "git.dev.alexdunmow.com/block/ninja/internal/helpers"
func check(m map[string]interface{}) bool {
_ = helpers.GetBoolOr(m, "enabled", false)
return m["active"].(bool)
}
`)
vs := checkReinventedBackend(dir)
found := false
for _, v := range vs {
if v.rule == "use-helper-accessor" {
found = true
}
}
if !found {
t.Fatalf("expected use-helper-accessor for m[\"key\"].(bool) with helpers imported, got %#v", vs)
}
}
func TestCheckReinventedBackend_NonGoFile_Skipped(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "util.ts", `// TypeScript: function timestamptz() {}
function timestamptz(t: Date) { return t }
`)
vs := checkReinventedBackend(dir)
if len(vs) != 0 {
t.Fatalf("expected no violations for .ts file (backend check only scans .go), got %d", len(vs))
}
}
// ─── checkReinventedFrontend ──────────────────────────────────────────────
func TestCheckReinventedFrontend_ClsxDirectly_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "comp.tsx", `import { clsx } from 'clsx'
export function Comp({ active }: { active: boolean }) {
return <div className={clsx('base', { active })} />
}
`)
vs := checkReinventedFrontend(dir)
found := false
for _, v := range vs {
if v.rule == "use-cn" {
found = true
}
}
if !found {
t.Fatalf("expected use-cn violation for clsx() usage, got %#v", vs)
}
}
func TestCheckReinventedFrontend_ClassnamesDirectly_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "comp.tsx", `import classnames from 'classnames'
export function Comp({ a }: { a: boolean }) {
return <div className={classnames('foo', { a })} />
}
`)
vs := checkReinventedFrontend(dir)
found := false
for _, v := range vs {
if v.rule == "use-cn" {
found = true
}
}
if !found {
t.Fatalf("expected use-cn violation for classnames() usage, got %#v", vs)
}
}
func TestCheckReinventedFrontend_CustomDebounce_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "utils-extra.ts", `// local debounce — do not commit
function debounce<T extends (...args: unknown[]) => void>(fn: T, delay: number): T {
let timer: ReturnType<typeof setTimeout>
return ((...args: unknown[]) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}) as T
}
`)
vs := checkReinventedFrontend(dir)
found := false
for _, v := range vs {
if v.rule == "use-debounce" {
found = true
}
}
if !found {
t.Fatalf("expected use-debounce violation for custom debounce function, got %#v", vs)
}
}
func TestCheckReinventedFrontend_CustomFormatBytes_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "format-extra.ts", `const formatBytes = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
return (bytes / 1024).toFixed(1) + ' KB'
}
`)
vs := checkReinventedFrontend(dir)
found := false
for _, v := range vs {
if v.rule == "use-formatBytes" {
found = true
}
}
if !found {
t.Fatalf("expected use-formatBytes violation, got %#v", vs)
}
}
func TestCheckReinventedFrontend_CustomSlugify_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "helpers.ts", `const slugify = (s: string) => s.toLowerCase().replace(/\s+/g, '-')
`)
vs := checkReinventedFrontend(dir)
found := false
for _, v := range vs {
if v.rule == "use-slugify" {
found = true
}
}
if !found {
t.Fatalf("expected use-slugify violation, got %#v", vs)
}
}
func TestCheckReinventedFrontend_RawURLSearchParams_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "comp.tsx", `export function Comp() {
const params = new URLSearchParams(window.location.search)
return <div>{params.get('tab')}</div>
}
`)
vs := checkReinventedFrontend(dir)
found := false
for _, v := range vs {
if v.rule == "use-router-search" {
found = true
}
}
if !found {
t.Fatalf("expected use-router-search violation for URLSearchParams(window.location.search), got %#v", vs)
}
}
func TestCheckReinventedFrontend_AllowedFile_NotFlagged(t *testing.T) {
// lib/utils.ts is the canonical home — debounce there is OK
dir := t.TempDir()
utilsDir := filepath.Join(dir, "lib")
writeTestFile(t, filepath.Join(utilsDir, "utils.ts"), `// canonical utility definitions
const debounce = <T extends (...args: unknown[]) => void>(fn: T, delay: number): T => {
let timer: ReturnType<typeof setTimeout>
return ((...args: unknown[]) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), delay)
}) as T
}
export { debounce }
`, 0644)
vs := checkReinventedFrontend(dir)
for _, v := range vs {
if v.rule == "use-debounce" {
t.Fatalf("lib/utils.ts should be exempt from use-debounce check, got violation at %s:%d", v.file, v.line)
}
}
}
func TestCheckReinventedFrontend_CommentLine_Skipped(t *testing.T) {
// Lines that are comments should be skipped
dir := t.TempDir()
writeFile(t, dir, "comp.tsx", `// You could use clsx() here but we use cn() instead
// const slugify = (s: string) => s.toLowerCase()
export const x = 1
`)
vs := checkReinventedFrontend(dir)
if len(vs) != 0 {
t.Fatalf("expected comment lines to be skipped, got %d violations: %#v", len(vs), vs)
}
}
func TestCheckReinventedFrontend_CleanFile_NoViolation(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "comp.tsx", `import { cn } from '@/lib/utils'
export function Comp({ active }: { active: boolean }) {
return <div className={cn('base', { active })} />
}
`)
vs := checkReinventedFrontend(dir)
if len(vs) != 0 {
t.Fatalf("expected no violations for clean frontend file, got %d: %#v", len(vs), vs)
}
}
func TestCheckReinventedFrontend_NonTSFile_Skipped(t *testing.T) {
dir := t.TempDir()
// .go file — should not be scanned by frontend check
writeFile(t, dir, "service.go", `package service
const slugify = func(s string) string { return s }
`)
vs := checkReinventedFrontend(dir)
if len(vs) != 0 {
t.Fatalf("expected no violations for .go file in frontend scan, got %d", len(vs))
}
}
func TestCheckReinventedFrontend_NodeModules_Skipped(t *testing.T) {
dir := t.TempDir()
nmDir := filepath.Join(dir, "node_modules", "some-lib")
writeTestFile(t, filepath.Join(nmDir, "index.ts"), `function debounce() {}
`, 0644)
vs := checkReinventedFrontend(dir)
for _, v := range vs {
if v.rule == "use-debounce" {
t.Fatal("node_modules should be skipped")
}
}
}
func TestCheckReinventedFrontend_RelativeFilePath(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "utils-bad.ts", `function formatBytes(n: number) { return n + 'B' }
`)
vs := checkReinventedFrontend(dir)
if len(vs) == 0 {
t.Fatal("expected violation")
}
if vs[0].file != "utils-bad.ts" {
t.Fatalf("expected relative path 'utils-bad.ts', got %q", vs[0].file)
}
}
func TestCheckReinventedFrontend_CustomCountWords_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "seo-extra.ts", `const countWords = (text: string): number => text.split(/\s+/).filter(Boolean).length
`)
vs := checkReinventedFrontend(dir)
found := false
for _, v := range vs {
if v.rule == "use-countWords" {
found = true
}
}
if !found {
t.Fatalf("expected use-countWords violation, got %#v", vs)
}
}
func TestCheckReinventedFrontend_ProtoTimestampConversion_Flagged(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "table.tsx", `export function formatTs(ts: { seconds: bigint }) {
return new Date(Number(ts.seconds) * 1000).toISOString()
}
`)
vs := checkReinventedFrontend(dir)
found := false
for _, v := range vs {
if v.rule == "use-shared-formatDate" {
found = true
}
}
if !found {
t.Fatalf("expected use-shared-formatDate violation for proto timestamp conversion, got %#v", vs)
}
}

168
rpcerrors.go Normal file
View File

@ -0,0 +1,168 @@
package main
import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
)
type rpcErrorViolation struct {
file string
line int
rule string
snippet string
}
var (
// Matches: const { ... } = useQuery — destructuring pattern
reQueryDestructure = regexp.MustCompile(`(?:const|let|var)\s+\{([^}]+)\}\s*=\s*useQuery`)
// Matches: useMutation( — start of a mutation hook call
reUseMutation = regexp.MustCompile(`\buseMutation\s*\(`)
// Matches: onError in options
reOnError = regexp.MustCompile(`onError\s*:`)
// Matches: catch or .catch(
reCatch = regexp.MustCompile(`\bcatch\b`)
// Matches: toast.error
reToastError = regexp.MustCompile(`toast\.error\s*\(`)
)
// checkRPCErrors detects missing error handling for RPC queries and mutations.
func checkRPCErrors(webSrcDir string) (violations []rpcErrorViolation, warnings []rpcErrorViolation) {
globalMutationHandler := hasGlobalMutationErrorHandler(webSrcDir)
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 != ".tsx" && ext != ".ts" {
return nil
}
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") {
return nil
}
relPath, _ := filepath.Rel(webSrcDir, path)
// Only check components/ and routes/
if !strings.HasPrefix(relPath, "components/") && !strings.HasPrefix(relPath, "routes/") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Split(string(data), "\n")
fullContent := string(data)
for i, line := range lines {
lineNum := i + 1
trimmed := strings.TrimSpace(line)
// Skip comments
if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") || strings.HasPrefix(trimmed, "{/*") {
continue
}
// Check useQuery destructuring for missing error
if reQueryDestructure.MatchString(line) {
matches := reQueryDestructure.FindStringSubmatch(line)
if len(matches) > 1 {
destructured := matches[1]
if !strings.Contains(destructured, "error") {
warnings = append(warnings, rpcErrorViolation{
file: relPath,
line: lineNum,
rule: "query-no-error",
snippet: strings.TrimSpace(line),
})
}
}
}
// Check useMutation — find mutations with no error handling anywhere in file
if reUseMutation.MatchString(line) && !strings.HasPrefix(trimmed, "//") {
// Check if this mutation's options or its call sites have error handling
// Look in the surrounding context (next 20 lines) for onError
hasErrorHandling := false
// Check the mutation definition options (same line or next few lines)
for j := i; j < len(lines) && j <= i+15; j++ {
if reOnError.MatchString(lines[j]) {
hasErrorHandling = true
break
}
// Stop at next statement
if j > i && (strings.Contains(lines[j], "const ") || strings.Contains(lines[j], "return ")) {
break
}
}
// Check if the file has ANY try/catch or toast.error (broad heuristic)
if !hasErrorHandling {
if reCatch.MatchString(fullContent) || reToastError.MatchString(fullContent) {
// File has some error handling — mutation call sites likely handle it
hasErrorHandling = true
}
}
if !hasErrorHandling && !globalMutationHandler {
violations = append(violations, rpcErrorViolation{
file: relPath,
line: lineNum,
rule: "mutation-no-error-handler",
snippet: strings.TrimSpace(line),
})
}
}
}
return nil
}); err != nil {
_ = err // Walk errors are non-fatal for safety checks
}
return violations, warnings
}
func hasGlobalMutationErrorHandler(webSrcDir string) bool {
queryClientPath := filepath.Join(webSrcDir, "lib", "query-client.ts")
data, err := os.ReadFile(queryClientPath)
if err != nil {
return false
}
content := string(data)
return strings.Contains(content, "MutationCache") && strings.Contains(content, "onError")
}
func printRPCErrorHelp() {
fmt.Println(`
RPC error handling patterns:
Global handlers in App.tsx catch unhandled errors automatically:
QueryCache.onError toasts on refetch failures
MutationCache.onError toasts when no onError at call site
For custom error UI, destructure 'error' from useQuery:
const { data, isLoading, error } = useQuery(myQuery, input)
if (error) return <ErrorState message={error.message} />
For mutations, add onError or use try/catch:
mutation.mutate(data, {
onSuccess: () => toast.success('Saved'),
onError: (err) => toast.error('Failed', {
description: err.message
}),
})
`)
}

329
rpcerrors_test.go Normal file
View File

@ -0,0 +1,329 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// makeRPCDir creates a webSrcDir with optional lib/query-client.ts for the
// global mutation handler check.
func makeRPCDir(t *testing.T) string {
t.Helper()
dir := t.TempDir()
return dir
}
func writeRPCComponent(t *testing.T, webSrcDir, subpath, content string) {
t.Helper()
fullPath := filepath.Join(webSrcDir, subpath)
if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil {
t.Fatalf("mkdir %s: %v", filepath.Dir(fullPath), err)
}
if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
t.Fatalf("write %s: %v", fullPath, err)
}
}
func writeGlobalMutationHandler(t *testing.T, webSrcDir string) {
t.Helper()
libDir := filepath.Join(webSrcDir, "lib")
if err := os.MkdirAll(libDir, 0755); err != nil {
t.Fatalf("mkdir lib: %v", err)
}
if err := os.WriteFile(filepath.Join(libDir, "query-client.ts"), []byte(`
import { MutationCache, QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
mutationCache: new MutationCache({
onError: (error) => {
console.error('Global mutation error', error)
},
}),
})
`), 0644); err != nil {
t.Fatalf("write query-client.ts: %v", err)
}
}
// ─────────────────────────────────────────────
// checkRPCErrors query-no-error warning
// ─────────────────────────────────────────────
func TestCheckRPCErrors_QueryWithoutError_Warning(t *testing.T) {
dir := makeRPCDir(t)
writeRPCComponent(t, dir, "components/my-component.tsx", `
import { useQuery } from '@block-ninja/api/queries'
export function MyComponent() {
const { data, isLoading } = useQuery(myQuery, {})
return <div>{data}</div>
}
`)
violations, warnings := checkRPCErrors(dir)
if len(warnings) == 0 {
t.Fatal("expected at least 1 warning for useQuery without error destructuring")
}
found := false
for _, w := range warnings {
if w.rule == "query-no-error" {
found = true
}
}
if !found {
t.Fatalf("expected rule 'query-no-error' in warnings, got: %#v", warnings)
}
// Should not be a hard violation
if len(violations) != 0 {
t.Fatalf("expected 0 hard violations for missing query error, got %d: %#v", len(violations), violations)
}
}
func TestCheckRPCErrors_QueryWithError_NoWarning(t *testing.T) {
dir := makeRPCDir(t)
writeRPCComponent(t, dir, "components/my-component.tsx", `
import { useQuery } from '@block-ninja/api/queries'
export function MyComponent() {
const { data, isLoading, error } = useQuery(myQuery, {})
if (error) return <div>Error!</div>
return <div>{data}</div>
}
`)
_, warnings := checkRPCErrors(dir)
if len(warnings) != 0 {
t.Fatalf("expected 0 warnings when error is destructured, got %d: %#v", len(warnings), warnings)
}
}
// ─────────────────────────────────────────────
// checkRPCErrors mutation-no-error-handler violation
// ─────────────────────────────────────────────
func TestCheckRPCErrors_MutationWithNoErrorHandler_Violation(t *testing.T) {
dir := makeRPCDir(t)
// No global handler, no onError, no try/catch, no toast.error
writeRPCComponent(t, dir, "components/editor.tsx", `
import { useMutation } from '@block-ninja/api/queries'
export function Editor() {
const saveMutation = useMutation(saveBlock)
function handleSave() {
saveMutation.mutate({ content: 'hello' })
}
return <button onClick={handleSave}>Save</button>
}
`)
violations, _ := checkRPCErrors(dir)
if len(violations) == 0 {
t.Fatal("expected violation for mutation without error handler")
}
found := false
for _, v := range violations {
if v.rule == "mutation-no-error-handler" {
found = true
}
}
if !found {
t.Fatalf("expected rule 'mutation-no-error-handler', got: %#v", violations)
}
}
func TestCheckRPCErrors_MutationWithOnError_Clean(t *testing.T) {
dir := makeRPCDir(t)
writeRPCComponent(t, dir, "components/editor.tsx", `
import { useMutation } from '@block-ninja/api/queries'
import { toast } from 'sonner'
export function Editor() {
const saveMutation = useMutation(saveBlock, {
onError: (err) => toast.error('Save failed', { description: err.message }),
onSuccess: () => toast.success('Saved'),
})
return <button onClick={() => saveMutation.mutate({})}>Save</button>
}
`)
violations, _ := checkRPCErrors(dir)
if len(violations) != 0 {
t.Fatalf("expected 0 violations for mutation with onError, got %d: %#v", len(violations), violations)
}
}
func TestCheckRPCErrors_MutationWithTryCatch_Clean(t *testing.T) {
dir := makeRPCDir(t)
writeRPCComponent(t, dir, "components/editor.tsx", `
import { useMutation } from '@block-ninja/api/queries'
export function Editor() {
const saveMutation = useMutation(saveBlock)
async function handleSave() {
try {
await saveMutation.mutateAsync({})
} catch (err) {
console.error(err)
}
}
return <button onClick={handleSave}>Save</button>
}
`)
violations, _ := checkRPCErrors(dir)
if len(violations) != 0 {
t.Fatalf("expected 0 violations when try/catch is present, got %d: %#v", len(violations), violations)
}
}
func TestCheckRPCErrors_MutationWithToastError_Clean(t *testing.T) {
dir := makeRPCDir(t)
writeRPCComponent(t, dir, "components/editor.tsx", `
import { useMutation } from '@block-ninja/api/queries'
import { toast } from 'sonner'
export function Editor() {
const saveMutation = useMutation(saveBlock)
function handleSave() {
saveMutation.mutate({}, {
onSuccess: () => toast.success('Done'),
})
// fallback: toast.error happens elsewhere
}
function handleError(err) {
toast.error('Failed: ' + err.message)
}
return <button onClick={handleSave}>Save</button>
}
`)
violations, _ := checkRPCErrors(dir)
if len(violations) != 0 {
t.Fatalf("expected 0 violations when toast.error is present in file, got %d: %#v", len(violations), violations)
}
}
func TestCheckRPCErrors_GlobalHandlerSuppressesViolation(t *testing.T) {
dir := makeRPCDir(t)
writeGlobalMutationHandler(t, dir)
// Mutation with no per-mutation error handling — suppressed by global handler
writeRPCComponent(t, dir, "components/editor.tsx", `
import { useMutation } from '@block-ninja/api/queries'
export function Editor() {
const saveMutation = useMutation(saveBlock)
return <button onClick={() => saveMutation.mutate({})}>Save</button>
}
`)
violations, _ := checkRPCErrors(dir)
if len(violations) != 0 {
t.Fatalf("expected 0 violations when global mutation handler is present, got %d: %#v", len(violations), violations)
}
}
// ─────────────────────────────────────────────
// checkRPCErrors file path scoping
// ─────────────────────────────────────────────
func TestCheckRPCErrors_SkipsFilesOutsideComponentsAndRoutes(t *testing.T) {
dir := makeRPCDir(t)
// File is in lib/ — not components/ or routes/, should be skipped
writeRPCComponent(t, dir, "lib/utils.tsx", `
import { useMutation } from '@block-ninja/api/queries'
export function useHelperMutation() {
return useMutation(doSomething)
}
`)
violations, warnings := checkRPCErrors(dir)
if len(violations) != 0 || len(warnings) != 0 {
t.Fatalf("expected 0 violations and 0 warnings for file outside scoped dirs, got v=%d w=%d",
len(violations), len(warnings))
}
}
func TestCheckRPCErrors_ChecksRouteFiles(t *testing.T) {
dir := makeRPCDir(t)
// File in routes/ should be checked — mutation with no handler
writeRPCComponent(t, dir, "routes/dashboard.tsx", `
import { useMutation } from '@block-ninja/api/queries'
export function Dashboard() {
const doThing = useMutation(action)
return <button onClick={() => doThing.mutate({})}>Go</button>
}
`)
violations, _ := checkRPCErrors(dir)
if len(violations) == 0 {
t.Fatal("expected violation for mutation in routes/ without error handler")
}
}
// ─────────────────────────────────────────────
// checkRPCErrors comment lines skipped
// ─────────────────────────────────────────────
func TestCheckRPCErrors_CommentedMutationSkipped(t *testing.T) {
dir := makeRPCDir(t)
writeRPCComponent(t, dir, "components/thing.tsx", `
export function Thing() {
// const doThing = useMutation(action)
return <div />
}
`)
violations, _ := checkRPCErrors(dir)
if len(violations) != 0 {
t.Fatalf("expected 0 violations for commented-out mutation, got %d: %#v", len(violations), violations)
}
}
// ─────────────────────────────────────────────
// hasGlobalMutationErrorHandler
// ─────────────────────────────────────────────
func TestHasGlobalMutationErrorHandler_Present(t *testing.T) {
dir := makeRPCDir(t)
writeGlobalMutationHandler(t, dir)
if !hasGlobalMutationErrorHandler(dir) {
t.Fatal("expected hasGlobalMutationErrorHandler to return true when handler is present")
}
}
func TestHasGlobalMutationErrorHandler_Missing(t *testing.T) {
dir := makeRPCDir(t)
if hasGlobalMutationErrorHandler(dir) {
t.Fatal("expected hasGlobalMutationErrorHandler to return false when file is absent")
}
}
func TestHasGlobalMutationErrorHandler_PartialContent(t *testing.T) {
dir := makeRPCDir(t)
libDir := filepath.Join(dir, "lib")
if err := os.MkdirAll(libDir, 0755); err != nil {
t.Fatalf("mkdir lib: %v", err)
}
// Has MutationCache but no onError
if err := os.WriteFile(filepath.Join(libDir, "query-client.ts"), []byte(`
import { MutationCache } from '@tanstack/react-query'
const cache = new MutationCache({})
`), 0644); err != nil {
t.Fatalf("write query-client.ts: %v", err)
}
if hasGlobalMutationErrorHandler(dir) {
t.Fatal("expected false when MutationCache present but onError absent")
}
}

296
segmentation.go Normal file
View File

@ -0,0 +1,296 @@
package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
// SafetyRules represents the safety-rules.yml schema for a plugin.
type SafetyRules struct {
Segmentation *SegmentationRules `yaml:"segmentation"`
}
// SegmentationRules defines cross-plugin isolation constraints.
type SegmentationRules struct {
NoGoImportsFrom []string `yaml:"no_go_imports_from"`
NoFrontendImportsFrom []string `yaml:"no_frontend_imports_from"`
MigrationTablePrefix string `yaml:"migration_table_prefix"`
RequireFiles []string `yaml:"require_files"`
FederationName string `yaml:"federation_name"`
}
type segmentationViolation struct {
pluginName string
rule string
file string
line int
detail string
}
// loadSafetyRules reads safety-rules.yml from a plugin root. Returns nil if the file does not exist.
func loadSafetyRules(pluginRoot string) (*SafetyRules, error) {
path := filepath.Join(pluginRoot, "safety-rules.yml")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read %s: %w", path, err)
}
var rules SafetyRules
if err := yaml.Unmarshal(data, &rules); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return &rules, nil
}
// checkSegmentation runs all segmentation rules for a plugin and returns violations.
func checkSegmentation(pluginRoot, pluginName string, rules *SegmentationRules) []segmentationViolation {
var violations []segmentationViolation
// Rule: no_go_imports_from
if len(rules.NoGoImportsFrom) > 0 {
violations = append(violations, checkNoGoImports(pluginRoot, pluginName, rules.NoGoImportsFrom)...)
}
// Rule: no_frontend_imports_from
if len(rules.NoFrontendImportsFrom) > 0 {
violations = append(violations, checkNoFrontendImports(pluginRoot, pluginName, rules.NoFrontendImportsFrom)...)
}
// Rule: migration_table_prefix
if rules.MigrationTablePrefix != "" {
violations = append(violations, checkMigrationFKs(pluginRoot, pluginName, rules.MigrationTablePrefix)...)
}
// Rule: require_files
for _, reqFile := range rules.RequireFiles {
fullPath := filepath.Join(pluginRoot, reqFile)
if !fileExists(fullPath) {
violations = append(violations, segmentationViolation{
pluginName: pluginName,
rule: "require_files",
file: reqFile,
detail: fmt.Sprintf("required file %s does not exist", reqFile),
})
}
}
// Rule: federation_name
if rules.FederationName != "" {
violations = append(violations, checkFederationName(pluginRoot, pluginName, rules.FederationName)...)
}
return violations
}
// checkNoGoImports scans Go source files for references to forbidden plugin names.
func checkNoGoImports(pluginRoot, pluginName string, forbidden []string) []segmentationViolation {
var violations []segmentationViolation
_ = filepath.Walk(pluginRoot, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
// Skip non-Go, test files, vendor, .git, web directory
if info.IsDir() {
base := filepath.Base(path)
if base == ".git" || base == "vendor" || base == "web" || base == "node_modules" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
rel, _ := filepath.Rel(pluginRoot, path)
f, openErr := os.Open(path)
if openErr != nil {
return nil
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
for _, name := range forbidden {
if strings.Contains(line, name) {
violations = append(violations, segmentationViolation{
pluginName: pluginName,
rule: "no_go_imports_from",
file: rel,
line: lineNum,
detail: fmt.Sprintf("references forbidden plugin %q", name),
})
}
}
}
return nil
})
return violations
}
// checkNoFrontendImports scans TS/TSX files for references to forbidden plugin names.
func checkNoFrontendImports(pluginRoot, pluginName string, forbidden []string) []segmentationViolation {
var violations []segmentationViolation
webDir := filepath.Join(pluginRoot, "web")
if !dirExists(webDir) {
return nil
}
_ = filepath.Walk(webDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
base := filepath.Base(path)
if base == "node_modules" || base == "dist" || base == ".git" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".ts") && !strings.HasSuffix(path, ".tsx") {
return nil
}
// Skip generated files
if strings.Contains(path, "/generated/") {
return nil
}
rel, _ := filepath.Rel(pluginRoot, path)
f, openErr := os.Open(path)
if openErr != nil {
return nil
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
for _, name := range forbidden {
if strings.Contains(line, name) {
violations = append(violations, segmentationViolation{
pluginName: pluginName,
rule: "no_frontend_imports_from",
file: rel,
line: lineNum,
detail: fmt.Sprintf("references forbidden plugin %q", name),
})
}
}
}
return nil
})
return violations
}
// checkMigrationFKs scans migration SQL for REFERENCES to tables outside the allowed prefix.
func checkMigrationFKs(pluginRoot, pluginName, tablePrefix string) []segmentationViolation {
var violations []segmentationViolation
migrationsDir := filepath.Join(pluginRoot, "migrations")
if !dirExists(migrationsDir) {
return nil
}
// Match REFERENCES <table_name>
refRe := regexp.MustCompile(`(?i)REFERENCES\s+(\w+)`)
_ = filepath.Walk(migrationsDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".sql") {
return nil
}
// Skip down migrations
if strings.Contains(filepath.Base(path), "_down") {
return nil
}
rel, _ := filepath.Rel(pluginRoot, path)
f, openErr := os.Open(path)
if openErr != nil {
return nil
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
// Skip down-migration blocks
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "--") {
continue
}
matches := refRe.FindAllStringSubmatch(line, -1)
for _, match := range matches {
tableName := match[1]
// Allow references to own tables and public_users (common cross-reference)
if strings.HasPrefix(tableName, tablePrefix) || tableName == "public_users" {
continue
}
violations = append(violations, segmentationViolation{
pluginName: pluginName,
rule: "migration_table_prefix",
file: rel,
line: lineNum,
detail: fmt.Sprintf("FK references table %q which does not have prefix %q", tableName, tablePrefix),
})
}
}
return nil
})
return violations
}
// checkFederationName verifies the vite.config.ts federation name matches the expected value.
func checkFederationName(pluginRoot, pluginName, expectedName string) []segmentationViolation {
viteConfig := filepath.Join(pluginRoot, "web", "vite.config.ts")
if !fileExists(viteConfig) {
return nil // require_files check handles missing vite config
}
data, err := os.ReadFile(viteConfig)
if err != nil {
return nil
}
// Look for name: "messenger" or name: 'messenger' in federation config
nameRe := regexp.MustCompile(`name:\s*["'](\w+)["']`)
matches := nameRe.FindSubmatch(data)
if len(matches) < 2 {
return []segmentationViolation{{
pluginName: pluginName,
rule: "federation_name",
file: "web/vite.config.ts",
detail: "could not find federation name declaration",
}}
}
actualName := string(matches[1])
if actualName != expectedName {
return []segmentationViolation{{
pluginName: pluginName,
rule: "federation_name",
file: "web/vite.config.ts",
detail: fmt.Sprintf("federation name is %q, expected %q", actualName, expectedName),
}}
}
return nil
}

486
segmentation_test.go Normal file
View File

@ -0,0 +1,486 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// ─────────────────────────────────────────────
// loadSafetyRules
// ─────────────────────────────────────────────
func TestLoadSafetyRules_Missing(t *testing.T) {
dir := t.TempDir()
rules, err := loadSafetyRules(dir)
if err != nil {
t.Fatalf("loadSafetyRules() unexpected error for missing file: %v", err)
}
if rules != nil {
t.Fatalf("loadSafetyRules() want nil for missing file, got %+v", rules)
}
}
func TestLoadSafetyRules_ValidYAML(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "safety-rules.yml", `
segmentation:
no_go_imports_from:
- otherplugin
migration_table_prefix: "myplugin_"
federation_name: myplugin
`)
rules, err := loadSafetyRules(dir)
if err != nil {
t.Fatalf("loadSafetyRules() unexpected error: %v", err)
}
if rules == nil || rules.Segmentation == nil {
t.Fatal("loadSafetyRules() returned nil rules or nil segmentation")
}
if len(rules.Segmentation.NoGoImportsFrom) != 1 || rules.Segmentation.NoGoImportsFrom[0] != "otherplugin" {
t.Fatalf("unexpected NoGoImportsFrom: %v", rules.Segmentation.NoGoImportsFrom)
}
if rules.Segmentation.MigrationTablePrefix != "myplugin_" {
t.Fatalf("unexpected MigrationTablePrefix: %q", rules.Segmentation.MigrationTablePrefix)
}
if rules.Segmentation.FederationName != "myplugin" {
t.Fatalf("unexpected FederationName: %q", rules.Segmentation.FederationName)
}
}
func TestLoadSafetyRules_InvalidYAML(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "safety-rules.yml", `
segmentation:
no_go_imports_from: {bad yaml [
`)
_, err := loadSafetyRules(dir)
if err == nil {
t.Fatal("loadSafetyRules() expected error for invalid YAML, got nil")
}
}
func TestLoadSafetyRules_EmptyFile(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "safety-rules.yml", ``)
rules, err := loadSafetyRules(dir)
if err != nil {
t.Fatalf("loadSafetyRules() unexpected error for empty file: %v", err)
}
// Empty YAML → non-nil SafetyRules with nil Segmentation
if rules != nil && rules.Segmentation != nil {
t.Fatalf("loadSafetyRules() want nil segmentation for empty file, got %+v", rules.Segmentation)
}
}
// ─────────────────────────────────────────────
// checkSegmentation require_files rule
// ─────────────────────────────────────────────
func TestCheckSegmentation_RequireFiles_Present(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "README.md", "# plugin")
rules := &SegmentationRules{
RequireFiles: []string{"README.md"},
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) != 0 {
t.Fatalf("expected 0 violations when required file exists, got %d: %#v", len(vs), vs)
}
}
func TestCheckSegmentation_RequireFiles_Missing(t *testing.T) {
dir := t.TempDir()
// README.md does NOT exist
rules := &SegmentationRules{
RequireFiles: []string{"README.md"},
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) != 1 {
t.Fatalf("expected 1 violation for missing required file, got %d: %#v", len(vs), vs)
}
if vs[0].rule != "require_files" {
t.Fatalf("expected rule 'require_files', got %q", vs[0].rule)
}
if vs[0].pluginName != "myplugin" {
t.Fatalf("expected pluginName 'myplugin', got %q", vs[0].pluginName)
}
}
func TestCheckSegmentation_RequireFiles_Multiple(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "exists.md", "exists")
// "missing.md" does not exist
rules := &SegmentationRules{
RequireFiles: []string{"exists.md", "missing.md"},
}
vs := checkSegmentation(dir, "plugin", rules)
if len(vs) != 1 {
t.Fatalf("expected 1 violation for 1 missing file, got %d: %#v", len(vs), vs)
}
if vs[0].file != "missing.md" {
t.Fatalf("expected violation for missing.md, got %q", vs[0].file)
}
}
// ─────────────────────────────────────────────
// checkSegmentation no_go_imports_from rule
// ─────────────────────────────────────────────
func TestCheckSegmentation_NoGoImports_Clean(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "plugin.go", `package myplugin
import "fmt"
func Hello() string {
return fmt.Sprintf("hello")
}
`)
rules := &SegmentationRules{
NoGoImportsFrom: []string{"otherplugin"},
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for clean Go file, got %d: %#v", len(vs), vs)
}
}
func TestCheckSegmentation_NoGoImports_Violation(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "plugin.go", `package myplugin
import "git.example.com/block/ninja/internal/plugins/otherplugin"
func Hello() {
_ = otherplugin.SomeFunc()
}
`)
rules := &SegmentationRules{
NoGoImportsFrom: []string{"otherplugin"},
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) == 0 {
t.Fatal("expected violations for forbidden import, got 0")
}
found := false
for _, v := range vs {
if v.rule == "no_go_imports_from" {
found = true
}
}
if !found {
t.Fatalf("expected rule 'no_go_imports_from', got: %#v", vs)
}
}
func TestCheckSegmentation_NoGoImports_TestFilesSkipped(t *testing.T) {
dir := t.TempDir()
// Test files should be skipped by the scanner
writeFile(t, dir, "plugin_test.go", `package myplugin
import "git.example.com/block/ninja/internal/plugins/otherplugin"
func TestSomething(t *testing.T) {
_ = otherplugin.SomeFunc()
}
`)
rules := &SegmentationRules{
NoGoImportsFrom: []string{"otherplugin"},
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for test file, got %d: %#v", len(vs), vs)
}
}
// ─────────────────────────────────────────────
// checkSegmentation no_frontend_imports_from rule
// ─────────────────────────────────────────────
func TestCheckSegmentation_NoFrontendImports_NoWebDir(t *testing.T) {
dir := t.TempDir()
// No web/ directory — should return nothing
rules := &SegmentationRules{
NoFrontendImportsFrom: []string{"otherplugin"},
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) != 0 {
t.Fatalf("expected 0 violations when no web/ dir, got %d: %#v", len(vs), vs)
}
}
func TestCheckSegmentation_NoFrontendImports_Clean(t *testing.T) {
dir := t.TempDir()
webDir := filepath.Join(dir, "web", "src")
if err := os.MkdirAll(webDir, 0755); err != nil {
t.Fatalf("mkdir web: %v", err)
}
writeFile(t, webDir, "component.tsx", `import React from 'react'
export function MyComponent() {
return <div>Hello</div>
}
`)
rules := &SegmentationRules{
NoFrontendImportsFrom: []string{"otherplugin"},
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for clean frontend, got %d: %#v", len(vs), vs)
}
}
func TestCheckSegmentation_NoFrontendImports_Violation(t *testing.T) {
dir := t.TempDir()
webDir := filepath.Join(dir, "web", "src")
if err := os.MkdirAll(webDir, 0755); err != nil {
t.Fatalf("mkdir web: %v", err)
}
writeFile(t, webDir, "component.tsx", `import { SomeThing } from 'otherplugin/components'
export function MyComponent() {
return <SomeThing />
}
`)
rules := &SegmentationRules{
NoFrontendImportsFrom: []string{"otherplugin"},
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) == 0 {
t.Fatal("expected violations for forbidden frontend import, got 0")
}
found := false
for _, v := range vs {
if v.rule == "no_frontend_imports_from" {
found = true
}
}
if !found {
t.Fatalf("expected rule 'no_frontend_imports_from', got: %#v", vs)
}
}
// ─────────────────────────────────────────────
// checkSegmentation migration_table_prefix rule
// ─────────────────────────────────────────────
func TestCheckSegmentation_MigrationFK_NoMigrationsDir(t *testing.T) {
dir := t.TempDir()
// No migrations/ directory — should skip gracefully
rules := &SegmentationRules{
MigrationTablePrefix: "myplugin_",
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) != 0 {
t.Fatalf("expected 0 violations when no migrations/ dir, got %d: %#v", len(vs), vs)
}
}
func TestCheckSegmentation_MigrationFK_AllowsOwnPrefix(t *testing.T) {
dir := t.TempDir()
migrDir := filepath.Join(dir, "migrations")
if err := os.MkdirAll(migrDir, 0755); err != nil {
t.Fatalf("mkdir migrations: %v", err)
}
writeFile(t, migrDir, "001_create.sql", `
CREATE TABLE myplugin_items (
id BIGINT PRIMARY KEY,
FOREIGN KEY (owner_id) REFERENCES myplugin_owners(id)
);
`)
rules := &SegmentationRules{
MigrationTablePrefix: "myplugin_",
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for FK within own prefix, got %d: %#v", len(vs), vs)
}
}
func TestCheckSegmentation_MigrationFK_AllowsPublicUsers(t *testing.T) {
dir := t.TempDir()
migrDir := filepath.Join(dir, "migrations")
if err := os.MkdirAll(migrDir, 0755); err != nil {
t.Fatalf("mkdir migrations: %v", err)
}
writeFile(t, migrDir, "001_create.sql", `
CREATE TABLE myplugin_posts (
id BIGINT PRIMARY KEY,
user_id BIGINT REFERENCES public_users(id)
);
`)
rules := &SegmentationRules{
MigrationTablePrefix: "myplugin_",
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for FK to public_users, got %d: %#v", len(vs), vs)
}
}
func TestCheckSegmentation_MigrationFK_CrossPluginViolation(t *testing.T) {
dir := t.TempDir()
migrDir := filepath.Join(dir, "migrations")
if err := os.MkdirAll(migrDir, 0755); err != nil {
t.Fatalf("mkdir migrations: %v", err)
}
writeFile(t, migrDir, "001_create.sql", `
CREATE TABLE myplugin_items (
id BIGINT PRIMARY KEY,
other_id BIGINT REFERENCES otherplugin_widgets(id)
);
`)
rules := &SegmentationRules{
MigrationTablePrefix: "myplugin_",
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) == 0 {
t.Fatal("expected violations for cross-plugin FK reference, got 0")
}
found := false
for _, v := range vs {
if v.rule == "migration_table_prefix" {
found = true
}
}
if !found {
t.Fatalf("expected rule 'migration_table_prefix', got: %#v", vs)
}
}
func TestCheckSegmentation_MigrationFK_SkipsDownFiles(t *testing.T) {
dir := t.TempDir()
migrDir := filepath.Join(dir, "migrations")
if err := os.MkdirAll(migrDir, 0755); err != nil {
t.Fatalf("mkdir migrations: %v", err)
}
// _down file should be skipped
writeFile(t, migrDir, "001_create_down.sql", `
DROP TABLE IF EXISTS myplugin_items;
ALTER TABLE otherplugin_widgets DROP CONSTRAINT fk_myplugin;
`)
rules := &SegmentationRules{
MigrationTablePrefix: "myplugin_",
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for _down migration, got %d: %#v", len(vs), vs)
}
}
// ─────────────────────────────────────────────
// checkSegmentation federation_name rule
// ─────────────────────────────────────────────
func TestCheckSegmentation_FederationName_NoViteConfig(t *testing.T) {
dir := t.TempDir()
webDir := filepath.Join(dir, "web")
if err := os.MkdirAll(webDir, 0755); err != nil {
t.Fatalf("mkdir web: %v", err)
}
// No vite.config.ts — should skip (require_files handles this)
rules := &SegmentationRules{
FederationName: "myplugin",
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) != 0 {
t.Fatalf("expected 0 violations when vite.config.ts is absent, got %d: %#v", len(vs), vs)
}
}
func TestCheckSegmentation_FederationName_Correct(t *testing.T) {
dir := t.TempDir()
webDir := filepath.Join(dir, "web")
if err := os.MkdirAll(webDir, 0755); err != nil {
t.Fatalf("mkdir web: %v", err)
}
writeFile(t, webDir, "vite.config.ts", `
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
federation({
name: "myplugin",
exposes: { './Editor': './src/Editor' },
}),
],
})
`)
rules := &SegmentationRules{
FederationName: "myplugin",
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) != 0 {
t.Fatalf("expected 0 violations for correct federation name, got %d: %#v", len(vs), vs)
}
}
func TestCheckSegmentation_FederationName_Wrong(t *testing.T) {
dir := t.TempDir()
webDir := filepath.Join(dir, "web")
if err := os.MkdirAll(webDir, 0755); err != nil {
t.Fatalf("mkdir web: %v", err)
}
writeFile(t, webDir, "vite.config.ts", `
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
federation({
name: "wrongname",
}),
],
})
`)
rules := &SegmentationRules{
FederationName: "myplugin",
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) == 0 {
t.Fatal("expected violations for wrong federation name, got 0")
}
if vs[0].rule != "federation_name" {
t.Fatalf("expected rule 'federation_name', got %q", vs[0].rule)
}
}
func TestCheckSegmentation_FederationName_NoNameDecl(t *testing.T) {
dir := t.TempDir()
webDir := filepath.Join(dir, "web")
if err := os.MkdirAll(webDir, 0755); err != nil {
t.Fatalf("mkdir web: %v", err)
}
// vite.config.ts without a federation name: "..." declaration
writeFile(t, webDir, "vite.config.ts", `
export default {}
`)
rules := &SegmentationRules{
FederationName: "myplugin",
}
vs := checkSegmentation(dir, "myplugin", rules)
if len(vs) == 0 {
t.Fatal("expected violation when federation name declaration is absent, got 0")
}
if vs[0].rule != "federation_name" {
t.Fatalf("expected rule 'federation_name', got %q", vs[0].rule)
}
}

177
sqlc_uuid.go Normal file
View File

@ -0,0 +1,177 @@
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
)
type sqlcUUIDViolation struct {
file string
rule string
detail string
}
type sqlcConfigFile struct {
SQL []sqlcConfigEntry `yaml:"sql"`
}
type sqlcConfigEntry struct {
Gen sqlcGenConfig `yaml:"gen"`
}
type sqlcGenConfig struct {
Go sqlcGoConfig `yaml:"go"`
}
type sqlcGoConfig struct {
Overrides []sqlcOverride `yaml:"overrides"`
}
type sqlcOverride struct {
DBType string `yaml:"db_type"`
Nullable bool `yaml:"nullable"`
GoType sqlcGoType `yaml:"go_type"`
}
type sqlcGoType struct {
Scalar string
Import string
Type string
}
func (g *sqlcGoType) UnmarshalYAML(node *yaml.Node) error {
switch node.Kind {
case yaml.ScalarNode:
return node.Decode(&g.Scalar)
case yaml.MappingNode:
var decoded struct {
Import string `yaml:"import"`
Type string `yaml:"type"`
}
if err := node.Decode(&decoded); err != nil {
return err
}
g.Import = decoded.Import
g.Type = decoded.Type
return nil
default:
return fmt.Errorf("unsupported go_type yaml node kind %d", node.Kind)
}
}
func (g sqlcGoType) matchesGoogleUUID(nullable bool) bool {
if nullable {
return g.Scalar == "github.com/google/uuid.NullUUID" ||
(g.Import == "github.com/google/uuid" && g.Type == "NullUUID")
}
return g.Scalar == "github.com/google/uuid.UUID" ||
(g.Import == "github.com/google/uuid" && g.Type == "UUID")
}
func findSQLCConfigFiles(root string, includeRoot bool) []string {
var files []string
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
switch info.Name() {
case ".git", "vendor", "node_modules", "dist":
return filepath.SkipDir
}
return nil
}
base := filepath.Base(path)
if base != "sqlc.yaml" && base != "sqlc.yml" {
return nil
}
if includeRoot && samePath(path, filepath.Join(root, base)) {
files = append(files, path)
return nil
}
rel, relErr := filepath.Rel(root, path)
if relErr != nil {
return nil
}
if strings.Contains(filepath.ToSlash(rel), "/cmd/") || strings.HasPrefix(filepath.ToSlash(rel), "cmd/") {
files = append(files, path)
}
return nil
})
sort.Strings(files)
return files
}
func checkSQLCUUIDOverrides(root string, includeRoot bool) []sqlcUUIDViolation {
var violations []sqlcUUIDViolation
for _, path := range findSQLCConfigFiles(root, includeRoot) {
data, err := os.ReadFile(path)
if err != nil {
violations = append(violations, sqlcUUIDViolation{
file: displayLintPath(root, path),
rule: "sqlc-read",
detail: fmt.Sprintf("failed to read sqlc config: %v", err),
})
continue
}
var cfg sqlcConfigFile
if err := yaml.Unmarshal(data, &cfg); err != nil {
violations = append(violations, sqlcUUIDViolation{
file: displayLintPath(root, path),
rule: "sqlc-parse",
detail: fmt.Sprintf("failed to parse sqlc config: %v", err),
})
continue
}
hasUUID := false
hasNullUUID := false
for _, block := range cfg.SQL {
for _, override := range block.Gen.Go.Overrides {
if override.DBType != "uuid" {
continue
}
if override.Nullable {
if override.GoType.matchesGoogleUUID(true) {
hasNullUUID = true
}
continue
}
if override.GoType.matchesGoogleUUID(false) {
hasUUID = true
}
}
}
relPath := displayLintPath(root, path)
if !hasUUID {
violations = append(violations, sqlcUUIDViolation{
file: relPath,
rule: "sqlc-uuid-type",
detail: `db_type "uuid" must map to github.com/google/uuid.UUID`,
})
}
if !hasNullUUID {
violations = append(violations, sqlcUUIDViolation{
file: relPath,
rule: "sqlc-null-uuid-type",
detail: `nullable db_type "uuid" must map to github.com/google/uuid.NullUUID`,
})
}
}
return violations
}

74
sqlc_uuid_test.go Normal file
View File

@ -0,0 +1,74 @@
package main
import (
"path/filepath"
"strings"
"testing"
)
func TestCheckSQLCUUIDOverridesFlagsStringTypes(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "sqlc.yaml"), `version: "2"
sql:
- engine: "postgresql"
gen:
go:
overrides:
- db_type: "uuid"
go_type: "string"
- db_type: "uuid"
nullable: true
go_type:
type: "string"
pointer: true
`, 0644)
violations := checkSQLCUUIDOverrides(root, true)
if len(violations) != 2 {
t.Fatalf("checkSQLCUUIDOverrides() returned %d violations, want 2: %#v", len(violations), violations)
}
rules := []string{violations[0].rule, violations[1].rule}
if !strings.Contains(strings.Join(rules, ","), "sqlc-uuid-type") {
t.Fatalf("rules = %#v, want sqlc-uuid-type", rules)
}
if !strings.Contains(strings.Join(rules, ","), "sqlc-null-uuid-type") {
t.Fatalf("rules = %#v, want sqlc-null-uuid-type", rules)
}
}
func TestCheckSQLCUUIDOverridesAllowsGoogleUUIDTypes(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "sqlc.yaml"), `version: "2"
sql:
- engine: "postgresql"
gen:
go:
overrides:
- db_type: "uuid"
go_type:
import: "github.com/google/uuid"
type: "UUID"
- db_type: "uuid"
nullable: true
go_type:
import: "github.com/google/uuid"
type: "NullUUID"
`, 0644)
violations := checkSQLCUUIDOverrides(root, true)
if len(violations) != 0 {
t.Fatalf("checkSQLCUUIDOverrides() returned %d violations, want 0: %#v", len(violations), violations)
}
}
func TestFindSQLCConfigFilesIncludesRootAndCmdConfigs(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "sqlc.yaml"), `version: "2"`, 0644)
writeTestFile(t, filepath.Join(root, "cmd", "tool", "sqlc.yaml"), `version: "2"`, 0644)
writeTestFile(t, filepath.Join(root, "internal", "sqlc.yaml"), `version: "2"`, 0644)
files := findSQLCConfigFiles(root, true)
if len(files) != 2 {
t.Fatalf("findSQLCConfigFiles() returned %d files, want 2: %#v", len(files), files)
}
}

187
tailwind.go Normal file
View File

@ -0,0 +1,187 @@
package main
import (
"bufio"
"os"
"path/filepath"
"regexp"
"strings"
"git.dev.alexdunmow.com/block/check-safety/internal/helpers"
)
type tailwindViolation struct {
file string
line int
rule string
snippet string
}
var (
// PostCSS config: bare tailwindcss plugin (v3 syntax)
rePostCSSBarePlugin = regexp.MustCompile(`(?:['"]?tailwindcss['"]?\s*:|['"]tailwindcss['"])`)
// PostCSS config: correct v4 plugin
rePostCSSV4Plugin = regexp.MustCompile(`@tailwindcss/postcss`)
// CSS: old @tailwind directives (v3)
reTailwindDirective = regexp.MustCompile(`^\s*@tailwind\s+(base|components|utilities)`)
// CSS: v4 import
reTailwindV4Import = regexp.MustCompile(`@import\s+["']tailwindcss["']`)
// CSS: @config directive
reCSSConfigDirective = regexp.MustCompile(`^\s*@config\s+`)
// CSS: old autoprefixer in postcss config (unnecessary with v4)
rePostCSSAutoprefixer = regexp.MustCompile(`(?:['"]?autoprefixer['"]?\s*:|['"]autoprefixer['"])`)
)
func checkTailwindV4(frontendDir string) []tailwindViolation {
var violations []tailwindViolation
projectDir := filepath.Dir(frontendDir)
for _, v := range checkPostCSSConfig(projectDir) {
v.file = filepath.Clean(filepath.Join("..", v.file))
violations = append(violations, v)
}
violations = append(violations, checkCSSDirectives(frontendDir, projectDir)...)
return violations
}
func checkPostCSSConfig(projectDir string) []tailwindViolation {
var violations []tailwindViolation
configNames := []string{"postcss.config.js", "postcss.config.mjs", "postcss.config.cjs", "postcss.config.ts", ".postcssrc", ".postcssrc.js", ".postcssrc.json"}
for _, name := range configNames {
configPath := filepath.Join(projectDir, name)
if !fileExists(configPath) {
continue
}
violations = append(violations, scanPostCSSFile(configPath, name)...)
}
return violations
}
func scanPostCSSFile(configPath, name string) []tailwindViolation {
var violations []tailwindViolation
f, err := os.Open(configPath)
if err != nil {
return nil
}
defer helpers.LogDeferredError(nil, "close postcss config file", f.Close, "path", configPath)
scanner := bufio.NewScanner(f)
lineNum := 0
hasV4Plugin := false
for scanner.Scan() {
lineNum++
line := scanner.Text()
if rePostCSSV4Plugin.MatchString(line) {
hasV4Plugin = true
}
if rePostCSSBarePlugin.MatchString(line) && !rePostCSSV4Plugin.MatchString(line) {
violations = append(violations, tailwindViolation{
file: name,
line: lineNum,
rule: "postcss-v4-plugin",
snippet: strings.TrimSpace(line),
})
}
if hasV4Plugin && rePostCSSAutoprefixer.MatchString(line) {
violations = append(violations, tailwindViolation{
file: name,
line: lineNum,
rule: "postcss-no-autoprefixer",
snippet: strings.TrimSpace(line),
})
}
}
return violations
}
func checkCSSDirectives(frontendDir, projectDir string) []tailwindViolation {
var violations []tailwindViolation
hasTailwindConfig := fileExists(filepath.Join(projectDir, "tailwind.config.ts")) ||
fileExists(filepath.Join(projectDir, "tailwind.config.js"))
if err := filepath.Walk(frontendDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return nil
}
if !strings.HasSuffix(path, ".css") {
return nil
}
if strings.Contains(path, "node_modules") || strings.Contains(path, "/dist/") {
return nil
}
relPath, _ := filepath.Rel(frontendDir, path)
if relPath == "" {
relPath = path
}
f, openErr := os.Open(path)
if openErr != nil {
return nil
}
defer helpers.LogDeferredError(nil, "close CSS file for tailwind scan", f.Close, "path", path)
scanner := bufio.NewScanner(f)
lineNum := 0
hasTailwindImport := false
hasConfigDirective := false
for scanner.Scan() {
lineNum++
line := scanner.Text()
if reTailwindV4Import.MatchString(line) {
hasTailwindImport = true
}
if reCSSConfigDirective.MatchString(line) {
hasConfigDirective = true
}
if reTailwindDirective.MatchString(line) {
violations = append(violations, tailwindViolation{
file: relPath,
line: lineNum,
rule: "css-v4-import",
snippet: strings.TrimSpace(line),
})
}
}
if hasTailwindImport && hasTailwindConfig && !hasConfigDirective {
violations = append(violations, tailwindViolation{
file: relPath,
line: 0,
rule: "css-missing-config",
snippet: "uses @import \"tailwindcss\" but missing @config directive (tailwind.config.* exists)",
})
}
return nil
}); err != nil {
violations = append(violations, tailwindViolation{
file: frontendDir,
line: 0,
rule: "walk-error",
snippet: err.Error(),
})
}
return violations
}

222
tailwind_test.go Normal file
View File

@ -0,0 +1,222 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestCheckPostCSSConfig_BarePlugin(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "postcss.config.js")
if err := os.WriteFile(configPath, []byte(`export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
`), 0644); err != nil {
t.Fatal(err)
}
violations := checkPostCSSConfig(dir)
if len(violations) == 0 {
t.Fatal("expected violations for bare tailwindcss plugin")
}
foundPlugin := false
for _, v := range violations {
if v.rule == "postcss-v4-plugin" {
foundPlugin = true
}
}
if !foundPlugin {
t.Fatalf("expected postcss-v4-plugin violation, got %v", violations)
}
}
func TestCheckPostCSSConfig_V4Plugin_OK(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "postcss.config.js")
if err := os.WriteFile(configPath, []byte(`export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
`), 0644); err != nil {
t.Fatal(err)
}
violations := checkPostCSSConfig(dir)
if len(violations) > 0 {
t.Fatalf("unexpected violations for v4 postcss config: %v", violations)
}
}
func TestCheckPostCSSConfig_AutoprefixerWithV4(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, "postcss.config.js")
if err := os.WriteFile(configPath, []byte(`export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
`), 0644); err != nil {
t.Fatal(err)
}
violations := checkPostCSSConfig(dir)
foundAutoprefixer := false
for _, v := range violations {
if v.rule == "postcss-no-autoprefixer" {
foundAutoprefixer = true
}
}
if !foundAutoprefixer {
t.Fatalf("expected postcss-no-autoprefixer violation, got %v", violations)
}
}
func TestCheckCSSDirectives_V3Syntax(t *testing.T) {
dir := t.TempDir()
cssPath := filepath.Join(dir, "index.css")
if err := os.WriteFile(cssPath, []byte(`@tailwind base;
@tailwind components;
@tailwind utilities;
`), 0644); err != nil {
t.Fatal(err)
}
violations := checkCSSDirectives(dir, dir)
if len(violations) != 3 {
t.Fatalf("expected 3 violations for @tailwind directives, got %d: %v", len(violations), violations)
}
for _, v := range violations {
if v.rule != "css-v4-import" {
t.Fatalf("expected css-v4-import rule, got %s", v.rule)
}
}
}
func TestCheckCSSDirectives_V4Syntax_OK(t *testing.T) {
dir := t.TempDir()
cssPath := filepath.Join(dir, "index.css")
if err := os.WriteFile(cssPath, []byte(`@import "tailwindcss";
@config "../tailwind.config.ts";
`), 0644); err != nil {
t.Fatal(err)
}
parentDir := filepath.Dir(dir)
configPath := filepath.Join(parentDir, "tailwind.config.ts")
if err := os.WriteFile(configPath, []byte(`export default {}`), 0644); err != nil {
t.Fatal(err)
}
violations := checkCSSDirectives(dir, parentDir)
if len(violations) > 0 {
t.Fatalf("unexpected violations for v4 CSS: %v", violations)
}
}
func TestCheckCSSDirectives_MissingConfig(t *testing.T) {
dir := t.TempDir()
srcDir := filepath.Join(dir, "src")
if err := os.MkdirAll(srcDir, 0755); err != nil {
t.Fatal(err)
}
cssPath := filepath.Join(srcDir, "index.css")
if err := os.WriteFile(cssPath, []byte(`@import "tailwindcss";
`), 0644); err != nil {
t.Fatal(err)
}
configPath := filepath.Join(dir, "tailwind.config.ts")
if err := os.WriteFile(configPath, []byte(`export default {}`), 0644); err != nil {
t.Fatal(err)
}
violations := checkCSSDirectives(srcDir, dir)
foundMissing := false
for _, v := range violations {
if v.rule == "css-missing-config" {
foundMissing = true
}
}
if !foundMissing {
t.Fatalf("expected css-missing-config violation, got %v", violations)
}
}
func TestCheckPostCSSConfig_PostCSSRC_JSON(t *testing.T) {
dir := t.TempDir()
configPath := filepath.Join(dir, ".postcssrc.json")
if err := os.WriteFile(configPath, []byte(`{
"plugins": {
"tailwindcss": {},
"autoprefixer": {}
}
}
`), 0644); err != nil {
t.Fatal(err)
}
violations := checkPostCSSConfig(dir)
foundPlugin := false
for _, v := range violations {
if v.rule == "postcss-v4-plugin" {
foundPlugin = true
}
}
if !foundPlugin {
t.Fatalf("expected postcss-v4-plugin violation for .postcssrc.json, got %v", violations)
}
}
func TestCheckCSSDirectives_PlainCSS_NoViolation(t *testing.T) {
dir := t.TempDir()
srcDir := filepath.Join(dir, "src")
if err := os.MkdirAll(srcDir, 0755); err != nil {
t.Fatal(err)
}
cssPath := filepath.Join(srcDir, "components.css")
if err := os.WriteFile(cssPath, []byte(`.btn { padding: 8px 16px; }
.card { border: 1px solid #ccc; }
`), 0644); err != nil {
t.Fatal(err)
}
configPath := filepath.Join(dir, "tailwind.config.ts")
if err := os.WriteFile(configPath, []byte(`export default {}`), 0644); err != nil {
t.Fatal(err)
}
violations := checkCSSDirectives(srcDir, dir)
if len(violations) > 0 {
t.Fatalf("plain CSS file should not trigger violations, got %v", violations)
}
}
func TestCheckCSSDirectives_NodeModulesSkipped(t *testing.T) {
dir := t.TempDir()
nmDir := filepath.Join(dir, "node_modules", "some-lib")
if err := os.MkdirAll(nmDir, 0755); err != nil {
t.Fatal(err)
}
cssPath := filepath.Join(nmDir, "style.css")
if err := os.WriteFile(cssPath, []byte(`@tailwind base;
@tailwind components;
@tailwind utilities;
`), 0644); err != nil {
t.Fatal(err)
}
violations := checkCSSDirectives(dir, dir)
if len(violations) > 0 {
t.Fatalf("node_modules CSS should be skipped, got %v", violations)
}
}

358
targets.go Normal file
View File

@ -0,0 +1,358 @@
package main
import (
"os"
"path/filepath"
"sort"
)
type pluginScanTarget struct {
root string
display string
frontendDir string
frontendLabel string
}
type backendScanTarget struct {
root string
display string
protoRoot string
allowedPackagePrefixes []string
}
type frontendScanTarget struct {
dir string
label string
pluginRules bool
managedConfigs bool
}
type rbacTargetSummary struct {
label string
protoMethods []string
roleByMethod map[string]string
missingCount int
staleCount int
requiresRPCs bool
}
func (t backendScanTarget) displayOrRoot() string {
if t.display != "" {
return t.display
}
return t.root
}
func resolveScanRoots(target string) (backendDir string, repoRoot string, err error) {
absTarget, err := filepath.Abs(target)
if err != nil {
return "", "", err
}
if dirExists(filepath.Join(absTarget, "backend", "cmd", "check-safety")) {
return filepath.Join(absTarget, "backend"), absTarget, nil
}
if dirExists(filepath.Join(absTarget, "cmd", "check-safety")) {
parent := filepath.Clean(filepath.Join(absTarget, ".."))
if fileExists(filepath.Join(parent, "buf.yaml")) {
return absTarget, parent, nil
}
return absTarget, absTarget, nil
}
return absTarget, absTarget, nil
}
func defaultScanTargetDir(targetDir string, pluginRoots []string, cwd string) string {
if len(pluginRoots) > 0 || targetDir != "." {
return targetDir
}
if fileExists(filepath.Join(cwd, "plugin.mod")) {
return cwd
}
return filepath.Join(blockNinjaRepoRoot(), "backend")
}
func resolvePluginTargets(repoRoot string, roots []string) (targets []pluginScanTarget, unresolved []string) {
seenRoots := make(map[string]bool)
seenFrontends := make(map[string]bool)
for _, root := range roots {
resolvedRoot, err := expandPath(root)
if err != nil {
unresolved = append(unresolved, shortPluginLabel(root))
continue
}
absRoot, err := filepath.Abs(resolvedRoot)
if err != nil {
unresolved = append(unresolved, shortPluginLabel(root))
continue
}
info, err := os.Stat(absRoot)
if err != nil || !info.IsDir() {
unresolved = append(unresolved, shortPluginLabel(root))
continue
}
if seenRoots[absRoot] {
continue
}
seenRoots[absRoot] = true
target := pluginScanTarget{
root: absRoot,
display: shortPluginLabel(absRoot),
}
if frontendDir, ok := findPluginFrontendDir(absRoot); ok {
if !seenFrontends[frontendDir] {
target.frontendDir = frontendDir
target.frontendLabel = target.display
seenFrontends[frontendDir] = true
}
}
targets = append(targets, target)
}
sort.Slice(targets, func(i, j int) bool {
return targets[i].display < targets[j].display
})
return targets, unresolved
}
func collectBackendScanTargets(repoRoot, backendDir string, includeCoreTargets bool) []backendScanTarget {
var targets []backendScanTarget
seen := make(map[string]bool)
if !includeCoreTargets {
return nil
}
addTarget := func(root string, allowedPrefixes []string) {
if root == "" || seen[root] || !dirExists(root) {
return
}
seen[root] = true
targets = append(targets, backendScanTarget{
root: root,
display: normalizeDisplayLabel(displayLintPath(repoRoot, root)),
protoRoot: resolveProtoScanRoot(root),
allowedPackagePrefixes: allowedPrefixes,
})
}
addTarget(backendDir, inferAllowedPackagePrefixes(repoRoot, backendDir))
sort.Slice(targets, func(i, j int) bool {
return targets[i].displayOrRoot() < targets[j].displayOrRoot()
})
return targets
}
func filterPluginTargets(targets []pluginScanTarget, backendDir string) []pluginScanTarget {
filtered := make([]pluginScanTarget, 0, len(targets))
for _, target := range targets {
if samePath(target.root, backendDir) {
continue
}
filtered = append(filtered, target)
}
return filtered
}
func collectFrontendScanTargets(repoRoot, backendDir string, pluginTargets []pluginScanTarget, includeCoreTargets bool) []frontendScanTarget {
var targets []frontendScanTarget
seen := make(map[string]bool)
addTarget := func(dir, label string, pluginRules bool, managedConfigs bool) {
if dir == "" || seen[dir] || !dirExists(dir) {
return
}
seen[dir] = true
targets = append(targets, frontendScanTarget{
dir: dir,
label: normalizeDisplayLabel(label),
pluginRules: pluginRules,
managedConfigs: managedConfigs,
})
}
if includeCoreTargets && samePath(backendDir, filepath.Join(repoRoot, "backend")) {
addTarget(filepath.Join(repoRoot, "web", "src"), "web/src", false, false)
addTarget(filepath.Join(orchestratorRepoRoot(), "frontend", "src"), "orchestrator/frontend/src", false, true)
for _, pluginWebDir := range discoverInternalPluginWebDirs(backendDir) {
rel, _ := filepath.Rel(repoRoot, pluginWebDir)
addTarget(pluginWebDir, rel, true, true)
}
} else if includeCoreTargets {
if frontendDir, ok := findPluginFrontendDir(backendDir); ok {
addTarget(frontendDir, displayLintPath(repoRoot, frontendDir), true, true)
}
}
for _, target := range pluginTargets {
if target.frontendDir == "" {
continue
}
addTarget(target.frontendDir, target.frontendLabel, true, true)
}
sort.Slice(targets, func(i, j int) bool {
return targets[i].label < targets[j].label
})
return targets
}
func shouldIncludeCoreTargets(targetDir, backendDir string, pluginRoots []string, pluginPageDirs []string) bool {
if len(pluginRoots) == 0 && len(pluginPageDirs) == 0 {
return true
}
blockNinjaRepo := blockNinjaRepoRoot()
if samePath(targetDir, blockNinjaRepo) || samePath(targetDir, filepath.Join(blockNinjaRepo, "backend")) {
return false
}
if samePath(backendDir, filepath.Join(blockNinjaRepo, "backend")) &&
(samePath(targetDir, filepath.Join(blockNinjaRepo, "backend")) || samePath(targetDir, blockNinjaRepo)) {
return false
}
return true
}
func appendManualFrontendTargets(repoRoot string, targets []frontendScanTarget, dirs []string) []frontendScanTarget {
if len(dirs) == 0 {
return targets
}
seen := make(map[string]bool, len(targets))
for _, target := range targets {
if target.dir == "" {
continue
}
absDir, err := filepath.Abs(target.dir)
if err != nil {
continue
}
seen[absDir] = true
}
for _, dir := range dirs {
absDir, err := filepath.Abs(dir)
if err != nil || seen[absDir] {
continue
}
seen[absDir] = true
targets = append(targets, frontendScanTarget{
dir: absDir,
label: normalizeDisplayLabel(displayLintPath(repoRoot, absDir)),
pluginRules: true,
managedConfigs: true,
})
}
sort.Slice(targets, func(i, j int) bool {
return targets[i].label < targets[j].label
})
return targets
}
func collectESLintDirectiveTargets(repoRoot string, frontendTargets []frontendScanTarget) []frontendScanTarget {
var targets []frontendScanTarget
seen := make(map[string]bool)
addTarget := func(dir, label string) {
if dir == "" || !dirExists(dir) {
return
}
absDir, err := filepath.Abs(dir)
if err != nil || seen[absDir] {
return
}
seen[absDir] = true
targets = append(targets, frontendScanTarget{
dir: absDir,
label: normalizeDisplayLabel(label),
})
}
for _, target := range frontendTargets {
addTarget(target.dir, target.label)
}
if samePath(repoRoot, blockNinjaRepoRoot()) {
addTarget(filepath.Join(repoRoot, "packages", "ui", "src"), "packages/ui/src")
}
sort.Slice(targets, func(i, j int) bool {
return targets[i].label < targets[j].label
})
return targets
}
func discoverInternalPluginWebDirs(backendDir string) []string {
pluginsDir := filepath.Join(backendDir, "internal", "plugins")
entries, err := os.ReadDir(pluginsDir)
if err != nil {
return nil
}
var dirs []string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
webDir := filepath.Join(pluginsDir, entry.Name(), "web")
if dirLooksLikeFrontendSource(webDir) {
abs, err := filepath.Abs(webDir)
if err != nil {
continue
}
dirs = append(dirs, abs)
}
}
return dirs
}
func findPluginFrontendDir(root string) (string, bool) {
for _, candidate := range []string{
root,
filepath.Join(root, "src"),
filepath.Join(root, "web"),
filepath.Join(root, "web", "src"),
filepath.Join(root, "frontend"),
filepath.Join(root, "frontend", "src"),
} {
if !dirLooksLikeFrontendSource(candidate) {
continue
}
absCandidate, err := filepath.Abs(candidate)
if err != nil {
continue
}
return absCandidate, true
}
return "", false
}
func inferAllowedPackagePrefixes(repoRoot, backendDir string) []string {
switch {
case samePath(repoRoot, orchestratorRepoRoot()):
return []string{"orchestrator."}
case samePath(backendDir, filepath.Join(repoRoot, "backend")):
return []string{"blockninja."}
default:
return nil
}
}
func resolveProtoScanRoot(backendDir string) string {
parent := filepath.Clean(filepath.Join(backendDir, ".."))
if fileExists(filepath.Join(parent, "buf.yaml")) || dirExists(filepath.Join(parent, "proto")) {
return parent
}
return backendDir
}

1
testdata/golden/clean/expected.exit vendored Normal file
View File

@ -0,0 +1 @@
0

89
testdata/golden/clean/expected.stdout vendored Normal file
View File

@ -0,0 +1,89 @@
(running checks in FIXTURE_DIR)
=== Check 1: Secret env var reads outside config.Load() ===
OK: No secret env var reads outside config.Load()
=== Check 2: RPC methods registered in RBAC interceptor ===
SKIP: no proto-backed RPC procedures found in scanned target(s)
=== Check 2b: Plugin proto ownership ===
OK: Plugin proto ownership is clean
=== Check 2c: Standalone plugin SDK import boundaries ===
OK: FIXTURE_DIR/go.mod does not locally replace git.dev.alexdunmow.com/block/core
=== Check 2d: sqlc UUID overrides in plugins and cmd ===
SKIP: no plugin or cmd sqlc configs found
=== Check 2e: Warn on any usage in Go and TypeScript ===
OK: No any usage found in scanned Go or TypeScript sources
=== Check 2f: sqlc compile and buf generate ===
SKIP: no sqlc or buf codegen targets found
=== Check 3: Go code compiles and passes go fix, golangci-lint --fix, go vet, and strict lint ===
OK: Go lint pipeline clean for 1 module(s)
- .
=== Check 3b: Orchestrator backend tests ===
SKIP: orchestrator backend not found or not part of this scan
=== Check 4: Frontend auto-format, lint, and typecheck ===
SKIP: no frontend sources found
=== Check 5: Frontend uses generated ConnectRPC hooks ===
SKIP: no frontend sources found
=== Check 6: No hardcoded colors in frontend (use theme tokens) ===
OK: No hardcoded colors in .templ files
=== Check 7: No useState for tab state in routes (use URL ?tab= params) ===
SKIP: no frontend sources found
=== Check 8: Import @block-ninja/api from subpaths only ===
SKIP: no frontend sources found
=== Check 9: No npm/yarn lockfiles (pnpm only) ===
OK: Only pnpm lockfiles present
=== Check 10: Button automation attributes ===
SKIP: no frontend sources found
=== Check 10b: No eslint-disable-next-line comments ===
SKIP: no frontend sources found
=== Check 11: No placeholder code; only shipped features ===
OK: No placeholder code found
=== Check 12: No reinvented utilities (use helpers) ===
OK: No reinvented utilities detected
=== Check 13: RPC query/mutation error handling ===
SKIP: no frontend sources found
=== Check 14: No raw SQL outside sqlc/Bob ===
OK: No raw SQL outside sqlc/Bob
=== Check 15: No err.Error() leaked to HTTP clients ===
OK: No err.Error() leaked to HTTP clients
=== Check 16: Services and handlers in correct directories ===
OK: All services and handlers in correct directories
=== Check 17: No TODO markers in production code ===
OK: No TODO markers found in production code
=== Check 18: Plugin segmentation (safety-rules.yml) ===
OK: No plugins define safety-rules.yml segmentation rules
=== Check 19: Bearer token precedence over cookies in auth middleware ===
OK: Bearer token takes precedence over cookies in all auth extraction
=== Check 20: Tailwind v4 configuration (PostCSS, CSS directives, @config) ===
SKIP: no frontend sources found
=== Check 21: Plugin presets.json validation ===
OK: No presets.json files found in scanned targets
=== Check 22: No hand-rolled HTML sanitization (use bluemonday) ===
OK: No hand-rolled HTML sanitization detected

1
testdata/golden/nomod/expected.exit vendored Normal file
View File

@ -0,0 +1 @@
1

110
testdata/golden/nomod/expected.stdout vendored Normal file
View File

@ -0,0 +1,110 @@
(running checks in FIXTURE_DIR)
=== Check 1: Secret env var reads outside config.Load() ===
FAIL: internal/service/handler.go:17 — os.Getenv("JWT_SECRET")
1 violation(s). Secrets must flow through Config → DI.
=== Check 2: RPC methods registered in RBAC interceptor ===
SKIP: no proto-backed RPC procedures found in scanned target(s)
=== Check 2b: Plugin proto ownership ===
OK: Plugin proto ownership is clean
=== Check 2c: Standalone plugin SDK import boundaries ===
FAIL: 1 CMS backend go.mod violation(s):
FIXTURE_DIR/go.mod [missing-go-mod] missing go.mod
Fix: CMS backend must not replace git.dev.alexdunmow.com/block/core locally, and standalone plugins must use git.dev.alexdunmow.com/block/core imports, require git.dev.alexdunmow.com/block/core <SDK_VERSION>, and declare no replace directives.
=== Check 2d: sqlc UUID overrides in plugins and cmd ===
SKIP: no plugin or cmd sqlc configs found
=== Check 2e: Warn on any usage in Go and TypeScript ===
OK: No any usage found in scanned Go or TypeScript sources
=== Check 2f: sqlc compile and buf generate ===
SKIP: no sqlc or buf codegen targets found
=== Check 3: Go code compiles and passes go fix, golangci-lint --fix, go vet, and strict lint ===
SKIP: . (no go.mod found)
SKIP: no Go modules found
=== Check 3b: Orchestrator backend tests ===
SKIP: orchestrator backend not found or not part of this scan
=== Check 4: Frontend auto-format, lint, and typecheck ===
SKIP: no frontend sources found
=== Check 5: Frontend uses generated ConnectRPC hooks ===
SKIP: no frontend sources found
=== Check 6: No hardcoded colors in frontend (use theme tokens) ===
OK: No hardcoded colors in .templ files
=== Check 7: No useState for tab state in routes (use URL ?tab= params) ===
SKIP: no frontend sources found
=== Check 8: Import @block-ninja/api from subpaths only ===
SKIP: no frontend sources found
=== Check 9: No npm/yarn lockfiles (pnpm only) ===
OK: Only pnpm lockfiles present
=== Check 10: Button automation attributes ===
SKIP: no frontend sources found
=== Check 10b: No eslint-disable-next-line comments ===
SKIP: no frontend sources found
=== Check 11: No placeholder code; only shipped features ===
OK: No placeholder code found
=== Check 12: No reinvented utilities (use helpers) ===
OK: No reinvented utilities detected
=== Check 13: RPC query/mutation error handling ===
SKIP: no frontend sources found
=== Check 14: No raw SQL outside sqlc/Bob ===
FAIL: 1 raw SQL statement(s) found outside sqlc/Bob:
internal/service/handler.go:22 SELECT id, name FROM users WHERE active = true
Use sqlc for static queries or Bob for dynamic queries:
┌───────────────────┬─────────────────────────────────────────────────────┐
│ Need │ Use │
├───────────────────┼─────────────────────────────────────────────────────┤
│ Static queries │ sqlc (sql/queries/*.sql → internal/db/*.go) │
│ Dynamic queries │ Bob (github.com/stephenafamo/bob) │
└───────────────────┴─────────────────────────────────────────────────────┘
Raw SQL causes bugs when schema changes. sqlc catches these at compile time.
=== Check 15: No err.Error() leaked to HTTP clients ===
FAIL: 1 err.Error() leak(s) to HTTP clients:
internal/service/handler.go:27 http.Error(w, err.Error(), ...) — leaks internal error
Fix: Log the error with slog.Error() and return a user-friendly message: http.Error(w, "failed to open repository", status)
=== Check 16: Services and handlers in correct directories ===
OK: All services and handlers in correct directories
=== Check 17: No TODO markers in production code ===
FAIL: 1 TODO marker(s) found:
internal/service/handler.go:14 // TODO: add proper initialisation
Fix: Remove TODO markers and ship explicit behavior instead of placeholders.
=== Check 18: Plugin segmentation (safety-rules.yml) ===
OK: No plugins define safety-rules.yml segmentation rules
=== Check 19: Bearer token precedence over cookies in auth middleware ===
OK: Bearer token takes precedence over cookies in all auth extraction
=== Check 20: Tailwind v4 configuration (PostCSS, CSS directives, @config) ===
SKIP: no frontend sources found
=== Check 21: Plugin presets.json validation ===
OK: No presets.json files found in scanned targets
=== Check 22: No hand-rolled HTML sanitization (use bluemonday) ===
OK: No hand-rolled HTML sanitization detected

127
todo.go Normal file
View File

@ -0,0 +1,127 @@
package main
import (
"bufio"
"os"
"path/filepath"
"regexp"
"sort"
"git.dev.alexdunmow.com/block/check-safety/internal/helpers"
"strings"
)
type todoViolation struct {
file string
line int
snippet string
}
type todoScanRoot struct {
root string
displayPrefix string
}
var todoFileExts = map[string]bool{
".css": true,
".go": true,
".html": true,
".js": true,
".jsx": true,
".scss": true,
".sql": true,
".templ": true,
".ts": true,
".tsx": true,
}
var todoDirSkips = map[string]bool{
".git": true,
"dist": true,
"node_modules": true,
"vendor": true,
}
var reTODO = regexp.MustCompile(`(?i)\bTODO(?:\([^)]*\))?\b`)
func checkTODOs(roots []todoScanRoot) []todoViolation {
var violations []todoViolation
for _, scanRoot := range roots {
if scanRoot.root == "" {
continue
}
if err := filepath.Walk(scanRoot.root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
if todoDirSkips[info.Name()] {
return filepath.SkipDir
}
return nil
}
slashPath := filepath.ToSlash(path)
ext := filepath.Ext(path)
if !todoFileExts[ext] {
return nil
}
if strings.Contains(slashPath, "cmd/check-safety/") {
return nil
}
if strings.HasSuffix(path, "_test.go") ||
strings.HasSuffix(path, ".test.ts") ||
strings.HasSuffix(path, ".test.tsx") ||
strings.HasSuffix(path, ".spec.ts") ||
strings.HasSuffix(path, ".spec.tsx") {
return nil
}
f, openErr := os.Open(path)
if openErr != nil {
return nil
}
defer helpers.LogDeferredError(nil, "close TODO scan file", f.Close, "path", path)
relPath, _ := filepath.Rel(scanRoot.root, path)
if relPath == "" {
relPath = path
}
relPath = filepath.ToSlash(relPath)
if scanRoot.displayPrefix != "" {
relPath = scanRoot.displayPrefix + "/" + relPath
}
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
if !reTODO.MatchString(line) {
continue
}
violations = append(violations, todoViolation{
file: relPath,
line: lineNum,
snippet: strings.TrimSpace(line),
})
}
return nil
}); err != nil {
_ = err // Walk errors are non-fatal for TODO checks
}
}
sort.Slice(violations, func(i, j int) bool {
if violations[i].file == violations[j].file {
return violations[i].line < violations[j].line
}
return violations[i].file < violations[j].file
})
return violations
}

280
todo_test.go Normal file
View File

@ -0,0 +1,280 @@
package main
import (
"path/filepath"
"testing"
)
// todoRoot is a convenience helper for these tests.
func todoRoot(root string) []todoScanRoot {
return []todoScanRoot{{root: root}}
}
func TestCheckTODOs_GoFileFlagsTODO(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "service.go", `package service
// TODO: implement retry logic
func Retry() {}
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) == 0 {
t.Fatal("expected TODO violation in .go file")
}
if vs[0].file != "service.go" {
t.Fatalf("expected file 'service.go', got %q", vs[0].file)
}
if vs[0].line != 3 {
t.Fatalf("expected line 3, got %d", vs[0].line)
}
}
func TestCheckTODOs_TSFileFlagsTODO(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "utils.ts", `export function foo() {
// TODO: add error handling
return null
}
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) == 0 {
t.Fatal("expected TODO violation in .ts file")
}
}
func TestCheckTODOs_TSXFileFlagsTODO(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "component.tsx", `export function Comp() {
// TODO(alex): fix this
return <div />
}
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) == 0 {
t.Fatal("expected TODO violation in .tsx file")
}
}
func TestCheckTODOs_HTMLFileFlagsTODO(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "page.html", `<!-- TODO: update hero copy -->
<h1>Hello</h1>
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) == 0 {
t.Fatal("expected TODO violation in .html file")
}
}
func TestCheckTODOs_TemplFileFlagsTODO(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "block.templ", `package block
templ Hero() {
// TODO: make dynamic
<h1>Static</h1>
}
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) == 0 {
t.Fatal("expected TODO violation in .templ file")
}
}
func TestCheckTODOs_SQLFileFlagsTODO(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "queries.sql", `-- TODO: add index
SELECT * FROM users;
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) == 0 {
t.Fatal("expected TODO violation in .sql file")
}
}
func TestCheckTODOs_ScssFileFlagsTODO(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "styles.scss", `// TODO: update colours
.btn { color: red; }
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) == 0 {
t.Fatal("expected TODO violation in .scss file")
}
}
func TestCheckTODOs_CaseInsensitive(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "service.go", `package service
// todo: lowercase todo should also match
func f() {}
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) == 0 {
t.Fatal("expected TODO violation for lowercase 'todo'")
}
}
func TestCheckTODOs_WithParens(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "service.go", `package service
// TODO(alex): with author attribution
func f() {}
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) == 0 {
t.Fatal("expected TODO violation for 'TODO(alex)' form")
}
}
func TestCheckTODOs_CleanFile_NoViolation(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "clean.go", `package service
// This is a regular comment about the implementation.
func DoWork() error {
return nil
}
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) != 0 {
t.Fatalf("expected no violations for clean file, got %d", len(vs))
}
}
func TestCheckTODOs_TestGoFile_Skipped(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "service_test.go", `package service
// TODO: add more test cases
func TestFoo(t *testing.T) {}
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) != 0 {
t.Fatalf("expected _test.go files to be skipped, got %d violations", len(vs))
}
}
func TestCheckTODOs_TestTSFile_Skipped(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "utils.test.ts", `// TODO: more tests
describe("foo", () => {})
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) != 0 {
t.Fatalf("expected .test.ts files to be skipped, got %d violations", len(vs))
}
}
func TestCheckTODOs_TestTSXFile_Skipped(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "comp.test.tsx", `// TODO: add render tests
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) != 0 {
t.Fatalf("expected .test.tsx files to be skipped, got %d violations", len(vs))
}
}
func TestCheckTODOs_SpecTSFile_Skipped(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "utils.spec.ts", `// TODO: cover edge cases
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) != 0 {
t.Fatalf("expected .spec.ts files to be skipped, got %d violations", len(vs))
}
}
func TestCheckTODOs_NonCodeFile_Skipped(t *testing.T) {
// .md files are not in todoFileExts — should be skipped
dir := t.TempDir()
writeFile(t, dir, "README.md", `# Project
TODO: finish the docs
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) != 0 {
t.Fatalf("expected .md files to be skipped, got %d violations", len(vs))
}
}
func TestCheckTODOs_NodeModulesSkipped(t *testing.T) {
dir := t.TempDir()
nmDir := filepath.Join(dir, "node_modules", "some-lib")
writeTestFile(t, filepath.Join(nmDir, "index.ts"), `// TODO: vendor file — should be skipped
`, 0644)
vs := checkTODOs(todoRoot(dir))
if len(vs) != 0 {
t.Fatalf("expected node_modules to be skipped, got %d violations", len(vs))
}
}
func TestCheckTODOs_VendorDirSkipped(t *testing.T) {
dir := t.TempDir()
vendorDir := filepath.Join(dir, "vendor", "somelib")
writeTestFile(t, filepath.Join(vendorDir, "lib.go"), `package lib
// TODO: vendor should be skipped
`, 0644)
vs := checkTODOs(todoRoot(dir))
if len(vs) != 0 {
t.Fatalf("expected vendor/ to be skipped, got %d violations", len(vs))
}
}
func TestCheckTODOs_EmptyRootSkipped(t *testing.T) {
// An empty root string should be skipped without panicking
vs := checkTODOs([]todoScanRoot{{root: ""}})
if len(vs) != 0 {
t.Fatalf("expected no violations for empty root, got %d", len(vs))
}
}
func TestCheckTODOs_DisplayPrefix(t *testing.T) {
dir := t.TempDir()
writeFile(t, dir, "service.go", `package service
// TODO: remove this
func f() {}
`)
vs := checkTODOs([]todoScanRoot{{root: dir, displayPrefix: "backend"}})
if len(vs) == 0 {
t.Fatal("expected TODO violation")
}
if vs[0].file != "backend/service.go" {
t.Fatalf("expected prefixed path 'backend/service.go', got %q", vs[0].file)
}
}
func TestCheckTODOs_MultipleFiles_SortedByFileAndLine(t *testing.T) {
dir := t.TempDir()
// b.go has one TODO on line 2
writeFile(t, dir, "b.go", `package p
// TODO: b
func b() {}
`)
// a.go has two TODOs — line 3 and line 5
writeFile(t, dir, "a.go", `package p
func a() {
// TODO: first
x := 1
// TODO: second
_ = x
}
`)
vs := checkTODOs(todoRoot(dir))
if len(vs) != 3 {
t.Fatalf("expected 3 violations, got %d: %#v", len(vs), vs)
}
// Sorted: a.go first (alphabetically before b.go)
if vs[0].file != "a.go" {
t.Fatalf("first violation should be in a.go, got %q", vs[0].file)
}
if vs[0].line > vs[1].line {
t.Fatalf("violations in same file should be sorted by line")
}
if vs[2].file != "b.go" {
t.Fatalf("last violation should be in b.go, got %q", vs[2].file)
}
}