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:
commit
cd88c808b0
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/check-safety
|
||||
*.test
|
||||
*.out
|
||||
.idea/
|
||||
.vscode/
|
||||
36
CLAUDE.md
Normal file
36
CLAUDE.md
Normal 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
28
Makefile
Normal 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
56
README.md
Normal 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
221
any_usage.go
Normal 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
70
any_usage_test.go
Normal 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
196
authprecedence.go
Normal 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
211
authprecedence_test.go
Normal 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
49
check_anyusage.go
Normal 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
39
check_authprecedence.go
Normal 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
39
check_bareimports.go
Normal 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
40
check_buttonautomation.go
Normal 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
48
check_codegen.go
Normal 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
99
check_colors.go
Normal 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
44
check_envreads.go
Normal 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
41
check_errleak.go
Normal 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
40
check_eslintdirective.go
Normal 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
66
check_frontendapi.go
Normal 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
54
check_frontendlint.go
Normal 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
56
check_golint.go
Normal 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
50
check_htmlsanitize.go
Normal 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
38
check_lockfiles.go
Normal 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
53
check_orchtests.go
Normal 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
65
check_placeholder.go
Normal 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
28
check_plugindiscovery.go
Normal 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
69
check_presets.go
Normal 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
51
check_protoownership.go
Normal 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
41
check_rawsql.go
Normal 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
214
check_rbac.go
Normal 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
49
check_reinvented.go
Normal 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
56
check_rpcerrors.go
Normal 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
97
check_sdkboundaries.go
Normal 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
52
check_segmentation.go
Normal 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
53
check_sqlcuuid.go
Normal 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
40
check_structure.go
Normal 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
39
check_tabstate.go
Normal 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
51
check_tailwind.go
Normal 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
48
check_todos.go
Normal 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
130
codegen.go
Normal 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
75
codegen_test.go
Normal 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
475
colors.go
Normal 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
147
colors_format.go
Normal 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
175
colors_test.go
Normal 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
285
comingsoon.go
Normal 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
334
comingsoon_test.go
Normal 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
128
errleak.go
Normal 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
181
errleak_test.go
Normal 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
379
frontend.go
Normal 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
238
frontend_extra.go
Normal 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
610
frontend_extra_test.go
Normal 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
109
frontend_test.go
Normal 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
245
frontend_ui.go
Normal 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
370
frontend_ui_test.go
Normal 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
8
go.mod
Normal 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
6
go.sum
Normal 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
72
go_tests.go
Normal 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
289
golden_test.go
Normal 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
113
htmlsanitize.go
Normal 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
120
htmlsanitize_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
17
internal/helpers/deferlog.go
Normal file
17
internal/helpers/deferlog.go
Normal 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
175
internal/theme/colors.go
Normal 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
647
internal/theme/css.go
Normal 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
325
internal/theme/defaults.go
Normal 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"
|
||||
}
|
||||
134
internal/theme/google_fonts.go
Normal file
134
internal/theme/google_fonts.go
Normal 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
265
lint.go
Normal 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
522
lint_pipeline.go
Normal 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
717
lint_test.go
Normal 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
194
main.go
Normal 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
191
main_test.go
Normal 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
155
pathutil.go
Normal 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
126
plugin_imports.go
Normal 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
76
plugin_imports_test.go
Normal 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
161
plugin_sdk_versions.go
Normal 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
104
plugin_sdk_versions_test.go
Normal 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
115
presets.go
Normal 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
283
presets_test.go
Normal 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
747
proto_rbac.go
Normal 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
133
rawsql.go
Normal 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
208
rawsql_test.go
Normal 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
37
registry.go
Normal 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
43
registry_test.go
Normal 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
383
reinvented.go
Normal 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
455
reinvented_test.go
Normal 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
168
rpcerrors.go
Normal 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
329
rpcerrors_test.go
Normal 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
296
segmentation.go
Normal 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
486
segmentation_test.go
Normal 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
177
sqlc_uuid.go
Normal 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
74
sqlc_uuid_test.go
Normal 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
187
tailwind.go
Normal 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
222
tailwind_test.go
Normal 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
358
targets.go
Normal 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
1
testdata/golden/clean/expected.exit
vendored
Normal file
@ -0,0 +1 @@
|
||||
0
|
||||
89
testdata/golden/clean/expected.stdout
vendored
Normal file
89
testdata/golden/clean/expected.stdout
vendored
Normal 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
1
testdata/golden/nomod/expected.exit
vendored
Normal file
@ -0,0 +1 @@
|
||||
1
|
||||
110
testdata/golden/nomod/expected.stdout
vendored
Normal file
110
testdata/golden/nomod/expected.stdout
vendored
Normal 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
127
todo.go
Normal 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
280
todo_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user