feat: concise output + diff-scoped any check

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-04 01:16:56 +08:00
parent 3b5a6d26d6
commit c4d01de82c
49 changed files with 760 additions and 1122 deletions

View File

@ -32,10 +32,38 @@ If you run it against the top of the consolidated BlockNinja repo, it prints a h
| `--orchestrator` | Scan the orchestrator backend only (resolved from the hardcoded consolidated layout), regardless of the positional target. | | `--orchestrator` | Scan the orchestrator backend only (resolved from the hardcoded consolidated layout), regardless of the positional target. |
| `--plugin-dir <path> [more…]` | Register one or more plugin roots (dirs with `plugin.mod`). Alias: `--plugin-dirs`. Consumes args until the next `--flag`. | | `--plugin-dir <path> [more…]` | Register one or more plugin roots (dirs with `plugin.mod`). Alias: `--plugin-dirs`. Consumes args until the next `--flag`. |
| `--plugin-pages <dir> [more…]` | Register frontend source dirs directly as plugin page targets (for the frontend checks). Consumes args until the next `--flag`. | | `--plugin-pages <dir> [more…]` | Register frontend source dirs directly as plugin page targets (for the frontend checks). Consumes args until the next `--flag`. |
| `--verbose` / `-v` | Print every check (including `OK`/`SKIP`), not just failures and warnings. |
| `--all-any` | The `any`-usage check (2e) defaults to only the **unstaged working-tree diff** (newly introduced `any`); `--all-any` scans every source file instead. |
The CLI contract (`check-safety <target-dir> [--flags]`) is stable — the CMS `Makefile` The CLI contract (`check-safety <target-dir> [--flags]`) is stable — the CMS `Makefile`
`safety-check` / `install-safety-checker` targets shell into this directory and depend on it. `safety-check` / `install-safety-checker` targets shell into this directory and depend on it.
### Output
Output is concise by design (it is usually fed back into an agent's context). Passing and
skipped checks print nothing; only `FAIL`/`WARN`/`ERR` checks print a `<STATUS> <id> <summary>`
line with their findings indented beneath, followed by one tally line. A clean run is two lines:
```
check-safety /home/alex/src/blockninja/cms
32 checks: 20 ok 12 skip -> OK
```
A run with problems:
```
check-safety /home/alex/src/blockninja/cms
FAIL 15 1 err.Error() leak(s) to HTTP clients — log via slog.Error() and return a user-friendly message
internal/service/handler.go:27 http.Error(w, err.Error(), ...) — leaks internal error
WARN 2e 1 any usage in changed lines (showing 1, --all-any to scan all)
web/src/api.ts:12 [go] data: any
32 checks: 27 ok 3 skip 1 warn 1 fail -> FAIL
```
Pass `--verbose` to see every check's status. Exit code is `1` on any `FAIL`, `2` on a hard
`ERR`, `0` otherwise (warnings do not fail the run).
## Install globally ## Install globally
```bash ```bash

View File

@ -17,6 +17,15 @@ type anyUsageWarning struct {
snippet string snippet string
} }
// lineKeeper decides whether an `any` at absPath:line should be reported. A nil
// keeper keeps everything (the --all-any full scan); the diff-scoped default
// keeps only lines present in the unstaged working-tree diff.
type lineKeeper func(absPath string, line int) bool
func (k lineKeeper) keep(absPath string, line int) bool {
return k == nil || k(absPath, line)
}
var ( var (
reTypeScriptAny = regexp.MustCompile(`\bany\b`) reTypeScriptAny = regexp.MustCompile(`\bany\b`)
reTypeScriptAsAny = regexp.MustCompile(`\bas\s+any\b`) reTypeScriptAsAny = regexp.MustCompile(`\bas\s+any\b`)
@ -26,7 +35,7 @@ var (
reTypeScriptEqAny = regexp.MustCompile(`=\s*any(?:\b|[\s;])`) reTypeScriptEqAny = regexp.MustCompile(`=\s*any(?:\b|[\s;])`)
) )
func checkGoAnyUsage(root string) []anyUsageWarning { func checkGoAnyUsage(root string, keep lineKeeper) []anyUsageWarning {
var warnings []anyUsageWarning var warnings []anyUsageWarning
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
@ -61,6 +70,9 @@ func checkGoAnyUsage(root string) []anyUsageWarning {
continue continue
} }
seen[position.Line] = true seen[position.Line] = true
if !keep.keep(path, position.Line) {
continue
}
snippet := "" snippet := ""
if position.Line-1 >= 0 && position.Line-1 < len(lines) { if position.Line-1 >= 0 && position.Line-1 < len(lines) {
snippet = strings.TrimSpace(lines[position.Line-1]) snippet = strings.TrimSpace(lines[position.Line-1])
@ -166,7 +178,7 @@ func findAnyTypePositions(expr ast.Expr) []token.Pos {
return positions return positions
} }
func checkTypeScriptAnyUsage(root string) []anyUsageWarning { func checkTypeScriptAnyUsage(root string, keep lineKeeper) []anyUsageWarning {
var warnings []anyUsageWarning var warnings []anyUsageWarning
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {

View File

@ -19,7 +19,7 @@ func Map() map[string]any {
} }
`, 0644) `, 0644)
warnings := checkGoAnyUsage(root) warnings := checkGoAnyUsage(root, nil)
if len(warnings) < 3 { if len(warnings) < 3 {
t.Fatalf("checkGoAnyUsage() returned %d warnings, want at least 3: %#v", len(warnings), warnings) t.Fatalf("checkGoAnyUsage() returned %d warnings, want at least 3: %#v", len(warnings), warnings)
} }
@ -38,7 +38,7 @@ type Payload struct {
} }
`, 0644) `, 0644)
warnings := checkGoAnyUsage(root) warnings := checkGoAnyUsage(root, nil)
if len(warnings) != 0 { if len(warnings) != 0 {
t.Fatalf("checkGoAnyUsage() returned %d warnings, want 0: %#v", len(warnings), warnings) t.Fatalf("checkGoAnyUsage() returned %d warnings, want 0: %#v", len(warnings), warnings)
} }
@ -50,7 +50,7 @@ func TestCheckTypeScriptAnyUsageFlagsAnyPatterns(t *testing.T) {
export const fn = (value: any[]): any => value as any export const fn = (value: any[]): any => value as any
`, 0644) `, 0644)
warnings := checkTypeScriptAnyUsage(root) warnings := checkTypeScriptAnyUsage(root, nil)
if len(warnings) < 2 { if len(warnings) < 2 {
t.Fatalf("checkTypeScriptAnyUsage() returned %d warnings, want at least 2: %#v", len(warnings), warnings) t.Fatalf("checkTypeScriptAnyUsage() returned %d warnings, want at least 2: %#v", len(warnings), warnings)
} }
@ -63,7 +63,7 @@ const label = "any";
/* any */ /* any */
`, 0644) `, 0644)
warnings := checkTypeScriptAnyUsage(root) warnings := checkTypeScriptAnyUsage(root, nil)
if len(warnings) != 0 { if len(warnings) != 0 {
t.Fatalf("checkTypeScriptAnyUsage() returned %d warnings, want 0: %#v", len(warnings), warnings) t.Fatalf("checkTypeScriptAnyUsage() returned %d warnings, want 0: %#v", len(warnings), warnings)
} }

135
changedlines.go Normal file
View File

@ -0,0 +1,135 @@
package main
import (
"bufio"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// diffIndex answers "is this file:line part of the unstaged working-tree diff?"
// It resolves each file to its enclosing git repo and, once per repo, parses
// `git diff --unified=0` (added/modified lines) plus untracked files (which count
// as fully changed). Everything is cached; files with no enclosing repo, or no
// matching change, are treated as unchanged.
type diffIndex struct {
repoOf map[string]string // dir -> git repo root ("" = none)
repos map[string]*repoDiff // repo root -> parsed diff
}
type repoDiff struct {
changed map[string]map[int]bool // repo-relative slash path -> changed line set
untracked map[string]bool // repo-relative slash path (fully changed)
}
func newDiffIndex() *diffIndex {
return &diffIndex{repoOf: map[string]string{}, repos: map[string]*repoDiff{}}
}
// isChanged reports whether absPath:line lies in the unstaged working-tree diff.
func (d *diffIndex) isChanged(absPath string, line int) bool {
repo := d.repoRoot(filepath.Dir(absPath))
if repo == "" {
return false
}
rd := d.repoDiff(repo)
rel, err := filepath.Rel(repo, absPath)
if err != nil {
return false
}
rel = filepath.ToSlash(rel)
if rd.untracked[rel] {
return true
}
if lines, ok := rd.changed[rel]; ok {
return lines[line]
}
return false
}
// repoRoot walks up from dir to find the enclosing git repo root, cached.
func (d *diffIndex) repoRoot(dir string) string {
if root, ok := d.repoOf[dir]; ok {
return root
}
out, err := exec.Command("git", "-C", dir, "rev-parse", "--show-toplevel").Output()
root := ""
if err == nil {
root = strings.TrimSpace(string(out))
}
d.repoOf[dir] = root
return root
}
// repoDiff parses (and caches) the unstaged diff + untracked files for a repo.
func (d *diffIndex) repoDiff(repo string) *repoDiff {
if rd, ok := d.repos[repo]; ok {
return rd
}
rd := &repoDiff{changed: map[string]map[int]bool{}, untracked: map[string]bool{}}
if out, err := exec.Command("git", "-C", repo, "diff", "--unified=0", "--no-color").Output(); err == nil {
parseUnifiedDiff(string(out), rd)
}
if out, err := exec.Command("git", "-C", repo, "ls-files", "--others", "--exclude-standard").Output(); err == nil {
for _, f := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if f != "" {
rd.untracked[filepath.ToSlash(f)] = true
}
}
}
d.repos[repo] = rd
return rd
}
// parseUnifiedDiff records added/modified line numbers per file from a
// `git diff --unified=0` payload. Only the new-file side (`+c,d`) matters.
func parseUnifiedDiff(diff string, rd *repoDiff) {
var cur string
sc := bufio.NewScanner(strings.NewReader(diff))
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "+++ b/"):
cur = strings.TrimPrefix(line, "+++ b/")
if _, ok := rd.changed[cur]; !ok {
rd.changed[cur] = map[int]bool{}
}
case strings.HasPrefix(line, "@@"):
start, count := parseHunkNewRange(line)
if start <= 0 || cur == "" {
continue
}
if rd.changed[cur] == nil {
rd.changed[cur] = map[int]bool{}
}
for i := 0; i < count; i++ {
rd.changed[cur][start+i] = true
}
}
}
}
// parseHunkNewRange extracts (start, count) from the `+c,d` part of a hunk
// header like `@@ -a,b +c,d @@`. A missing `,d` means count 1.
func parseHunkNewRange(header string) (int, int) {
plus := strings.IndexByte(header, '+')
if plus < 0 {
return 0, 0
}
rest := header[plus+1:]
if sp := strings.IndexByte(rest, ' '); sp >= 0 {
rest = rest[:sp]
}
start, count := 0, 1
if comma := strings.IndexByte(rest, ','); comma >= 0 {
start, _ = strconv.Atoi(rest[:comma])
count, _ = strconv.Atoi(rest[comma+1:])
} else {
start, _ = strconv.Atoi(rest)
}
return start, count
}

102
changedlines_test.go Normal file
View File

@ -0,0 +1,102 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestParseHunkNewRange(t *testing.T) {
cases := []struct {
header string
wantStart int
wantCount int
}{
{"@@ -1,2 +3,4 @@", 3, 4},
{"@@ -0,0 +1 @@", 1, 1}, // no ,count on the + side → count 1
{"@@ -5,0 +6,3 @@ func Foo() {", 6, 3}, // trailing context after @@
{"@@ -1 +1 @@", 1, 1},
{"not a hunk", 0, 0},
}
for _, c := range cases {
start, count := parseHunkNewRange(c.header)
if start != c.wantStart || count != c.wantCount {
t.Errorf("parseHunkNewRange(%q) = (%d,%d), want (%d,%d)", c.header, start, count, c.wantStart, c.wantCount)
}
}
}
func TestParseUnifiedDiffRecordsAddedLines(t *testing.T) {
diff := "diff --git a/foo.go b/foo.go\n" +
"--- a/foo.go\n" +
"+++ b/foo.go\n" +
"@@ -10,0 +11,2 @@\n" +
"+added one\n" +
"+added two\n"
rd := &repoDiff{changed: map[string]map[int]bool{}, untracked: map[string]bool{}}
parseUnifiedDiff(diff, rd)
lines := rd.changed["foo.go"]
if !lines[11] || !lines[12] {
t.Fatalf("expected lines 11,12 changed, got %#v", lines)
}
if lines[10] || lines[13] {
t.Fatalf("unexpected extra changed lines: %#v", lines)
}
}
func TestDiffIndexIsChangedTracksWorkingTreeEdits(t *testing.T) {
repo := t.TempDir()
runGit := func(args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
runGit("init")
runGit("config", "user.email", "test@example.com")
runGit("config", "user.name", "test")
committed := filepath.Join(repo, "committed.go")
if err := os.WriteFile(committed, []byte("package p\n\nvar a int\nvar b int\n"), 0o644); err != nil {
t.Fatal(err)
}
runGit("add", "committed.go")
runGit("commit", "-m", "init")
// Unstaged edit: append a line to the committed file.
if err := os.WriteFile(committed, []byte("package p\n\nvar a int\nvar b int\nvar c int\n"), 0o644); err != nil {
t.Fatal(err)
}
// Untracked new file.
untracked := filepath.Join(repo, "new.go")
if err := os.WriteFile(untracked, []byte("package p\n\nvar d int\n"), 0o644); err != nil {
t.Fatal(err)
}
idx := newDiffIndex()
if idx.isChanged(committed, 3) {
t.Errorf("line 3 (unchanged, committed) should not be reported as changed")
}
if !idx.isChanged(committed, 5) {
t.Errorf("line 5 (the unstaged append) should be reported as changed")
}
if !idx.isChanged(untracked, 1) || !idx.isChanged(untracked, 3) {
t.Errorf("all lines of an untracked file should be reported as changed")
}
}
func TestDiffIndexIsChangedOutsideRepoIsFalse(t *testing.T) {
dir := t.TempDir() // not a git repo
f := filepath.Join(dir, "x.go")
if err := os.WriteFile(f, []byte("package p\n"), 0o644); err != nil {
t.Fatal(err)
}
idx := newDiffIndex()
if idx.isChanged(f, 1) {
t.Errorf("files outside any git repo must not be reported as changed")
}
}

View File

@ -1,49 +1,64 @@
package main package main
import "fmt"
func init() { func init() {
register(Check{ register(Check{
Seq: 24, Seq: 24,
ID: "2e", ID: "2e",
Title: "Warn on any usage in Go and TypeScript", Title: "Warn on any usage in Go and TypeScript",
Run: func(ctx *ScanContext, rep *Reporter) { Run: func(ctx *ScanContext, rep *Reporter) {
// Check 2e: Warn on any usage in Go and TypeScript // By default only warn on `any` introduced in the unstaged working-tree
fmt.Println("=== Check 2e: Warn on any usage in Go and TypeScript ===") // diff; --all-any scans every source file.
var keep lineKeeper
if !ctx.allAny {
idx := newDiffIndex()
keep = idx.isChanged
}
var anyWarnings []anyUsageWarning var anyWarnings []anyUsageWarning
for _, target := range ctx.backendTargets { for _, target := range ctx.backendTargets {
for _, w := range checkGoAnyUsage(target.root) { for _, w := range checkGoAnyUsage(target.root, keep) {
w.file = prefixDisplayPath(target.displayOrRoot(), w.file) w.file = prefixDisplayPath(target.displayOrRoot(), w.file)
anyWarnings = append(anyWarnings, w) anyWarnings = append(anyWarnings, w)
} }
} }
for _, target := range ctx.pluginTargets { for _, target := range ctx.pluginTargets {
for _, w := range checkGoAnyUsage(target.root) { for _, w := range checkGoAnyUsage(target.root, keep) {
w.file = prefixDisplayPath(target.display, w.file) w.file = prefixDisplayPath(target.display, w.file)
anyWarnings = append(anyWarnings, w) anyWarnings = append(anyWarnings, w)
} }
} }
for _, target := range collectESLintDirectiveTargets(ctx.repoRoot, ctx.frontendTargets) { for _, target := range collectESLintDirectiveTargets(ctx.repoRoot, ctx.frontendTargets) {
for _, w := range checkTypeScriptAnyUsage(target.dir) { for _, w := range checkTypeScriptAnyUsage(target.dir, keep) {
w.file = prefixDisplayPath(target.label, w.file) w.file = prefixDisplayPath(target.label, w.file)
anyWarnings = append(anyWarnings, w) anyWarnings = append(anyWarnings, w)
} }
} }
if len(anyWarnings) > 0 {
fmt.Printf(" WARN: %d any usage warning(s):\n", len(anyWarnings)) if len(anyWarnings) == 0 {
limit := min(len(anyWarnings), maxAnyWarningsPrinted) if ctx.allAny {
for _, w := range anyWarnings[:limit] { rep.OK("no any usage found")
fmt.Printf(" %s:%d [%s] %s\n", w.file, w.line, w.lang, w.snippet) } else {
rep.OK("no any usage in changed lines")
} }
if len(anyWarnings) > limit { return
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() scope := "any usage"
if !ctx.allAny {
scope = "any usage in changed lines"
}
limit := min(len(anyWarnings), maxAnyWarningsPrinted)
extra := ""
if !ctx.allAny {
extra = ", --all-any to scan all"
}
rep.Warn("%d %s (showing %d%s)", len(anyWarnings), scope, limit, extra)
for _, w := range anyWarnings[:limit] {
rep.Findingf("%s:%d [%s] %s", w.file, w.line, w.lang, w.snippet)
}
if len(anyWarnings) > limit {
rep.Findingf("... %d more omitted", len(anyWarnings)-limit)
}
}, },
}) })
} }

View File

@ -1,15 +1,11 @@
package main package main
import "fmt"
func init() { func init() {
register(Check{ register(Check{
Seq: 190, Seq: 190,
ID: "19", ID: "19",
Title: "Bearer token precedence over cookies in auth middleware", Title: "Bearer token precedence over cookies in auth middleware",
Run: func(ctx *ScanContext, rep *Reporter) { 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 var authPrecViolations []authPrecedenceViolation
for _, target := range ctx.backendTargets { for _, target := range ctx.backendTargets {
for _, v := range checkAuthPrecedence(target.root) { for _, v := range checkAuthPrecedence(target.root) {
@ -24,15 +20,12 @@ func init() {
} }
} }
if len(authPrecViolations) > 0 { if len(authPrecViolations) > 0 {
fmt.Printf(" FAIL: %d auth precedence violation(s):\n", len(authPrecViolations)) rep.Fail("%d auth precedence violation(s) — check Bearer/Authorization header before cookies", len(authPrecViolations))
for _, v := range authPrecViolations { for _, v := range authPrecViolations {
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet) rep.Findingf("%s:%d %s", 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 { } else {
fmt.Println(" OK: Bearer token takes precedence over cookies in all auth extraction") rep.OK("Bearer token takes precedence over cookies in all auth extraction")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
} }
}, },
}) })

View File

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

View File

@ -1,7 +1,5 @@
package main package main
import "fmt"
// enableBuildmodePluginCheck is the per-check enable toggle. It ships DISABLED // enableBuildmodePluginCheck is the per-check enable toggle. It ships DISABLED
// (WO-WZ-009): the wasm plugin migration is still porting the fleet off `.so`, // (WO-WZ-009): the wasm plugin migration is still porting the fleet off `.so`,
// so a `-buildmode=plugin` target is not yet a hard error. WO-WZ-017 flips this // so a `-buildmode=plugin` target is not yet a hard error. WO-WZ-017 flips this
@ -20,20 +18,15 @@ func init() {
if !enableBuildmodePluginCheck { if !enableBuildmodePluginCheck {
return // disabled until WO-WZ-017; emit no output return // disabled until WO-WZ-017; emit no output
} }
fmt.Println("=== Check 29: No -buildmode=plugin targets in ported plugin repos ===")
violations := checkBuildmodePlugin(ctx.resolvedPluginTargets) violations := checkBuildmodePlugin(ctx.resolvedPluginTargets)
if len(violations) > 0 { if len(violations) > 0 {
fmt.Printf(" FAIL: %d legacy .so build target(s) found:\n", len(violations)) rep.Fail("%d legacy .so build target(s) found — build wasm with `ninja plugin build`", len(violations))
for _, v := range violations { for _, v := range violations {
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet) rep.Findingf("%s:%d %s", v.file, v.line, v.snippet)
} }
fmt.Println("\n Fix: build wasm with `ninja plugin build` (make build-wasm); drop the -buildmode=plugin target.")
rep.Fail()
} else { } else {
fmt.Println(" OK: No -buildmode=plugin targets found") rep.OK("No -buildmode=plugin targets found")
printPerTargetOKLines(pluginTargetLabels(ctx.resolvedPluginTargets))
} }
fmt.Println()
}, },
}) })
} }

View File

@ -1,15 +1,11 @@
package main package main
import "fmt"
func init() { func init() {
register(Check{ register(Check{
Seq: 100, Seq: 100,
ID: "10", ID: "10",
Title: "Button automation attributes", Title: "Button automation attributes",
Run: func(ctx *ScanContext, rep *Reporter) { Run: func(ctx *ScanContext, rep *Reporter) {
// Check 10: Button automation attributes
fmt.Println("=== Check 10: Button automation attributes ===")
if len(ctx.frontendTargets) > 0 { if len(ctx.frontendTargets) > 0 {
var buttonViolations []buttonViolation var buttonViolations []buttonViolation
for _, target := range ctx.frontendTargets { for _, target := range ctx.frontendTargets {
@ -19,22 +15,16 @@ func init() {
} }
} }
if len(buttonViolations) > 0 { if len(buttonViolations) > 0 {
fmt.Printf(" FAIL: %d <Button> without action=:\n", len(buttonViolations)) rep.Fail("%d <Button> without action= — add action= and entity= props", len(buttonViolations))
for _, v := range buttonViolations { for _, v := range buttonViolations {
fmt.Printf(" %s:%d\n", v.file, v.line) rep.Findingf("%s:%d", 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 { } else {
fmt.Println(" OK: All buttons have automation attributes") rep.OK("All buttons have automation attributes")
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
} }
} else { } else {
fmt.Println(" SKIP: no frontend sources found") rep.Skip("no frontend sources found")
} }
fmt.Println()
}, },
}) })
} }

View File

@ -1,10 +1,6 @@
package main package main
import ( import "strings"
"fmt"
"os"
"strings"
)
func init() { func init() {
register(Check{ register(Check{
@ -12,67 +8,56 @@ func init() {
ID: "2f", ID: "2f",
Title: "sqlc compile and buf generate", Title: "sqlc compile and buf generate",
Run: func(ctx *ScanContext, rep *Reporter) { 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) codegenTargets := discoverCodegenTargets(ctx.repoRoot, ctx.backendDir, ctx.backendTargets, ctx.pluginTargets, ctx.includeCoreTargets)
if len(codegenTargets) > 0 { if len(codegenTargets) > 0 {
completedCodegenTargets, codegenFailures, codegenErr := runCodegenChecks(codegenTargets) completedCodegenTargets, codegenFailures, codegenErr := runCodegenChecks(codegenTargets)
if codegenErr != nil { if codegenErr != nil {
fmt.Printf(" ERROR: failed to run codegen checks: %v\n", codegenErr) rep.Fatal("failed to run codegen checks: %v", codegenErr)
os.Exit(2)
} }
if len(codegenFailures) > 0 { if len(codegenFailures) > 0 {
fmt.Printf(" FAIL: %d codegen target(s) failed sqlc compile or buf generate:\n", len(codegenFailures)) rep.Fail("%d codegen target(s) failed sqlc compile or buf generate", len(codegenFailures))
for _, failure := range codegenFailures { for _, failure := range codegenFailures {
fmt.Printf(" [%s] %s\n", failure.stage, failure.target) rep.Findingf("[%s] %s", failure.stage, failure.target)
if failure.output != "" { if failure.output != "" {
for line := range strings.SplitSeq(failure.output, "\n") { for line := range strings.SplitSeq(failure.output, "\n") {
fmt.Printf(" %s\n", line) rep.Findingf("%s", line)
} }
} }
} }
rep.Fail()
} else { } else {
fmt.Printf(" OK: sqlc compile / buf generate clean for %d target(s)\n", len(completedCodegenTargets)) rep.OK("sqlc compile / buf generate clean for %d target(s)", len(completedCodegenTargets))
for _, target := range completedCodegenTargets {
fmt.Printf(" - %s\n", target)
}
} }
} else { } else {
fmt.Println(" SKIP: no sqlc or buf codegen targets found") rep.Skip("no sqlc or buf codegen targets found")
} }
protoFreshness, protoFreshnessErr := checkProtoGeneratedFreshness(ctx.repoRoot) protoFreshness, protoFreshnessErr := checkProtoGeneratedFreshness(ctx.repoRoot)
if protoFreshnessErr != nil { if protoFreshnessErr != nil {
fmt.Printf(" ERROR: failed to run make proto freshness check: %v\n", protoFreshnessErr) rep.Fail("failed to run make proto freshness check: %v", protoFreshnessErr)
if protoFreshness.output != "" { if protoFreshness.output != "" {
for line := range strings.SplitSeq(protoFreshness.output, "\n") { for line := range strings.SplitSeq(protoFreshness.output, "\n") {
fmt.Printf(" %s\n", line) rep.Findingf("%s", line)
} }
} }
rep.Fail()
} else if protoFreshness.changed() { } else if protoFreshness.changed() {
fmt.Println(" FAIL: make proto changed generated outputs; run make proto and commit the generated files.") rep.Fail("make proto changed generated outputs; run make proto and commit the generated files")
fmt.Println(" before:") rep.Finding("before:")
printIndentedStatus(protoFreshness.beforeStatus) appendStatusFindings(rep, protoFreshness.beforeStatus)
fmt.Println(" after:") rep.Finding("after:")
printIndentedStatus(protoFreshness.afterStatus) appendStatusFindings(rep, protoFreshness.afterStatus)
rep.Fail()
} else if protoFreshness.checked { } else if protoFreshness.checked {
fmt.Println(" OK: make proto freshness check clean") rep.OK("make proto freshness check clean")
} }
fmt.Println()
}, },
}) })
} }
func printIndentedStatus(status string) { func appendStatusFindings(rep *Reporter, status string) {
if strings.TrimSpace(status) == "" { if strings.TrimSpace(status) == "" {
fmt.Println(" (clean)") rep.Finding("(clean)")
return return
} }
for line := range strings.SplitSeq(status, "\n") { for line := range strings.SplitSeq(status, "\n") {
fmt.Printf(" %s\n", line) rep.Findingf("%s", line)
} }
} }

View File

@ -1,6 +1,6 @@
package main package main
import "fmt" import "sort"
func init() { func init() {
register(Check{ register(Check{
@ -8,9 +8,6 @@ func init() {
ID: "6", ID: "6",
Title: "No hardcoded colors in frontend (use theme tokens)", Title: "No hardcoded colors in frontend (use theme tokens)",
Run: func(ctx *ScanContext, rep *Reporter) { 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 var colorViolations []colorViolation
for _, target := range ctx.frontendTargets { for _, target := range ctx.frontendTargets {
for _, v := range checkColors(target.dir) { for _, v := range checkColors(target.dir) {
@ -45,25 +42,18 @@ func init() {
hasColorSources := len(ctx.frontendTargets) > 0 || len(templColorViolations) > 0 || len(seenTemplRoots) > 0 hasColorSources := len(ctx.frontendTargets) > 0 || len(templColorViolations) > 0 || len(seenTemplRoots) > 0
if !hasColorSources && len(ctx.backendTargets) == 0 { if !hasColorSources && len(ctx.backendTargets) == 0 {
fmt.Println(" SKIP: no frontend or templ sources found") rep.Skip("no frontend or templ sources found")
} else { } else {
hadColorViolations := false
if len(colorViolations) > 0 { if len(colorViolations) > 0 {
fmt.Println(formatColorViolations("frontend", colorViolations)) reportColorViolations(rep, "frontend", colorViolations)
hadColorViolations = true
rep.Fail()
} else if len(ctx.frontendTargets) > 0 { } else if len(ctx.frontendTargets) > 0 {
fmt.Println(" OK: No hardcoded colors in frontend components") rep.OK("No hardcoded colors in frontend components")
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
} }
if len(templColorViolations) > 0 { if len(templColorViolations) > 0 {
fmt.Println(formatColorViolations(".templ files", templColorViolations)) reportColorViolations(rep, ".templ files", templColorViolations)
hadColorViolations = true
rep.Fail()
} else if len(seenTemplRoots) > 0 { } else if len(seenTemplRoots) > 0 {
fmt.Println(" OK: No hardcoded colors in .templ files") rep.OK("No hardcoded colors in .templ files")
} }
// Scan .ninjatpl block templates in plugin roots for hardcoded colors. // Scan .ninjatpl block templates in plugin roots for hardcoded colors.
@ -81,19 +71,37 @@ func init() {
} }
} }
if len(ninjaTplColorViolations) > 0 { if len(ninjaTplColorViolations) > 0 {
fmt.Println(formatColorViolations(".ninjatpl files", ninjaTplColorViolations)) reportColorViolations(rep, ".ninjatpl files", ninjaTplColorViolations)
hadColorViolations = true
rep.Fail()
} else if len(seenNinjaTplRoots) > 0 { } else if len(seenNinjaTplRoots) > 0 {
fmt.Println(" OK: No hardcoded colors in .ninjatpl files") rep.OK("No hardcoded colors in .ninjatpl files")
}
if hadColorViolations {
printColorFixInstructions(colorViolations, templColorViolations, ninjaTplColorViolations)
} }
} }
fmt.Println()
}, },
}) })
} }
// reportColorViolations emits a Fail plus one finding per hardcoded-color hit,
// sorted for deterministic output.
func reportColorViolations(rep *Reporter, scope string, violations []colorViolation) {
rep.Fail("%d hardcoded color(s) in %s — use semantic tokens/CSS vars", len(violations), scope)
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
})
for _, v := range sorted {
if v.line > 0 {
rep.Findingf("%s:%d [%s] %s", v.file, v.line, v.rule, v.snippet)
} else {
rep.Findingf("%s [%s] %s", v.file, v.rule, v.snippet)
}
}
}

View File

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

View File

@ -1,15 +1,11 @@
package main package main
import "fmt"
func init() { func init() {
register(Check{ register(Check{
Seq: 150, Seq: 150,
ID: "15", ID: "15",
Title: "No err.Error() leaked to HTTP clients", Title: "No err.Error() leaked to HTTP clients",
Run: func(ctx *ScanContext, rep *Reporter) { 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 var errLeaks []errLeakViolation
for _, target := range ctx.backendTargets { for _, target := range ctx.backendTargets {
for _, v := range checkErrLeak(target.root) { for _, v := range checkErrLeak(target.root) {
@ -24,18 +20,13 @@ func init() {
} }
} }
if len(errLeaks) > 0 { if len(errLeaks) > 0 {
fmt.Printf(" FAIL: %d err.Error() leak(s) to HTTP clients:\n", len(errLeaks)) rep.Fail("%d err.Error() leak(s) to HTTP clients — log via slog.Error() and return a user-friendly message", len(errLeaks))
for _, v := range errLeaks { for _, v := range errLeaks {
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet) rep.Findingf("%s:%d %s", 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 { } else {
fmt.Println(" OK: No err.Error() leaked to HTTP clients") rep.OK("No err.Error() leaked to HTTP clients")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
} }
fmt.Println()
}, },
}) })
} }

View File

@ -1,15 +1,11 @@
package main package main
import "fmt"
func init() { func init() {
register(Check{ register(Check{
Seq: 106, Seq: 106,
ID: "10b", ID: "10b",
Title: "No eslint-disable-next-line comments", Title: "No eslint-disable-next-line comments",
Run: func(ctx *ScanContext, rep *Reporter) { 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) eslintDirectiveTargets := collectESLintDirectiveTargets(ctx.repoRoot, ctx.frontendTargets)
if len(eslintDirectiveTargets) > 0 { if len(eslintDirectiveTargets) > 0 {
var directiveViolations []eslintDirectiveViolation var directiveViolations []eslintDirectiveViolation
@ -20,21 +16,16 @@ func init() {
} }
} }
if len(directiveViolations) > 0 { if len(directiveViolations) > 0 {
fmt.Printf(" FAIL: %d eslint-disable-next-line comment(s) found:\n", len(directiveViolations)) rep.Fail("%d eslint-disable-next-line comment(s) found — rewrite the code or typing so the directive is unnecessary", len(directiveViolations))
for _, v := range directiveViolations { for _, v := range directiveViolations {
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet) rep.Findingf("%s:%d %s", v.file, v.line, v.snippet)
} }
fmt.Println("\n Fix: rewrite the code or typing so the directive is unnecessary.")
rep.Fail()
} else { } else {
fmt.Println(" OK: No eslint-disable-next-line comments found") rep.OK("No eslint-disable-next-line comments found")
printPerTargetOKLines(frontendTargetLabels(eslintDirectiveTargets))
} }
} else { } else {
fmt.Println(" SKIP: no frontend sources found") rep.Skip("no frontend sources found")
} }
fmt.Println()
}, },
}) })
} }

View File

@ -1,15 +1,11 @@
package main package main
import "fmt"
func init() { func init() {
register(Check{ register(Check{
Seq: 50, Seq: 50,
ID: "5", ID: "5",
Title: "Frontend uses generated ConnectRPC hooks", Title: "Frontend uses generated ConnectRPC hooks",
Run: func(ctx *ScanContext, rep *Reporter) { 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 { if len(ctx.frontendTargets) > 0 {
var feViolations []frontendViolation var feViolations []frontendViolation
var feWarnings []frontendViolation var feWarnings []frontendViolation
@ -34,33 +30,25 @@ func init() {
} }
if len(feViolations) > 0 { if len(feViolations) > 0 {
fmt.Printf(" FAIL: %d violation(s) — use generated hooks from @block-ninja/api/queries:\n", len(feViolations)) rep.Fail("%d violation(s) — use generated hooks from @block-ninja/api/queries", len(feViolations))
for _, v := range feViolations { for _, v := range feViolations {
fmt.Printf(" %s:%d [%s] %s\n", v.file, v.line, v.rule, v.snippet) rep.Findingf("%s:%d [%s] %s", v.file, v.line, v.rule, v.snippet)
} }
rep.Fail()
} }
if len(feWarnings) > 0 { if len(feWarnings) > 0 {
fmt.Printf(" WARN: %d fetch() call(s) to non-proto REST endpoints (no ConnectRPC equivalent):\n", len(feWarnings)) rep.Warn("%d fetch() call(s) to non-proto REST endpoints (no ConnectRPC equivalent)", len(feWarnings))
for _, w := range feWarnings { for _, w := range feWarnings {
fmt.Printf(" %s:%d %s\n", w.file, w.line, w.snippet) rep.Findingf("%s:%d %s", w.file, w.line, w.snippet)
} }
} }
if len(feViolations) == 0 { if len(feViolations) == 0 && len(feWarnings) == 0 {
fmt.Printf(" OK: Frontend API patterns are clean") rep.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 { } else {
fmt.Println(" SKIP: no frontend sources found") rep.Skip("no frontend sources found")
} }
fmt.Println()
}, },
}) })
} }

View File

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

View File

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

View File

@ -1,15 +1,11 @@
package main package main
import "fmt"
func init() { func init() {
register(Check{ register(Check{
Seq: 220, Seq: 220,
ID: "22", ID: "22",
Title: "No hand-rolled HTML sanitization (use bluemonday)", Title: "No hand-rolled HTML sanitization (use bluemonday)",
Run: func(ctx *ScanContext, rep *Reporter) { 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 var htmlSanViolations []htmlSanitizeViolation
for _, target := range ctx.backendTargets { for _, target := range ctx.backendTargets {
for _, v := range checkHTMLSanitize(target.root) { for _, v := range checkHTMLSanitize(target.root) {
@ -24,26 +20,12 @@ func init() {
} }
} }
if len(htmlSanViolations) > 0 { if len(htmlSanViolations) > 0 {
fmt.Printf(" FAIL: %d hand-rolled HTML sanitization pattern(s):\n", len(htmlSanViolations)) rep.Fail("%d hand-rolled HTML sanitization pattern(s) — use bluemonday", len(htmlSanViolations))
for _, v := range htmlSanViolations { for _, v := range htmlSanViolations {
fmt.Printf(" %s:%d [%s] %s\n", v.file, v.line, v.rule, v.snippet) rep.Findingf("%s:%d [%s] %s", 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 { } else {
fmt.Println(" OK: No hand-rolled HTML sanitization detected") rep.OK("No hand-rolled HTML sanitization detected")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
} }
}, },
}) })

View File

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

View File

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

View File

@ -1,9 +1,6 @@
package main package main
import ( import "sort"
"fmt"
"sort"
)
func init() { func init() {
register(Check{ register(Check{
@ -11,8 +8,6 @@ func init() {
ID: "11", ID: "11",
Title: "No placeholder code; only shipped features", Title: "No placeholder code; only shipped features",
Run: func(ctx *ScanContext, rep *Reporter) { 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{} comingSoonViolations := []comingSoonViolation{}
placeholderRoots := make([]todoScanRoot, 0, len(ctx.backendTargets)+len(ctx.frontendTargets)+len(ctx.pluginTargets)) placeholderRoots := make([]todoScanRoot, 0, len(ctx.backendTargets)+len(ctx.frontendTargets)+len(ctx.pluginTargets))
for _, target := range ctx.backendTargets { for _, target := range ctx.backendTargets {
@ -48,18 +43,13 @@ func init() {
}) })
if len(allPlaceholderViolations) > 0 { if len(allPlaceholderViolations) > 0 {
fmt.Printf(" FAIL: %d placeholder reference(s) found:\n", len(allPlaceholderViolations)) rep.Fail("%d placeholder reference(s) found — ship the feature now", len(allPlaceholderViolations))
for _, v := range allPlaceholderViolations { for _, v := range allPlaceholderViolations {
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet) rep.Findingf("%s:%d %s", v.file, v.line, v.snippet)
} }
fmt.Println("\n Fix: ship the feature now. Placeholder code is not allowed.")
rep.Fail()
} else { } else {
fmt.Println(" OK: No placeholder code found") rep.OK("No placeholder code found")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
} }
fmt.Println()
}, },
}) })
} }

View File

@ -1,7 +1,5 @@
package main package main
import "fmt"
func init() { func init() {
register(Check{ register(Check{
Seq: 107, Seq: 107,
@ -9,19 +7,12 @@ func init() {
Title: "Plugin frontend discovery", Title: "Plugin frontend discovery",
Run: func(ctx *ScanContext, rep *Reporter) { Run: func(ctx *ScanContext, rep *Reporter) {
if len(ctx.unresolvedPluginRoots) > 0 { if len(ctx.unresolvedPluginRoots) > 0 {
fmt.Println("=== Check 10: Plugin frontend discovery ===") rep.Fail("%d plugin root(s) could not be resolved", len(ctx.unresolvedPluginRoots))
fmt.Printf(" FAIL: %d plugin root(s) could not be resolved:\n", len(ctx.unresolvedPluginRoots))
for _, root := range ctx.unresolvedPluginRoots { for _, root := range ctx.unresolvedPluginRoots {
fmt.Printf(" - %s\n", root) rep.Findingf("- %s", 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 { } else if len(ctx.resolvedPluginTargets) > 0 {
fmt.Println("=== Check 10: Plugin frontend discovery ===") rep.OK("Resolved %d plugin root(s)", len(ctx.resolvedPluginTargets))
fmt.Printf(" OK: Resolved %d plugin root(s)\n", len(ctx.resolvedPluginTargets))
printPerTargetOKLines(pluginTargetLabels(ctx.resolvedPluginTargets))
fmt.Println()
} }
}, },
}) })

View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
) )
@ -12,8 +11,6 @@ func init() {
ID: "21", ID: "21",
Title: "Plugin presets.json validation", Title: "Plugin presets.json validation",
Run: func(ctx *ScanContext, rep *Reporter) { Run: func(ctx *ScanContext, rep *Reporter) {
// Check 21: Plugin presets.json validation
fmt.Println("\n=== Check 21: Plugin presets.json validation ===")
var presetViolations []presetViolation var presetViolations []presetViolation
presetsChecked := 0 presetsChecked := 0
// Check internal plugins // Check internal plugins
@ -50,20 +47,15 @@ func init() {
} }
} }
if len(presetViolations) > 0 { if len(presetViolations) > 0 {
fmt.Printf(" FAIL: %d presets.json validation error(s):\n", len(presetViolations)) rep.Fail("%d presets.json validation error(s)", len(presetViolations))
for _, v := range presetViolations { for _, v := range presetViolations {
fmt.Printf(" %s — %s\n", v.file, v.message) rep.Findingf("%s — %s", 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 { } else if presetsChecked > 0 {
fmt.Printf(" OK: All presets.json files are valid (%d checked)\n", presetsChecked) rep.OK("All presets.json files are valid (%d checked)", presetsChecked)
} else { } else {
fmt.Println(" OK: No presets.json files found in scanned targets") rep.OK("No presets.json files found in scanned targets")
} }
fmt.Println()
}, },
}) })
} }

View File

@ -1,18 +1,11 @@
package main package main
import (
"fmt"
"os"
)
func init() { func init() {
register(Check{ register(Check{
Seq: 21, Seq: 21,
ID: "2b", ID: "2b",
Title: "Plugin proto ownership", Title: "Plugin proto ownership",
Run: func(ctx *ScanContext, rep *Reporter) { Run: func(ctx *ScanContext, rep *Reporter) {
// Check 2b: Plugin proto ownership
fmt.Println("=== Check 2b: Plugin proto ownership ===")
var protoOwnershipViolations []protoOwnershipViolation var protoOwnershipViolations []protoOwnershipViolation
for _, target := range ctx.backendTargets { for _, target := range ctx.backendTargets {
if !isPluginModuleRoot(target.root) { if !isPluginModuleRoot(target.root) {
@ -20,32 +13,25 @@ func init() {
} }
violations, err := checkPluginProtoOwnership(target.root, target.displayOrRoot()) violations, err := checkPluginProtoOwnership(target.root, target.displayOrRoot())
if err != nil { if err != nil {
fmt.Printf(" ERROR: failed to check proto ownership for %s: %v\n", target.displayOrRoot(), err) rep.Fatal("failed to check proto ownership for %s: %v", target.displayOrRoot(), err)
os.Exit(2)
} }
protoOwnershipViolations = append(protoOwnershipViolations, violations...) protoOwnershipViolations = append(protoOwnershipViolations, violations...)
} }
for _, target := range ctx.pluginTargets { for _, target := range ctx.pluginTargets {
violations, err := checkPluginProtoOwnership(target.root, target.display) violations, err := checkPluginProtoOwnership(target.root, target.display)
if err != nil { if err != nil {
fmt.Printf(" ERROR: failed to check proto ownership for %s: %v\n", target.display, err) rep.Fatal("failed to check proto ownership for %s: %v", target.display, err)
os.Exit(2)
} }
protoOwnershipViolations = append(protoOwnershipViolations, violations...) protoOwnershipViolations = append(protoOwnershipViolations, violations...)
} }
if len(protoOwnershipViolations) > 0 { if len(protoOwnershipViolations) > 0 {
fmt.Printf(" FAIL: %d plugin proto ownership violation(s):\n", len(protoOwnershipViolations)) rep.Fail("%d plugin proto ownership violation(s)", len(protoOwnershipViolations))
for _, v := range protoOwnershipViolations { for _, v := range protoOwnershipViolations {
fmt.Printf(" %s [%s] local proto missing; matching proto exists in %s\n", v.root, v.namespace, v.coreProto) rep.Findingf("%s [%s] local proto missing; matching proto exists in %s", 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 { } else {
fmt.Println(" OK: Plugin proto ownership is clean") rep.OK("Plugin proto ownership is clean")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
} }
fmt.Println()
}, },
}) })
} }

View File

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

View File

@ -1,8 +1,6 @@
package main package main
import ( import (
"fmt"
"os"
"path/filepath" "path/filepath"
"sort" "sort"
) )
@ -13,8 +11,6 @@ func init() {
ID: "2", ID: "2",
Title: "RPC methods registered in RBAC interceptor", Title: "RPC methods registered in RBAC interceptor",
Run: func(ctx *ScanContext, rep *Reporter) { 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) protoMethods := make([]string, 0)
rbacMethods := make(map[string]string) rbacMethods := make(map[string]string)
protoMethodSet := make(map[string]bool) protoMethodSet := make(map[string]bool)
@ -34,8 +30,7 @@ func init() {
summary.requiresRPCs = true summary.requiresRPCs = true
targetProtoMethods, err := extractBufProcedures(target.protoRoot, target.allowedPackagePrefixes...) targetProtoMethods, err := extractBufProcedures(target.protoRoot, target.allowedPackagePrefixes...)
if err != nil { if err != nil {
fmt.Printf(" ERROR: failed to parse proto files for %s: %v\n", target.displayOrRoot(), err) rep.Fatal("failed to parse proto files for %s: %v", target.displayOrRoot(), err)
os.Exit(2)
} }
summary.protoMethods = append(summary.protoMethods, targetProtoMethods...) summary.protoMethods = append(summary.protoMethods, targetProtoMethods...)
for _, pm := range targetProtoMethods { for _, pm := range targetProtoMethods {
@ -48,8 +43,7 @@ func init() {
targetRBACMethods, err := extractRBACMethodRoles(rbacFile) targetRBACMethods, err := extractRBACMethodRoles(rbacFile)
if err != nil { if err != nil {
fmt.Printf(" ERROR: failed to parse RBAC interceptor for %s: %v\n", target.displayOrRoot(), err) rep.Fatal("failed to parse RBAC interceptor for %s: %v", target.displayOrRoot(), err)
os.Exit(2)
} }
for method, role := range targetRBACMethods { for method, role := range targetRBACMethods {
rbacMethods[method] = role rbacMethods[method] = role
@ -65,8 +59,7 @@ func init() {
} }
targetProtoMethods, err := extractBufProcedures(target.root) targetProtoMethods, err := extractBufProcedures(target.root)
if err != nil { if err != nil {
fmt.Printf(" ERROR: failed to parse proto files for %s: %v\n", target.displayOrRoot(), err) rep.Fatal("failed to parse proto files for %s: %v", target.displayOrRoot(), err)
os.Exit(2)
} }
summary.protoMethods = append(summary.protoMethods, targetProtoMethods...) summary.protoMethods = append(summary.protoMethods, targetProtoMethods...)
for _, pm := range targetProtoMethods { for _, pm := range targetProtoMethods {
@ -79,8 +72,7 @@ func init() {
targetRoleMethods, err := extractPluginRoleMethods(target.root) targetRoleMethods, err := extractPluginRoleMethods(target.root)
if err != nil { if err != nil {
fmt.Printf(" ERROR: failed to parse RBAC roles for %s: %v\n", target.displayOrRoot(), err) rep.Fatal("failed to parse RBAC roles for %s: %v", target.displayOrRoot(), err)
os.Exit(2)
} }
for method, role := range targetRoleMethods { for method, role := range targetRoleMethods {
rbacMethods[method] = role rbacMethods[method] = role
@ -96,8 +88,7 @@ func init() {
} }
pluginProtoMethods, err := extractBufProcedures(target.root) pluginProtoMethods, err := extractBufProcedures(target.root)
if err != nil { if err != nil {
fmt.Printf(" ERROR: failed to parse plugin proto files for %s: %v\n", target.display, err) rep.Fatal("failed to parse plugin proto files for %s: %v", target.display, err)
os.Exit(2)
} }
summary.protoMethods = append(summary.protoMethods, pluginProtoMethods...) summary.protoMethods = append(summary.protoMethods, pluginProtoMethods...)
for _, pm := range pluginProtoMethods { for _, pm := range pluginProtoMethods {
@ -110,8 +101,7 @@ func init() {
pluginRoleMethods, err := extractPluginRoleMethods(target.root) pluginRoleMethods, err := extractPluginRoleMethods(target.root)
if err != nil { if err != nil {
fmt.Printf(" ERROR: failed to parse plugin RBAC roles for %s: %v\n", target.display, err) rep.Fatal("failed to parse plugin RBAC roles for %s: %v", target.display, err)
os.Exit(2)
} }
for method, role := range pluginRoleMethods { for method, role := range pluginRoleMethods {
rbacMethods[method] = role rbacMethods[method] = role
@ -122,10 +112,9 @@ func init() {
if len(protoMethods) == 0 { if len(protoMethods) == 0 {
if requiresRPCDiscovery { if requiresRPCDiscovery {
fmt.Println(" ERROR: no RPC procedures found in proto sources or generated Connect files") rep.Fatal("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)") rep.Skip("no proto-backed RPC procedures found in scanned target(s)")
} else { } else {
var missing []string var missing []string
for _, m := range protoMethods { for _, m := range protoMethods {
@ -170,45 +159,41 @@ func init() {
for _, summary := range rbacSummaries { for _, summary := range rbacSummaries {
if len(summary.protoMethods) == 0 { if len(summary.protoMethods) == 0 {
if summary.requiresRPCs { if summary.requiresRPCs {
fmt.Printf(" ERROR: %s — no RPC procedures found\n", summary.label) rep.Fail("%s — no RPC procedures found", summary.label)
} else { } else {
fmt.Printf(" OK: %s — no proto-backed RPC procedures\n", summary.label) rep.OK("%s — no proto-backed RPC procedures", summary.label)
} }
continue continue
} }
fmt.Printf(" OK: %s — %d RPC methods discovered\n", summary.label, len(summary.protoMethods)) rep.OK("%s — %d RPC methods discovered", summary.label, len(summary.protoMethods))
printRoleBreakdown(countRBACRoles(summary.protoMethods, summary.roleByMethod))
if summary.missingCount > 0 { if summary.missingCount > 0 {
fmt.Printf(" missing RBAC entries: %d\n", summary.missingCount) rep.Findingf("missing RBAC entries: %d", summary.missingCount)
} }
if summary.staleCount > 0 { if summary.staleCount > 0 {
fmt.Printf(" stale RBAC entries: %d\n", summary.staleCount) rep.Findingf("stale RBAC entries: %d", summary.staleCount)
} }
} }
if len(missing) > 0 { if len(missing) > 0 {
fmt.Printf(" FAIL: %d RPC method(s) NOT in MethodRoles (will get permission_denied):\n", len(missing)) rep.Fail("%d RPC method(s) NOT in MethodRoles (will get permission_denied)", len(missing))
for _, m := range missing { for _, m := range missing {
fmt.Printf(" - %s\n", m) rep.Findingf("- %s", m)
} }
rep.Fail()
} }
if len(stale) > 0 { if len(stale) > 0 {
fmt.Printf(" WARN: %d RBAC entries have no matching proto method (stale?):\n", len(stale)) rep.Warn("%d RBAC entries have no matching proto method (stale?)", len(stale))
for _, m := range stale { for _, m := range stale {
fmt.Printf(" - %s\n", m) rep.Findingf("- %s", m)
} }
} }
if len(missing) == 0 && len(stale) == 0 { if len(missing) == 0 && len(stale) == 0 {
fmt.Printf(" OK: All %d RPC methods are registered in MethodRoles\n", len(protoMethods)) rep.OK("All %d RPC methods are registered in MethodRoles", len(protoMethods))
} else if len(missing) == 0 { } else if len(missing) == 0 {
fmt.Printf(" OK: All %d RPC methods are registered (but %d stale entries exist)\n", len(protoMethods), len(stale)) rep.OK("All %d RPC methods are registered (but %d stale entries exist)", len(protoMethods), len(stale))
} }
} }
fmt.Println()
}, },
}) })
} }

View File

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

View File

@ -1,15 +1,11 @@
package main package main
import "fmt"
func init() { func init() {
register(Check{ register(Check{
Seq: 130, Seq: 130,
ID: "13", ID: "13",
Title: "RPC query/mutation error handling", Title: "RPC query/mutation error handling",
Run: func(ctx *ScanContext, rep *Reporter) { 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 { if len(ctx.frontendTargets) > 0 {
var rpcViolations []rpcErrorViolation var rpcViolations []rpcErrorViolation
var rpcWarnings []rpcErrorViolation var rpcWarnings []rpcErrorViolation
@ -26,31 +22,21 @@ func init() {
} }
if len(rpcViolations) > 0 { if len(rpcViolations) > 0 {
fmt.Printf(" FAIL: %d mutation(s) with no error handling:\n", len(rpcViolations)) rep.Fail("%d mutation(s) with no error handling", len(rpcViolations))
for _, v := range rpcViolations { for _, v := range rpcViolations {
fmt.Printf(" %s:%d [%s] %s\n", v.file, v.line, v.rule, v.snippet) rep.Findingf("%s:%d [%s] %s", 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 { if len(rpcWarnings) > 0 {
fmt.Printf(" (%d queries rely on global error handler)", len(rpcWarnings)) rep.Findingf("%d useQuery call(s) without error destructuring (global handler covers these)", len(rpcWarnings))
} }
fmt.Println() } else if len(rpcWarnings) > 0 {
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets)) rep.OK("All mutations have error handling (%d queries rely on global error handler)", len(rpcWarnings))
} else {
rep.OK("All mutations have error handling")
} }
} else { } else {
fmt.Println(" SKIP: no frontend sources found") rep.Skip("no frontend sources found")
} }
fmt.Println()
}, },
}) })
} }

View File

@ -1,18 +1,11 @@
package main package main
import (
"fmt"
"os"
)
func init() { func init() {
register(Check{ register(Check{
Seq: 22, Seq: 22,
ID: "2c", ID: "2c",
Title: "Standalone plugin SDK import boundaries", Title: "Standalone plugin SDK import boundaries",
Run: func(ctx *ScanContext, rep *Reporter) { 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 cmsGoModViolations []pluginGoModViolation
var pluginImportViolations []pluginImportViolation var pluginImportViolations []pluginImportViolation
var pluginGoModViolations []pluginGoModViolation var pluginGoModViolations []pluginGoModViolation
@ -20,8 +13,7 @@ func init() {
checkedStandalonePluginRoots := make(map[string]bool) checkedStandalonePluginRoots := make(map[string]bool)
requiredSDKVersion, requiredSDKVersionErr := currentCMSCoreSDKVersion() requiredSDKVersion, requiredSDKVersionErr := currentCMSCoreSDKVersion()
if requiredSDKVersionErr != nil { if requiredSDKVersionErr != nil {
fmt.Printf(" ERROR: failed to resolve CMS SDK version: %v\n", requiredSDKVersionErr) rep.Fatal("failed to resolve CMS SDK version: %v", requiredSDKVersionErr)
os.Exit(2)
} }
checkStandalonePluginRoot := func(root string, label string) { checkStandalonePluginRoot := func(root string, label string) {
if checkedStandalonePluginRoots[root] || !shouldCheckStandalonePluginImports(root) { if checkedStandalonePluginRoots[root] || !shouldCheckStandalonePluginImports(root) {
@ -52,46 +44,38 @@ func init() {
} }
if len(cmsGoModViolations) > 0 || len(pluginImportViolations) > 0 || len(pluginGoModViolations) > 0 { if len(cmsGoModViolations) > 0 || len(pluginImportViolations) > 0 || len(pluginGoModViolations) > 0 {
if len(cmsGoModViolations) > 0 { if len(cmsGoModViolations) > 0 {
fmt.Printf(" FAIL: %d CMS backend go.mod violation(s):\n", len(cmsGoModViolations)) rep.Fail("%d CMS backend go.mod violation(s)", len(cmsGoModViolations))
for _, v := range cmsGoModViolations { for _, v := range cmsGoModViolations {
if v.line > 0 { if v.line > 0 {
fmt.Printf(" %s:%d [%s] %s\n", v.file, v.line, v.rule, v.detail) rep.Findingf("%s:%d [%s] %s", v.file, v.line, v.rule, v.detail)
continue continue
} }
fmt.Printf(" %s [%s] %s\n", v.file, v.rule, v.detail) rep.Findingf("%s [%s] %s", v.file, v.rule, v.detail)
} }
} }
if len(pluginImportViolations) > 0 { if len(pluginImportViolations) > 0 {
fmt.Printf(" FAIL: %d standalone plugin import violation(s):\n", len(pluginImportViolations)) rep.Fail("%d standalone plugin import violation(s)", len(pluginImportViolations))
for _, v := range pluginImportViolations { for _, v := range pluginImportViolations {
fmt.Printf(" %s:%d imports BlockNinja CMS package %q\n", v.file, v.line, v.importPath) rep.Findingf("%s:%d imports BlockNinja CMS package %q", v.file, v.line, v.importPath)
} }
} }
if len(pluginGoModViolations) > 0 { if len(pluginGoModViolations) > 0 {
fmt.Printf(" FAIL: %d standalone plugin go.mod violation(s):\n", len(pluginGoModViolations)) rep.Fail("%d standalone plugin go.mod violation(s)", len(pluginGoModViolations))
for _, v := range pluginGoModViolations { for _, v := range pluginGoModViolations {
if v.line > 0 { if v.line > 0 {
fmt.Printf(" %s:%d [%s] %s\n", v.file, v.line, v.rule, v.detail) rep.Findingf("%s:%d [%s] %s", v.file, v.line, v.rule, v.detail)
continue continue
} }
fmt.Printf(" %s [%s] %s\n", v.file, v.rule, v.detail) rep.Findingf("%s [%s] %s", 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 { } else if len(standalonePluginLabels) > 0 {
fmt.Printf(" OK: Standalone plugin imports and go.mod stay on SDK version %s\n", requiredSDKVersion) rep.OK("Standalone plugin imports and go.mod stay on SDK version %s", 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 { } else if ctx.includeCoreTargets {
fmt.Printf(" OK: %s/go.mod does not locally replace %s\n", ctx.backendTargets[0].displayOrRoot(), blockCoreImportPrefix) rep.OK("%s/go.mod does not locally replace %s", ctx.backendTargets[0].displayOrRoot(), blockCoreImportPrefix)
} else { } else {
fmt.Println(" SKIP: no standalone plugin roots scanned") rep.Skip("no standalone plugin roots scanned")
} }
fmt.Println()
}, },
}) })
} }

View File

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

View File

@ -1,15 +1,11 @@
package main package main
import "fmt"
func init() { func init() {
register(Check{ register(Check{
Seq: 23, Seq: 23,
ID: "2d", ID: "2d",
Title: "sqlc UUID overrides in plugins and cmd", Title: "sqlc UUID overrides in plugins and cmd",
Run: func(ctx *ScanContext, rep *Reporter) { 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 sqlcUUIDViolations []sqlcUUIDViolation
var sqlcScanLabels []string var sqlcScanLabels []string
for _, target := range ctx.backendTargets { for _, target := range ctx.backendTargets {
@ -34,20 +30,15 @@ func init() {
} }
} }
if len(sqlcUUIDViolations) > 0 { if len(sqlcUUIDViolations) > 0 {
fmt.Printf(" FAIL: %d sqlc uuid override violation(s):\n", len(sqlcUUIDViolations)) rep.Fail("%d sqlc uuid override violation(s) — use github.com/google/uuid.UUID/NullUUID, not string UUIDs", len(sqlcUUIDViolations))
for _, v := range sqlcUUIDViolations { for _, v := range sqlcUUIDViolations {
fmt.Printf(" %s [%s] %s\n", v.file, v.rule, v.detail) rep.Findingf("%s [%s] %s", 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 { } else if len(sqlcScanLabels) > 0 {
fmt.Println(" OK: sqlc UUID overrides use github.com/google/uuid") rep.OK("sqlc UUID overrides use github.com/google/uuid")
printPerTargetOKLines(sqlcScanLabels)
} else { } else {
fmt.Println(" SKIP: no plugin or cmd sqlc configs found") rep.Skip("no plugin or cmd sqlc configs found")
} }
fmt.Println()
}, },
}) })
} }

View File

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

View File

@ -1,15 +1,11 @@
package main package main
import "fmt"
func init() { func init() {
register(Check{ register(Check{
Seq: 70, Seq: 70,
ID: "7", ID: "7",
Title: "No useState for tab state in routes (use URL ?tab= params)", Title: "No useState for tab state in routes (use URL ?tab= params)",
Run: func(ctx *ScanContext, rep *Reporter) { 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 { if len(ctx.frontendTargets) > 0 {
var tabViolations []tabStateViolation var tabViolations []tabStateViolation
for _, target := range ctx.frontendTargets { for _, target := range ctx.frontendTargets {
@ -19,21 +15,16 @@ func init() {
} }
} }
if len(tabViolations) > 0 { if len(tabViolations) > 0 {
fmt.Printf(" FAIL: %d route file(s) use useState for tab state:\n", len(tabViolations)) rep.Fail("%d route file(s) use useState for tab state — use URL search params (?tab=)", len(tabViolations))
for _, v := range tabViolations { for _, v := range tabViolations {
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet) rep.Findingf("%s:%d %s", 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 { } else {
fmt.Println(" OK: No useState tab state in routes") rep.OK("No useState tab state in routes")
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
} }
} else { } else {
fmt.Println(" SKIP: no frontend sources found") rep.Skip("no frontend sources found")
} }
fmt.Println()
}, },
}) })
} }

View File

@ -1,9 +1,6 @@
package main package main
import ( import "path/filepath"
"fmt"
"path/filepath"
)
func init() { func init() {
register(Check{ register(Check{
@ -11,8 +8,6 @@ func init() {
ID: "20", ID: "20",
Title: "Tailwind v4 configuration (PostCSS, CSS directives, @config)", Title: "Tailwind v4 configuration (PostCSS, CSS directives, @config)",
Run: func(ctx *ScanContext, rep *Reporter) { 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 { if len(ctx.frontendTargets) > 0 {
var twViolations []tailwindViolation var twViolations []tailwindViolation
for _, target := range ctx.frontendTargets { for _, target := range ctx.frontendTargets {
@ -25,26 +20,19 @@ func init() {
} }
} }
if len(twViolations) > 0 { if len(twViolations) > 0 {
fmt.Printf(" FAIL: %d Tailwind v4 configuration issue(s):\n", len(twViolations)) rep.Fail("%d Tailwind v4 configuration issue(s)", len(twViolations))
for _, v := range twViolations { for _, v := range twViolations {
if v.line > 0 { if v.line > 0 {
fmt.Printf(" %s:%d [%s] %s\n", v.file, v.line, v.rule, v.snippet) rep.Findingf("%s:%d [%s] %s", v.file, v.line, v.rule, v.snippet)
} else { } else {
fmt.Printf(" %s [%s] %s\n", v.file, v.rule, v.snippet) rep.Findingf("%s [%s] %s", 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 { } else {
fmt.Println(" OK: Tailwind v4 configuration is correct") rep.OK("Tailwind v4 configuration is correct")
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
} }
} else { } else {
fmt.Println(" SKIP: no frontend sources found") rep.Skip("no frontend sources found")
} }
}, },
}) })

View File

@ -1,15 +1,11 @@
package main package main
import "fmt"
func init() { func init() {
register(Check{ register(Check{
Seq: 170, Seq: 170,
ID: "17", ID: "17",
Title: "No TODO markers in production code", Title: "No TODO markers in production code",
Run: func(ctx *ScanContext, rep *Reporter) { 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)) todoRoots := make([]todoScanRoot, 0, len(ctx.backendTargets)+len(ctx.frontendTargets)+len(ctx.pluginTargets))
for _, target := range ctx.backendTargets { for _, target := range ctx.backendTargets {
todoRoots = append(todoRoots, todoScanRoot{ todoRoots = append(todoRoots, todoScanRoot{
@ -31,18 +27,13 @@ func init() {
} }
todoViolations := checkTODOs(todoRoots) todoViolations := checkTODOs(todoRoots)
if len(todoViolations) > 0 { if len(todoViolations) > 0 {
fmt.Printf(" FAIL: %d TODO marker(s) found:\n", len(todoViolations)) rep.Fail("%d TODO marker(s) found — ship explicit behavior, not placeholders", len(todoViolations))
for _, v := range todoViolations { for _, v := range todoViolations {
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet) rep.Findingf("%s:%d %s", v.file, v.line, v.snippet)
} }
fmt.Println("\n Fix: Remove TODO markers and ship explicit behavior instead of placeholders.")
rep.Fail()
} else { } else {
fmt.Println(" OK: No TODO markers found in production code") rep.OK("No TODO markers found in production code")
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
} }
fmt.Println()
}, },
}) })
} }

View File

@ -1,14 +1,11 @@
package main package main
import "fmt"
func init() { func init() {
register(Check{ register(Check{
Seq: 280, Seq: 280,
ID: "28", ID: "28",
Title: "Public page handlers inject admin toolbar data", Title: "Public page handlers inject admin toolbar data",
Run: func(ctx *ScanContext, rep *Reporter) { Run: func(ctx *ScanContext, rep *Reporter) {
fmt.Println("=== Check 28: Public page handlers inject admin toolbar data ===")
var violations []toolbarViolation var violations []toolbarViolation
for _, target := range ctx.backendTargets { for _, target := range ctx.backendTargets {
for _, v := range checkToolbarInjection(target.root) { for _, v := range checkToolbarInjection(target.root) {
@ -17,21 +14,13 @@ func init() {
} }
} }
if len(violations) > 0 { if len(violations) > 0 {
fmt.Printf(" FAIL: %d public page handler(s) missing toolbar injection:\n", len(violations)) rep.Fail("%d public page handler(s) missing toolbar injection", len(violations))
for _, v := range violations { for _, v := range violations {
fmt.Printf(" %s:%d func %s — missing siteSettings[\"toolbar\"] for admin toolbar\n", v.file, v.line, v.funcName) rep.Findingf("%s:%d func %s — missing siteSettings[\"toolbar\"] for admin toolbar", v.file, v.line, v.funcName)
} }
fmt.Println("\n Fix: Add auth check + toolbar injection between getSiteSettings() and doc[\"site_settings\"]:")
fmt.Println(" if claims, ok := auth.GetUserFromContext(ctx); ok && claims != nil {")
fmt.Println(" toolbarData := h.buildToolbarData(ctx, r, page, systemTemplate, pageTemplateKey)")
fmt.Println(" siteSettings[\"toolbar\"] = toolbarData")
fmt.Println(" }")
rep.Fail()
} else { } else {
fmt.Println(" OK: All public page handlers inject admin toolbar data") rep.OK("All public page handlers inject admin toolbar data")
} }
fmt.Println()
}, },
}) })
} }

View File

@ -1,147 +0,0 @@
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")
}

View File

@ -3,7 +3,6 @@ package main
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@ -132,44 +131,3 @@ func TestCheckNinjaTplColorsAllowsCSSVariablesAndNeutralOverlays(t *testing.T) {
t.Fatalf("checkNinjaTplColors() returned %d violations, want 0: %#v", len(violations), violations) 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)
}
}

View File

@ -24,7 +24,7 @@ func TestCheckSafetySkipsRPCCheckForPluginWithoutRPCs(t *testing.T) {
t.Fatalf("getwd: %v", err) t.Fatalf("getwd: %v", err)
} }
cmd := exec.Command("go", "run", ".", pluginRoot) cmd := exec.Command("go", "run", ".", pluginRoot, "--verbose")
cmd.Dir = cwd cmd.Dir = cwd
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
@ -32,10 +32,10 @@ func TestCheckSafetySkipsRPCCheckForPluginWithoutRPCs(t *testing.T) {
} }
got := string(output) got := string(output)
if !strings.Contains(got, "=== Check 2: RPC methods registered in RBAC interceptor ===") { if !strings.Contains(got, "SKIP 2 ") {
t.Fatalf("output missing Check 2 header:\n%s", got) t.Fatalf("output missing Check 2 skip line:\n%s", got)
} }
if !strings.Contains(got, "SKIP: no proto-backed RPC procedures found in scanned target(s)") { if !strings.Contains(got, "no proto-backed RPC procedures found in scanned target(s)") {
t.Fatalf("output missing no-RPC plugin skip:\n%s", got) t.Fatalf("output missing no-RPC plugin skip:\n%s", got)
} }
} }
@ -66,7 +66,7 @@ func TestCheckSafetyTypechecksExternalPluginFrontendWithWorkspaceDeps(t *testing
t.Fatalf("getwd: %v", err) t.Fatalf("getwd: %v", err)
} }
cmd := exec.Command("go", "run", ".", pluginRoot) cmd := exec.Command("go", "run", ".", pluginRoot, "--verbose")
cmd.Dir = cwd cmd.Dir = cwd
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
@ -74,10 +74,7 @@ func TestCheckSafetyTypechecksExternalPluginFrontendWithWorkspaceDeps(t *testing
} }
got := string(output) got := string(output)
if !strings.Contains(got, "=== Check 4: Frontend auto-format, lint, and typecheck ===") { if !strings.Contains(got, "Prettier, ESLint, and TypeScript checks clean for 1 frontend target") {
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) t.Fatalf("output missing successful frontend typecheck result:\n%s", got)
} }
if strings.Contains(got, "Cannot find module '@block-ninja/ui'") { if strings.Contains(got, "Cannot find module '@block-ninja/ui'") {
@ -110,15 +107,12 @@ func Example() string {
} }
got := string(output) got := string(output)
if !strings.Contains(got, "=== Check 2c: Standalone plugin SDK import boundaries ===") { if !strings.Contains(got, "FAIL 2c ") {
t.Fatalf("output missing Check 2c header:\n%s", got) t.Fatalf("output missing Check 2c fail line:\n%s", got)
} }
if !strings.Contains(got, "imports BlockNinja CMS package") { if !strings.Contains(got, "imports BlockNinja CMS package") {
t.Fatalf("output missing forbidden BlockNinja import message:\n%s", got) 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) { func TestCheckSafetyFailsStandalonePluginReplaceDirective(t *testing.T) {
@ -144,9 +138,6 @@ func TestCheckSafetyFailsStandalonePluginReplaceDirective(t *testing.T) {
} }
got := string(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") { if !strings.Contains(got, "replace directive present") {
t.Fatalf("output missing replace violation detail:\n%s", got) t.Fatalf("output missing replace violation detail:\n%s", got)
} }
@ -189,8 +180,8 @@ sql:
} }
got := string(output) got := string(output)
if !strings.Contains(got, "=== Check 2d: sqlc UUID overrides in plugins and cmd ===") { if !strings.Contains(got, "FAIL 2d ") {
t.Fatalf("output missing Check 2d header:\n%s", got) t.Fatalf("output missing Check 2d fail line:\n%s", got)
} }
if !strings.Contains(got, "github.com/google/uuid.UUID") { if !strings.Contains(got, "github.com/google/uuid.UUID") {
t.Fatalf("output missing UUID remediation:\n%s", got) t.Fatalf("output missing UUID remediation:\n%s", got)
@ -220,7 +211,9 @@ type Payload struct {
t.Fatalf("getwd: %v", err) t.Fatalf("getwd: %v", err)
} }
cmd := exec.Command("go", "run", ".", pluginRoot) // The default any check is scoped to the unstaged diff; the temp plugin dir
// is not a git repo, so --all-any is required to surface the warning.
cmd := exec.Command("go", "run", ".", pluginRoot, "--all-any")
cmd.Dir = cwd cmd.Dir = cwd
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
@ -228,10 +221,10 @@ type Payload struct {
} }
got := string(output) got := string(output)
if !strings.Contains(got, "=== Check 2e: Warn on any usage in Go and TypeScript ===") { if !strings.Contains(got, "WARN 2e ") {
t.Fatalf("output missing Check 2e header:\n%s", got) t.Fatalf("output missing Check 2e warn line:\n%s", got)
} }
if !strings.Contains(got, "WARN:") || !strings.Contains(got, "[go]") { if !strings.Contains(got, "[go]") {
t.Fatalf("output missing Go any warning:\n%s", got) t.Fatalf("output missing Go any warning:\n%s", got)
} }
} }
@ -269,8 +262,8 @@ sql:
} }
got := string(output) got := string(output)
if !strings.Contains(got, "=== Check 2f: sqlc compile and buf generate ===") { if !strings.Contains(got, "FAIL 2f ") {
t.Fatalf("output missing Check 2f header:\n%s", got) t.Fatalf("output missing Check 2f fail line:\n%s", got)
} }
if !strings.Contains(got, "[sqlc compile]") { if !strings.Contains(got, "[sqlc compile]") {
t.Fatalf("output missing sqlc compile stage:\n%s", got) t.Fatalf("output missing sqlc compile stage:\n%s", got)

18
main.go
View File

@ -86,12 +86,15 @@ const maxAnyWarningsPrinted = 120
func main() { func main() {
ctx := buildScanContext() ctx := buildScanContext()
rep := &Reporter{} rep := &Reporter{verbose: ctx.verbose}
// Checks self-register via init(); run them in declared Seq order. // 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 }) sort.Slice(registry, func(i, j int) bool { return registry[i].Seq < registry[j].Seq })
for i := range registry { for i := range registry {
rep.begin(registry[i].Seq, registry[i].ID, registry[i].Title)
registry[i].Run(ctx, rep) registry[i].Run(ctx, rep)
rep.end()
} }
rep.render()
os.Exit(rep.exitCode) os.Exit(rep.exitCode)
} }
@ -101,6 +104,8 @@ func buildScanContext() *ScanContext {
var pluginPageDirs []string var pluginPageDirs []string
var pluginRoots []string var pluginRoots []string
orchestratorOnly := false orchestratorOnly := false
allAny := false
verbose := false
// Parse args: first positional arg is backendDir. Plugin frontend paths may be passed // 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. // as direct source dirs via --plugin-pages or as plugin roots via --plugin-dir.
@ -108,6 +113,10 @@ func buildScanContext() *ScanContext {
for i := 0; i < len(args); i++ { for i := 0; i < len(args); i++ {
if args[i] == "--orchestrator" { if args[i] == "--orchestrator" {
orchestratorOnly = true orchestratorOnly = true
} else if args[i] == "--all-any" {
allAny = true
} else if args[i] == "--verbose" || args[i] == "-v" {
verbose = true
} else if args[i] == "--plugin-pages" { } else if args[i] == "--plugin-pages" {
// Consume all following args until next flag or end // Consume all following args until next flag or end
for i++; i < len(args) && !strings.HasPrefix(args[i], "--"); i++ { for i++; i < len(args) && !strings.HasPrefix(args[i], "--"); i++ {
@ -161,9 +170,9 @@ func buildScanContext() *ScanContext {
scanPath = targetDir scanPath = targetDir
} }
if orchestratorOnly { if orchestratorOnly {
fmt.Printf("(running checks in %s [orchestrator only])\n", repoRoot) fmt.Printf("check-safety %s [orchestrator only]\n", repoRoot)
} else { } else {
fmt.Printf("(running checks in %s)\n", scanPath) fmt.Printf("check-safety %s\n", scanPath)
} }
if !orchestratorOnly && len(pluginRoots) == 0 && len(pluginPageDirs) == 0 { if !orchestratorOnly && len(pluginRoots) == 0 && len(pluginPageDirs) == 0 {
blockNinjaRepo := blockNinjaRepoRoot() blockNinjaRepo := blockNinjaRepoRoot()
@ -171,7 +180,6 @@ func buildScanContext() *ScanContext {
fmt.Println("(for a plugin: use --plugin-dir <path> or run in the plugin directory)") fmt.Println("(for a plugin: use --plugin-dir <path> or run in the plugin directory)")
} }
} }
fmt.Println()
includeCoreTargets := shouldIncludeCoreTargets(targetDir, backendDir, pluginRoots, pluginPageDirs) includeCoreTargets := shouldIncludeCoreTargets(targetDir, backendDir, pluginRoots, pluginPageDirs)
backendTargets := collectBackendScanTargets(repoRoot, backendDir, includeCoreTargets) backendTargets := collectBackendScanTargets(repoRoot, backendDir, includeCoreTargets)
@ -186,6 +194,8 @@ func buildScanContext() *ScanContext {
backendDir: backendDir, backendDir: backendDir,
orchestratorOnly: orchestratorOnly, orchestratorOnly: orchestratorOnly,
includeCoreTargets: includeCoreTargets, includeCoreTargets: includeCoreTargets,
allAny: allAny,
verbose: verbose,
backendTargets: backendTargets, backendTargets: backendTargets,
pluginTargets: pluginTargets, pluginTargets: pluginTargets,
resolvedPluginTargets: resolvedPluginTargets, resolvedPluginTargets: resolvedPluginTargets,

View File

@ -1,11 +1,9 @@
package main package main
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"slices" "slices"
"sort"
"strings" "strings"
) )
@ -113,43 +111,3 @@ func isPluginModuleRoot(root string) bool {
func containsString(values []string, target string) bool { func containsString(values []string, target string) bool {
return slices.Contains(values, target) 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
}

View File

@ -721,21 +721,6 @@ func isExcludedServiceMethod(method string) bool {
return excludedServices[svcName] 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 checkProtoGeneratedFreshness(repoRoot string) (protoFreshnessResult, error) { func checkProtoGeneratedFreshness(repoRoot string) (protoFreshnessResult, error) {
result := protoFreshnessResult{} result := protoFreshnessResult{}
if !samePath(repoRoot, blockNinjaRepoRoot()) { if !samePath(repoRoot, blockNinjaRepoRoot()) {
@ -781,26 +766,3 @@ func protoGeneratedStatus(repoRoot string) (string, error) {
args = append(args, paths...) args = append(args, paths...)
return runCommand(repoRoot, "git", args...) return runCommand(repoRoot, "git", args...)
} }
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])
}
}

View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"fmt"
"go/ast" "go/ast"
"go/parser" "go/parser"
"go/token" "go/token"
@ -120,15 +119,3 @@ func checkRawSQL(root string) []rawSQLViolation {
return violations 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.`)
}

View File

@ -1,5 +1,11 @@
package main package main
import (
"fmt"
"os"
"strings"
)
// ScanContext holds all resolved scan state, built once before any check runs. // ScanContext holds all resolved scan state, built once before any check runs.
// It is read-only to checks. // It is read-only to checks.
type ScanContext struct { type ScanContext struct {
@ -7,6 +13,8 @@ type ScanContext struct {
backendDir string backendDir string
orchestratorOnly bool orchestratorOnly bool
includeCoreTargets bool includeCoreTargets bool
allAny bool // --all-any: scan every source for `any`, not just the unstaged diff
verbose bool // --verbose/-v: print every check, not just warn/fail/err
backendTargets []backendScanTarget backendTargets []backendScanTarget
pluginTargets []pluginScanTarget // filtered pluginTargets []pluginScanTarget // filtered
resolvedPluginTargets []pluginScanTarget // pre-filter resolvedPluginTargets []pluginScanTarget // pre-filter
@ -15,16 +23,193 @@ type ScanContext struct {
pluginPageDirs []string pluginPageDirs []string
} }
// Reporter accumulates the process exit code across checks. // status is a check's verdict. Ordered by severity so it can only escalate.
type Reporter struct { // statusUnset is the default before any verdict; the first call to any
exitCode int // OK/Skip/Warn/Fail/Fatal sets it, and later more-severe calls win. A check
// that declares both Skip (some targets) and OK (others) resolves to OK.
type status int
const (
statusUnset status = iota // no verdict declared yet (default)
statusSkip
statusOK
statusWarn
statusFail
statusErr // most severe
)
func (s status) label() string {
switch s {
case statusSkip:
return "SKIP"
case statusWarn:
return "WARN"
case statusFail:
return "FAIL"
case statusErr:
return "ERR "
default: // statusUnset and statusOK both render as a pass
return "OK "
}
} }
// Fail marks the overall run as failed (exit code 1). // checkReport accumulates one check's verdict and detail lines.
func (r *Reporter) Fail() { r.exitCode = 1 } type checkReport struct {
seq int
id string
title string
status status
summary string // short note shown after the title (verbose, or for OK/SKIP)
findings []string // detail lines, printed indented under the header
}
// Check is one registered safety check. Run prints its own header and results // Reporter collects every check's report and renders them once at the end.
// and calls rep.Fail() on violations, exactly as the original inline block did. // It also tracks the process exit code.
type Reporter struct {
exitCode int
verbose bool
current *checkReport
reports []*checkReport
}
// begin starts a fresh report for the next check. Default status is OK; checks
// escalate as needed.
func (r *Reporter) begin(seq int, id, title string) {
r.current = &checkReport{seq: seq, id: id, title: title, status: statusUnset}
}
// end files the current report.
func (r *Reporter) end() {
if r.current != nil {
r.reports = append(r.reports, r.current)
r.current = nil
}
}
// setStatus escalates the current check to the most severe verdict seen.
func (r *Reporter) setStatus(s status, summary string) {
if r.current == nil {
return
}
if s >= r.current.status {
r.current.status = s
if summary != "" {
r.current.summary = summary
}
} else if r.current.summary == "" && summary != "" {
r.current.summary = summary
}
}
// OK marks the current check as passing.
func (r *Reporter) OK(format string, a ...any) { r.setStatus(statusOK, fmt.Sprintf(format, a...)) }
// Skip marks the current check as skipped (nothing to scan).
func (r *Reporter) Skip(format string, a ...any) { r.setStatus(statusSkip, fmt.Sprintf(format, a...)) }
// Warn marks the current check as a warning. Does not affect the exit code.
func (r *Reporter) Warn(format string, a ...any) { r.setStatus(statusWarn, fmt.Sprintf(format, a...)) }
// Fail marks the current check as failed and sets exit code 1.
func (r *Reporter) Fail(format string, a ...any) {
r.setStatus(statusFail, fmt.Sprintf(format, a...))
r.exitCode = 1
}
// Fatal reports an unrecoverable error for the current check, renders what has
// been collected so far, and exits with code 2 — matching the pre-existing
// hard-error behavior of checks that could not run.
func (r *Reporter) Fatal(format string, a ...any) {
r.setStatus(statusErr, fmt.Sprintf(format, a...))
r.exitCode = 2
r.end()
r.render()
os.Exit(2)
}
// Finding appends one detail line to the current check.
func (r *Reporter) Finding(line string) {
if r.current != nil {
r.current.findings = append(r.current.findings, line)
}
}
// Findingf is Finding with formatting.
func (r *Reporter) Findingf(format string, a ...any) { r.Finding(fmt.Sprintf(format, a...)) }
// render prints the collected reports. In concise mode (default) only
// warn/fail/err checks print; --verbose prints every check.
func (r *Reporter) render() {
printed := false
for _, c := range r.reports {
show := r.verbose || c.status == statusWarn || c.status == statusFail || c.status == statusErr
if !show {
continue
}
// For warn/fail/err the summary is the specific, descriptive headline
// (the check title would just duplicate it); id still identifies the
// check. OK/SKIP (verbose only) show the title plus any summary note.
var headline string
if c.status == statusWarn || c.status == statusFail || c.status == statusErr {
headline = c.summary
if headline == "" {
headline = c.title
}
} else {
headline = c.title
if c.summary != "" {
headline += " — " + c.summary
}
}
fmt.Printf("%s %s %s\n", c.status.label(), c.id, headline)
for _, f := range c.findings {
fmt.Printf(" %s\n", f)
}
printed = true
}
if printed {
fmt.Println()
}
fmt.Println(r.summaryLine())
}
// summaryLine builds the trailing one-line tally.
func (r *Reporter) summaryLine() string {
var ok, skip, warn, fail, errc int
for _, c := range r.reports {
switch c.status {
case statusOK, statusUnset:
ok++
case statusSkip:
skip++
case statusWarn:
warn++
case statusFail:
fail++
case statusErr:
errc++
}
}
parts := []string{}
add := func(n int, label string) {
if n > 0 {
parts = append(parts, fmt.Sprintf("%d %s", n, label))
}
}
add(ok, "ok")
add(skip, "skip")
add(warn, "warn")
add(fail, "fail")
add(errc, "err")
verdict := "OK"
if r.exitCode != 0 {
verdict = "FAIL"
}
return fmt.Sprintf("%d checks: %s -> %s", len(r.reports), strings.Join(parts, " "), verdict)
}
// Check is one registered safety check. Run declares its verdict via the
// Reporter (rep.OK/Skip/Warn/Fail/Fatal + rep.Finding); the Reporter renders.
type Check struct { type Check struct {
Seq int Seq int
ID string ID string

View File

@ -354,30 +354,3 @@ func checkReinventedFrontend(webSrcDir string) []reinventedViolation {
return violations 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
`)
}

View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
@ -142,27 +141,3 @@ func hasGlobalMutationErrorHandler(webSrcDir string) bool {
content := string(data) content := string(data)
return strings.Contains(content, "MutationCache") && strings.Contains(content, "onError") 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
}),
})
`)
}

View File

@ -1,92 +1,2 @@
(running checks in FIXTURE_DIR) check-safety FIXTURE_DIR
32 checks: 20 ok 12 skip -> OK
=== 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
=== Check 28: Public page handlers inject admin toolbar data ===
OK: All public page handlers inject admin toolbar data

View File

@ -1,113 +1,13 @@
(running checks in FIXTURE_DIR) check-safety FIXTURE_DIR
FAIL 1 1 secret env var read(s) outside config.Load() — secrets must flow through Config → DI
=== Check 1: Secret env var reads outside config.Load() === internal/service/handler.go:17 — os.Getenv("JWT_SECRET")
FAIL: internal/service/handler.go:17 — os.Getenv("JWT_SECRET") FAIL 2c 1 CMS backend go.mod violation(s)
FIXTURE_DIR/go.mod [missing-go-mod] missing go.mod
1 violation(s). Secrets must flow through Config → DI. FAIL 14 1 raw SQL statement(s) found outside sqlc/Bob
internal/service/handler.go:22 SELECT id, name FROM users WHERE active = true
=== Check 2: RPC methods registered in RBAC interceptor === FAIL 15 1 err.Error() leak(s) to HTTP clients — log via slog.Error() and return a user-friendly message
SKIP: no proto-backed RPC procedures found in scanned target(s) internal/service/handler.go:27 http.Error(w, err.Error(), ...) — leaks internal error
FAIL 17 1 TODO marker(s) found — ship explicit behavior, not placeholders
=== Check 2b: Plugin proto ownership === internal/service/handler.go:14 // TODO: add proper initialisation
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
=== Check 28: Public page handlers inject admin toolbar data ===
OK: All public page handlers inject admin toolbar data
32 checks: 14 ok 13 skip 5 fail -> FAIL