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:
parent
3b5a6d26d6
commit
c4d01de82c
28
README.md
28
README.md
@ -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. |
|
||||
| `--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`. |
|
||||
| `--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`
|
||||
`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
|
||||
|
||||
```bash
|
||||
|
||||
16
any_usage.go
16
any_usage.go
@ -17,6 +17,15 @@ type anyUsageWarning struct {
|
||||
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 (
|
||||
reTypeScriptAny = regexp.MustCompile(`\bany\b`)
|
||||
reTypeScriptAsAny = regexp.MustCompile(`\bas\s+any\b`)
|
||||
@ -26,7 +35,7 @@ var (
|
||||
reTypeScriptEqAny = regexp.MustCompile(`=\s*any(?:\b|[\s;])`)
|
||||
)
|
||||
|
||||
func checkGoAnyUsage(root string) []anyUsageWarning {
|
||||
func checkGoAnyUsage(root string, keep lineKeeper) []anyUsageWarning {
|
||||
var warnings []anyUsageWarning
|
||||
|
||||
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
@ -61,6 +70,9 @@ func checkGoAnyUsage(root string) []anyUsageWarning {
|
||||
continue
|
||||
}
|
||||
seen[position.Line] = true
|
||||
if !keep.keep(path, position.Line) {
|
||||
continue
|
||||
}
|
||||
snippet := ""
|
||||
if position.Line-1 >= 0 && position.Line-1 < len(lines) {
|
||||
snippet = strings.TrimSpace(lines[position.Line-1])
|
||||
@ -166,7 +178,7 @@ func findAnyTypePositions(expr ast.Expr) []token.Pos {
|
||||
return positions
|
||||
}
|
||||
|
||||
func checkTypeScriptAnyUsage(root string) []anyUsageWarning {
|
||||
func checkTypeScriptAnyUsage(root string, keep lineKeeper) []anyUsageWarning {
|
||||
var warnings []anyUsageWarning
|
||||
|
||||
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
|
||||
@ -19,7 +19,7 @@ func Map() map[string]any {
|
||||
}
|
||||
`, 0644)
|
||||
|
||||
warnings := checkGoAnyUsage(root)
|
||||
warnings := checkGoAnyUsage(root, nil)
|
||||
if len(warnings) < 3 {
|
||||
t.Fatalf("checkGoAnyUsage() returned %d warnings, want at least 3: %#v", len(warnings), warnings)
|
||||
}
|
||||
@ -38,7 +38,7 @@ type Payload struct {
|
||||
}
|
||||
`, 0644)
|
||||
|
||||
warnings := checkGoAnyUsage(root)
|
||||
warnings := checkGoAnyUsage(root, nil)
|
||||
if len(warnings) != 0 {
|
||||
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
|
||||
`, 0644)
|
||||
|
||||
warnings := checkTypeScriptAnyUsage(root)
|
||||
warnings := checkTypeScriptAnyUsage(root, nil)
|
||||
if len(warnings) < 2 {
|
||||
t.Fatalf("checkTypeScriptAnyUsage() returned %d warnings, want at least 2: %#v", len(warnings), warnings)
|
||||
}
|
||||
@ -63,7 +63,7 @@ const label = "any";
|
||||
/* any */
|
||||
`, 0644)
|
||||
|
||||
warnings := checkTypeScriptAnyUsage(root)
|
||||
warnings := checkTypeScriptAnyUsage(root, nil)
|
||||
if len(warnings) != 0 {
|
||||
t.Fatalf("checkTypeScriptAnyUsage() returned %d warnings, want 0: %#v", len(warnings), warnings)
|
||||
}
|
||||
|
||||
135
changedlines.go
Normal file
135
changedlines.go
Normal 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
102
changedlines_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -1,49 +1,64 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 24,
|
||||
ID: "2e",
|
||||
Title: "Warn on any usage in Go and TypeScript",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 2e: Warn on any usage in Go and TypeScript
|
||||
fmt.Println("=== Check 2e: Warn on any usage in Go and TypeScript ===")
|
||||
// By default only warn on `any` introduced in the unstaged working-tree
|
||||
// diff; --all-any scans every source file.
|
||||
var keep lineKeeper
|
||||
if !ctx.allAny {
|
||||
idx := newDiffIndex()
|
||||
keep = idx.isChanged
|
||||
}
|
||||
|
||||
var anyWarnings []anyUsageWarning
|
||||
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)
|
||||
anyWarnings = append(anyWarnings, w)
|
||||
}
|
||||
}
|
||||
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)
|
||||
anyWarnings = append(anyWarnings, w)
|
||||
}
|
||||
}
|
||||
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)
|
||||
anyWarnings = append(anyWarnings, w)
|
||||
}
|
||||
}
|
||||
if len(anyWarnings) > 0 {
|
||||
fmt.Printf(" WARN: %d any usage warning(s):\n", len(anyWarnings))
|
||||
limit := min(len(anyWarnings), maxAnyWarningsPrinted)
|
||||
for _, w := range anyWarnings[:limit] {
|
||||
fmt.Printf(" %s:%d [%s] %s\n", w.file, w.line, w.lang, w.snippet)
|
||||
}
|
||||
if len(anyWarnings) > limit {
|
||||
fmt.Printf(" ... %d additional warning(s) omitted\n", len(anyWarnings)-limit)
|
||||
}
|
||||
fmt.Println("\n Guidance: prefer concrete types, generics, typed maps/structs, or unknown-style narrowing patterns over any.")
|
||||
|
||||
if len(anyWarnings) == 0 {
|
||||
if ctx.allAny {
|
||||
rep.OK("no any usage found")
|
||||
} else {
|
||||
fmt.Println(" OK: No any usage found in scanned Go or TypeScript sources")
|
||||
rep.OK("no any usage in changed lines")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 190,
|
||||
ID: "19",
|
||||
Title: "Bearer token precedence over cookies in auth middleware",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 19: Bearer token precedence over cookies
|
||||
fmt.Println("\n=== Check 19: Bearer token precedence over cookies in auth middleware ===")
|
||||
var authPrecViolations []authPrecedenceViolation
|
||||
for _, target := range ctx.backendTargets {
|
||||
for _, v := range checkAuthPrecedence(target.root) {
|
||||
@ -24,15 +20,12 @@ func init() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
fmt.Println(" OK: Bearer token takes precedence over cookies in all auth extraction")
|
||||
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
|
||||
rep.OK("Bearer token takes precedence over cookies in all auth extraction")
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 80,
|
||||
ID: "8",
|
||||
Title: "Import @block-ninja/api from subpaths only",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 8: Import @block-ninja/api from subpaths only
|
||||
fmt.Println("=== Check 8: Import @block-ninja/api from subpaths only ===")
|
||||
if len(ctx.frontendTargets) > 0 {
|
||||
var bareImports []bareImportViolation
|
||||
for _, target := range ctx.frontendTargets {
|
||||
@ -19,21 +15,16 @@ func init() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet)
|
||||
}
|
||||
fmt.Println("\n Fix: Import from subpaths: @block-ninja/api/types, /queries, or /services")
|
||||
rep.Fail()
|
||||
} else {
|
||||
fmt.Println(" OK: All @block-ninja/api imports use subpaths")
|
||||
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
|
||||
rep.Findingf("%s:%d %s", v.file, v.line, v.snippet)
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" SKIP: no frontend sources found")
|
||||
rep.OK("All @block-ninja/api imports use subpaths")
|
||||
}
|
||||
} else {
|
||||
rep.Skip("no frontend sources found")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
// enableBuildmodePluginCheck is the per-check enable toggle. It ships DISABLED
|
||||
// (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
|
||||
@ -20,20 +18,15 @@ func init() {
|
||||
if !enableBuildmodePluginCheck {
|
||||
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)
|
||||
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 {
|
||||
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 {
|
||||
fmt.Println(" OK: No -buildmode=plugin targets found")
|
||||
printPerTargetOKLines(pluginTargetLabels(ctx.resolvedPluginTargets))
|
||||
rep.OK("No -buildmode=plugin targets found")
|
||||
}
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 100,
|
||||
ID: "10",
|
||||
Title: "Button automation attributes",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 10: Button automation attributes
|
||||
fmt.Println("=== Check 10: Button automation attributes ===")
|
||||
if len(ctx.frontendTargets) > 0 {
|
||||
var buttonViolations []buttonViolation
|
||||
for _, target := range ctx.frontendTargets {
|
||||
@ -19,22 +15,16 @@ func init() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
fmt.Printf(" %s:%d\n", v.file, v.line)
|
||||
}
|
||||
fmt.Println("\n Fix: Add action= and entity= props: <Button action=\"create\" entity=\"user\">")
|
||||
fmt.Println(" See docs/BUTTON_AUTOMATION.md")
|
||||
rep.Fail()
|
||||
} else {
|
||||
fmt.Println(" OK: All buttons have automation attributes")
|
||||
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
|
||||
rep.Findingf("%s:%d", v.file, v.line)
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" SKIP: no frontend sources found")
|
||||
rep.OK("All buttons have automation attributes")
|
||||
}
|
||||
} else {
|
||||
rep.Skip("no frontend sources found")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
import "strings"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
@ -12,67 +8,56 @@ func init() {
|
||||
ID: "2f",
|
||||
Title: "sqlc compile and buf generate",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 2f: sqlc compile and buf generate
|
||||
fmt.Println("=== Check 2f: sqlc compile and buf generate ===")
|
||||
codegenTargets := discoverCodegenTargets(ctx.repoRoot, ctx.backendDir, ctx.backendTargets, ctx.pluginTargets, ctx.includeCoreTargets)
|
||||
if len(codegenTargets) > 0 {
|
||||
completedCodegenTargets, codegenFailures, codegenErr := runCodegenChecks(codegenTargets)
|
||||
if codegenErr != nil {
|
||||
fmt.Printf(" ERROR: failed to run codegen checks: %v\n", codegenErr)
|
||||
os.Exit(2)
|
||||
rep.Fatal("failed to run codegen checks: %v", codegenErr)
|
||||
}
|
||||
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 {
|
||||
fmt.Printf(" [%s] %s\n", failure.stage, failure.target)
|
||||
rep.Findingf("[%s] %s", failure.stage, failure.target)
|
||||
if failure.output != "" {
|
||||
for line := range strings.SplitSeq(failure.output, "\n") {
|
||||
fmt.Printf(" %s\n", line)
|
||||
rep.Findingf("%s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
rep.Fail()
|
||||
} else {
|
||||
fmt.Printf(" OK: sqlc compile / buf generate clean for %d target(s)\n", len(completedCodegenTargets))
|
||||
for _, target := range completedCodegenTargets {
|
||||
fmt.Printf(" - %s\n", target)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" SKIP: no sqlc or buf codegen targets found")
|
||||
rep.OK("sqlc compile / buf generate clean for %d target(s)", len(completedCodegenTargets))
|
||||
}
|
||||
} else {
|
||||
rep.Skip("no sqlc or buf codegen targets found")
|
||||
}
|
||||
|
||||
protoFreshness, protoFreshnessErr := checkProtoGeneratedFreshness(ctx.repoRoot)
|
||||
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 != "" {
|
||||
for line := range strings.SplitSeq(protoFreshness.output, "\n") {
|
||||
fmt.Printf(" %s\n", line)
|
||||
rep.Findingf("%s", line)
|
||||
}
|
||||
}
|
||||
rep.Fail()
|
||||
} else if protoFreshness.changed() {
|
||||
fmt.Println(" FAIL: make proto changed generated outputs; run make proto and commit the generated files.")
|
||||
fmt.Println(" before:")
|
||||
printIndentedStatus(protoFreshness.beforeStatus)
|
||||
fmt.Println(" after:")
|
||||
printIndentedStatus(protoFreshness.afterStatus)
|
||||
rep.Fail()
|
||||
rep.Fail("make proto changed generated outputs; run make proto and commit the generated files")
|
||||
rep.Finding("before:")
|
||||
appendStatusFindings(rep, protoFreshness.beforeStatus)
|
||||
rep.Finding("after:")
|
||||
appendStatusFindings(rep, protoFreshness.afterStatus)
|
||||
} 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) == "" {
|
||||
fmt.Println(" (clean)")
|
||||
rep.Finding("(clean)")
|
||||
return
|
||||
}
|
||||
for line := range strings.SplitSeq(status, "\n") {
|
||||
fmt.Printf(" %s\n", line)
|
||||
rep.Findingf("%s", line)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
import "sort"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
@ -8,9 +8,6 @@ func init() {
|
||||
ID: "6",
|
||||
Title: "No hardcoded colors in frontend (use theme tokens)",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 6: Hardcoded colors in frontend and .templ files
|
||||
fmt.Println("=== Check 6: No hardcoded colors in frontend (use theme tokens) ===")
|
||||
|
||||
var colorViolations []colorViolation
|
||||
for _, target := range ctx.frontendTargets {
|
||||
for _, v := range checkColors(target.dir) {
|
||||
@ -45,25 +42,18 @@ func init() {
|
||||
|
||||
hasColorSources := len(ctx.frontendTargets) > 0 || len(templColorViolations) > 0 || len(seenTemplRoots) > 0
|
||||
if !hasColorSources && len(ctx.backendTargets) == 0 {
|
||||
fmt.Println(" SKIP: no frontend or templ sources found")
|
||||
rep.Skip("no frontend or templ sources found")
|
||||
} else {
|
||||
hadColorViolations := false
|
||||
|
||||
if len(colorViolations) > 0 {
|
||||
fmt.Println(formatColorViolations("frontend", colorViolations))
|
||||
hadColorViolations = true
|
||||
rep.Fail()
|
||||
reportColorViolations(rep, "frontend", colorViolations)
|
||||
} else if len(ctx.frontendTargets) > 0 {
|
||||
fmt.Println(" OK: No hardcoded colors in frontend components")
|
||||
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
|
||||
rep.OK("No hardcoded colors in frontend components")
|
||||
}
|
||||
|
||||
if len(templColorViolations) > 0 {
|
||||
fmt.Println(formatColorViolations(".templ files", templColorViolations))
|
||||
hadColorViolations = true
|
||||
rep.Fail()
|
||||
reportColorViolations(rep, ".templ files", templColorViolations)
|
||||
} 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.
|
||||
@ -81,19 +71,37 @@ func init() {
|
||||
}
|
||||
}
|
||||
if len(ninjaTplColorViolations) > 0 {
|
||||
fmt.Println(formatColorViolations(".ninjatpl files", ninjaTplColorViolations))
|
||||
hadColorViolations = true
|
||||
rep.Fail()
|
||||
reportColorViolations(rep, ".ninjatpl files", ninjaTplColorViolations)
|
||||
} else if len(seenNinjaTplRoots) > 0 {
|
||||
fmt.Println(" OK: No hardcoded colors in .ninjatpl files")
|
||||
}
|
||||
|
||||
if hadColorViolations {
|
||||
printColorFixInstructions(colorViolations, templColorViolations, ninjaTplColorViolations)
|
||||
rep.OK("No hardcoded colors in .ninjatpl files")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 10,
|
||||
ID: "1",
|
||||
Title: "Secret env var reads outside config.Load()",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 1: Secret env var reads
|
||||
fmt.Println("=== Check 1: Secret env var reads outside config.Load() ===")
|
||||
var envViolations []envViolation
|
||||
for _, target := range ctx.backendTargets {
|
||||
for _, v := range checkEnvReads(target.root) {
|
||||
@ -24,21 +20,17 @@ func init() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
fmt.Printf(" OK: No secret env var reads outside config.Load()")
|
||||
if len(ctx.pluginTargets) > 0 {
|
||||
fmt.Printf(" (%d plugin roots scanned)", len(ctx.pluginTargets))
|
||||
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()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 150,
|
||||
ID: "15",
|
||||
Title: "No err.Error() leaked to HTTP clients",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 15: No err.Error() leaked to HTTP clients
|
||||
fmt.Println("=== Check 15: No err.Error() leaked to HTTP clients ===")
|
||||
var errLeaks []errLeakViolation
|
||||
for _, target := range ctx.backendTargets {
|
||||
for _, v := range checkErrLeak(target.root) {
|
||||
@ -24,18 +20,13 @@ func init() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
fmt.Println(" OK: No err.Error() leaked to HTTP clients")
|
||||
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
|
||||
rep.OK("No err.Error() leaked to HTTP clients")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 106,
|
||||
ID: "10b",
|
||||
Title: "No eslint-disable-next-line comments",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 10b: No eslint-disable-next-line comments
|
||||
fmt.Println("=== Check 10b: No eslint-disable-next-line comments ===")
|
||||
eslintDirectiveTargets := collectESLintDirectiveTargets(ctx.repoRoot, ctx.frontendTargets)
|
||||
if len(eslintDirectiveTargets) > 0 {
|
||||
var directiveViolations []eslintDirectiveViolation
|
||||
@ -20,21 +16,16 @@ func init() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet)
|
||||
}
|
||||
fmt.Println("\n Fix: rewrite the code or typing so the directive is unnecessary.")
|
||||
rep.Fail()
|
||||
} else {
|
||||
fmt.Println(" OK: No eslint-disable-next-line comments found")
|
||||
printPerTargetOKLines(frontendTargetLabels(eslintDirectiveTargets))
|
||||
rep.Findingf("%s:%d %s", v.file, v.line, v.snippet)
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" SKIP: no frontend sources found")
|
||||
rep.OK("No eslint-disable-next-line comments found")
|
||||
}
|
||||
} else {
|
||||
rep.Skip("no frontend sources found")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 50,
|
||||
ID: "5",
|
||||
Title: "Frontend uses generated ConnectRPC hooks",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 5: Frontend API usage patterns
|
||||
fmt.Println("=== Check 5: Frontend uses generated ConnectRPC hooks ===")
|
||||
if len(ctx.frontendTargets) > 0 {
|
||||
var feViolations []frontendViolation
|
||||
var feWarnings []frontendViolation
|
||||
@ -34,33 +30,25 @@ func init() {
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
fmt.Printf(" OK: Frontend API patterns are clean")
|
||||
if len(feWarnings) > 0 {
|
||||
fmt.Printf(" (%d known non-proto fetches)", len(feWarnings))
|
||||
}
|
||||
fmt.Println()
|
||||
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
|
||||
if len(feViolations) == 0 && len(feWarnings) == 0 {
|
||||
rep.OK("Frontend API patterns are clean")
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" SKIP: no frontend sources found")
|
||||
rep.Skip("no frontend sources found")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
import "strings"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
@ -12,43 +8,34 @@ func init() {
|
||||
ID: "4",
|
||||
Title: "Frontend auto-format, lint, and typecheck",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 4: Frontend auto-fix, lint, and typecheck
|
||||
fmt.Println("=== Check 4: Frontend auto-format, lint, and typecheck ===")
|
||||
if len(ctx.frontendTargets) > 0 {
|
||||
completedTargets, skippedTargets, lintFailures, lintErr := runFrontendAutoFixAndLint(ctx.repoRoot, ctx.frontendTargets)
|
||||
if lintErr != nil {
|
||||
fmt.Printf(" ERROR: failed to run frontend lint pipeline: %v\n", lintErr)
|
||||
os.Exit(2)
|
||||
rep.Fatal("failed to run frontend lint pipeline: %v", lintErr)
|
||||
}
|
||||
|
||||
for _, skipped := range skippedTargets {
|
||||
fmt.Printf(" SKIP: %s\n", skipped)
|
||||
rep.Skip("%s", skipped)
|
||||
}
|
||||
|
||||
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 {
|
||||
fmt.Printf(" [%s] %s\n", failure.stage, failure.target)
|
||||
rep.Findingf("[%s] %s", failure.stage, failure.target)
|
||||
if failure.output != "" {
|
||||
for line := range strings.SplitSeq(failure.output, "\n") {
|
||||
fmt.Printf(" %s\n", line)
|
||||
rep.Findingf(" %s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
rep.Fail()
|
||||
} else if len(completedTargets) > 0 {
|
||||
fmt.Printf(" OK: Prettier, ESLint, and TypeScript checks clean for %d frontend target(s)\n", len(completedTargets))
|
||||
for _, target := range completedTargets {
|
||||
fmt.Printf(" - %s\n", target)
|
||||
rep.OK("Prettier, ESLint, and TypeScript checks clean for %d frontend target(s)", len(completedTargets))
|
||||
} else {
|
||||
rep.Skip("no frontend lint targets with ESLint config")
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" SKIP: no frontend lint targets with ESLint config")
|
||||
rep.Skip("no frontend sources found")
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" SKIP: no frontend sources found")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
import "strings"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
@ -12,8 +8,6 @@ func init() {
|
||||
ID: "3",
|
||||
Title: "Go code compiles and passes go fix, golangci-lint --fix, go vet, and strict lint",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 3: Go lint pipeline
|
||||
fmt.Println("=== Check 3: Go code compiles and passes go fix, golangci-lint --fix, go vet, and strict lint ===")
|
||||
goLintRoots := make([]string, 0, len(ctx.pluginTargets))
|
||||
for _, target := range ctx.pluginTargets {
|
||||
goLintRoots = append(goLintRoots, target.root)
|
||||
@ -24,33 +18,26 @@ func init() {
|
||||
}
|
||||
completedGoTargets, skippedGoTargets, goLintFailures, goLintErr := runStrictGoLint(ctx.repoRoot, backendLintRoots, goLintRoots)
|
||||
if goLintErr != nil {
|
||||
fmt.Printf(" ERROR: failed to run Go lint pipeline: %v\n", goLintErr)
|
||||
os.Exit(2)
|
||||
rep.Fatal("failed to run Go lint pipeline: %v", goLintErr)
|
||||
}
|
||||
for _, skipped := range skippedGoTargets {
|
||||
fmt.Printf(" SKIP: %s\n", skipped)
|
||||
rep.Skip("%s", skipped)
|
||||
}
|
||||
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 {
|
||||
fmt.Printf(" [%s]\n", failure.target)
|
||||
rep.Findingf("[%s]", failure.target)
|
||||
if failure.output != "" {
|
||||
for line := range strings.SplitSeq(failure.output, "\n") {
|
||||
fmt.Printf(" %s\n", line)
|
||||
rep.Findingf(" %s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
rep.Fail()
|
||||
} else if len(completedGoTargets) > 0 {
|
||||
fmt.Printf(" OK: Go lint pipeline clean for %d module(s)\n", len(completedGoTargets))
|
||||
for _, target := range completedGoTargets {
|
||||
fmt.Printf(" - %s\n", target)
|
||||
}
|
||||
rep.OK("Go lint pipeline clean for %d module(s)", len(completedGoTargets))
|
||||
} else {
|
||||
fmt.Println(" SKIP: no Go modules found")
|
||||
rep.Skip("no Go modules found")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 220,
|
||||
ID: "22",
|
||||
Title: "No hand-rolled HTML sanitization (use bluemonday)",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 22: No hand-rolled HTML sanitization (use bluemonday)
|
||||
fmt.Println("=== Check 22: No hand-rolled HTML sanitization (use bluemonday) ===")
|
||||
var htmlSanViolations []htmlSanitizeViolation
|
||||
for _, target := range ctx.backendTargets {
|
||||
for _, v := range checkHTMLSanitize(target.root) {
|
||||
@ -24,26 +20,12 @@ func init() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
fmt.Println(" OK: No hand-rolled HTML sanitization detected")
|
||||
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
|
||||
rep.OK("No hand-rolled HTML sanitization detected")
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 90,
|
||||
ID: "9",
|
||||
Title: "No npm/yarn lockfiles (pnpm only)",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 9: No npm/yarn lockfiles
|
||||
fmt.Println("=== Check 9: No npm/yarn lockfiles (pnpm only) ===")
|
||||
lockViolations := []lockfileViolation{}
|
||||
if ctx.includeCoreTargets {
|
||||
lockViolations = checkLockfiles(ctx.repoRoot)
|
||||
@ -21,18 +17,13 @@ func init() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
fmt.Println(" OK: Only pnpm lockfiles present")
|
||||
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
|
||||
rep.OK("Only pnpm lockfiles present")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
import "strings"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
@ -12,8 +8,6 @@ func init() {
|
||||
ID: "3b",
|
||||
Title: "Orchestrator backend tests",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 3b: Orchestrator backend tests
|
||||
fmt.Println("=== Check 3b: Orchestrator backend tests ===")
|
||||
var orchestratorTestTargets []goTestTarget
|
||||
if ctx.orchestratorOnly {
|
||||
orchestratorTestTargets = discoverOrchestratorTestTargetsFromRoot(orchestratorRepoRoot())
|
||||
@ -23,31 +17,24 @@ func init() {
|
||||
if len(orchestratorTestTargets) > 0 {
|
||||
completedOrchestratorTargets, orchestratorFailures, orchestratorErr := runGoTestTargets(orchestratorTestTargets)
|
||||
if orchestratorErr != nil {
|
||||
fmt.Printf(" ERROR: failed to run orchestrator tests: %v\n", orchestratorErr)
|
||||
os.Exit(2)
|
||||
rep.Fatal("failed to run orchestrator tests: %v", orchestratorErr)
|
||||
}
|
||||
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 {
|
||||
fmt.Printf(" [%s] %s\n", failure.stage, failure.target)
|
||||
rep.Findingf("[%s] %s", failure.stage, failure.target)
|
||||
if failure.output != "" {
|
||||
for line := range strings.SplitSeq(failure.output, "\n") {
|
||||
fmt.Printf(" %s\n", line)
|
||||
rep.Findingf(" %s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
rep.Fail()
|
||||
} else {
|
||||
fmt.Printf(" OK: Orchestrator tests clean for %d target(s)\n", len(completedOrchestratorTargets))
|
||||
for _, target := range completedOrchestratorTargets {
|
||||
fmt.Printf(" - %s\n", target)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" SKIP: orchestrator backend not found or not part of this scan")
|
||||
rep.OK("Orchestrator tests clean for %d target(s)", len(completedOrchestratorTargets))
|
||||
}
|
||||
} else {
|
||||
rep.Skip("orchestrator backend not found or not part of this scan")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
import "sort"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
@ -11,8 +8,6 @@ func init() {
|
||||
ID: "11",
|
||||
Title: "No placeholder code; only shipped features",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 11: No placeholder code; only shipped features
|
||||
fmt.Println("=== Check 11: No placeholder code; only shipped features ===")
|
||||
comingSoonViolations := []comingSoonViolation{}
|
||||
placeholderRoots := make([]todoScanRoot, 0, len(ctx.backendTargets)+len(ctx.frontendTargets)+len(ctx.pluginTargets))
|
||||
for _, target := range ctx.backendTargets {
|
||||
@ -48,18 +43,13 @@ func init() {
|
||||
})
|
||||
|
||||
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 {
|
||||
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 {
|
||||
fmt.Println(" OK: No placeholder code found")
|
||||
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
|
||||
rep.OK("No placeholder code found")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 107,
|
||||
@ -9,19 +7,12 @@ func init() {
|
||||
Title: "Plugin frontend discovery",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
if len(ctx.unresolvedPluginRoots) > 0 {
|
||||
fmt.Println("=== Check 10: Plugin frontend discovery ===")
|
||||
fmt.Printf(" FAIL: %d plugin root(s) could not be resolved:\n", len(ctx.unresolvedPluginRoots))
|
||||
rep.Fail("%d plugin root(s) could not be resolved", len(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 {
|
||||
fmt.Println("=== Check 10: Plugin frontend discovery ===")
|
||||
fmt.Printf(" OK: Resolved %d plugin root(s)\n", len(ctx.resolvedPluginTargets))
|
||||
printPerTargetOKLines(pluginTargetLabels(ctx.resolvedPluginTargets))
|
||||
fmt.Println()
|
||||
rep.OK("Resolved %d plugin root(s)", len(ctx.resolvedPluginTargets))
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
@ -12,8 +11,6 @@ func init() {
|
||||
ID: "21",
|
||||
Title: "Plugin presets.json validation",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 21: Plugin presets.json validation
|
||||
fmt.Println("\n=== Check 21: Plugin presets.json validation ===")
|
||||
var presetViolations []presetViolation
|
||||
presetsChecked := 0
|
||||
// Check internal plugins
|
||||
@ -50,20 +47,15 @@ func init() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
fmt.Println(" OK: No presets.json files found in scanned targets")
|
||||
rep.OK("No presets.json files found in scanned targets")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,18 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 21,
|
||||
ID: "2b",
|
||||
Title: "Plugin proto ownership",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 2b: Plugin proto ownership
|
||||
fmt.Println("=== Check 2b: Plugin proto ownership ===")
|
||||
var protoOwnershipViolations []protoOwnershipViolation
|
||||
for _, target := range ctx.backendTargets {
|
||||
if !isPluginModuleRoot(target.root) {
|
||||
@ -20,32 +13,25 @@ func init() {
|
||||
}
|
||||
violations, err := checkPluginProtoOwnership(target.root, target.displayOrRoot())
|
||||
if err != nil {
|
||||
fmt.Printf(" ERROR: failed to check proto ownership for %s: %v\n", target.displayOrRoot(), err)
|
||||
os.Exit(2)
|
||||
rep.Fatal("failed to check proto ownership for %s: %v", target.displayOrRoot(), err)
|
||||
}
|
||||
protoOwnershipViolations = append(protoOwnershipViolations, violations...)
|
||||
}
|
||||
for _, target := range ctx.pluginTargets {
|
||||
violations, err := checkPluginProtoOwnership(target.root, target.display)
|
||||
if err != nil {
|
||||
fmt.Printf(" ERROR: failed to check proto ownership for %s: %v\n", target.display, err)
|
||||
os.Exit(2)
|
||||
rep.Fatal("failed to check proto ownership for %s: %v", target.display, err)
|
||||
}
|
||||
protoOwnershipViolations = append(protoOwnershipViolations, violations...)
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
fmt.Println(" OK: Plugin proto ownership is clean")
|
||||
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
|
||||
rep.OK("Plugin proto ownership is clean")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 140,
|
||||
ID: "14",
|
||||
Title: "No raw SQL outside sqlc/Bob",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 14: No raw SQL outside sqlc/Bob
|
||||
fmt.Println("=== Check 14: No raw SQL outside sqlc/Bob ===")
|
||||
var rawSQLViolations []rawSQLViolation
|
||||
for _, target := range ctx.backendTargets {
|
||||
for _, v := range checkRawSQL(target.root) {
|
||||
@ -24,18 +20,13 @@ func init() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
fmt.Println(" OK: No raw SQL outside sqlc/Bob")
|
||||
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
|
||||
rep.OK("No raw SQL outside sqlc/Bob")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
@ -13,8 +11,6 @@ func init() {
|
||||
ID: "2",
|
||||
Title: "RPC methods registered in RBAC interceptor",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 2: RPC methods in RBAC interceptor
|
||||
fmt.Println("=== Check 2: RPC methods registered in RBAC interceptor ===")
|
||||
protoMethods := make([]string, 0)
|
||||
rbacMethods := make(map[string]string)
|
||||
protoMethodSet := make(map[string]bool)
|
||||
@ -34,8 +30,7 @@ func init() {
|
||||
summary.requiresRPCs = true
|
||||
targetProtoMethods, err := extractBufProcedures(target.protoRoot, target.allowedPackagePrefixes...)
|
||||
if err != nil {
|
||||
fmt.Printf(" ERROR: failed to parse proto files for %s: %v\n", target.displayOrRoot(), err)
|
||||
os.Exit(2)
|
||||
rep.Fatal("failed to parse proto files for %s: %v", target.displayOrRoot(), err)
|
||||
}
|
||||
summary.protoMethods = append(summary.protoMethods, targetProtoMethods...)
|
||||
for _, pm := range targetProtoMethods {
|
||||
@ -48,8 +43,7 @@ func init() {
|
||||
|
||||
targetRBACMethods, err := extractRBACMethodRoles(rbacFile)
|
||||
if err != nil {
|
||||
fmt.Printf(" ERROR: failed to parse RBAC interceptor for %s: %v\n", target.displayOrRoot(), err)
|
||||
os.Exit(2)
|
||||
rep.Fatal("failed to parse RBAC interceptor for %s: %v", target.displayOrRoot(), err)
|
||||
}
|
||||
for method, role := range targetRBACMethods {
|
||||
rbacMethods[method] = role
|
||||
@ -65,8 +59,7 @@ func init() {
|
||||
}
|
||||
targetProtoMethods, err := extractBufProcedures(target.root)
|
||||
if err != nil {
|
||||
fmt.Printf(" ERROR: failed to parse proto files for %s: %v\n", target.displayOrRoot(), err)
|
||||
os.Exit(2)
|
||||
rep.Fatal("failed to parse proto files for %s: %v", target.displayOrRoot(), err)
|
||||
}
|
||||
summary.protoMethods = append(summary.protoMethods, targetProtoMethods...)
|
||||
for _, pm := range targetProtoMethods {
|
||||
@ -79,8 +72,7 @@ func init() {
|
||||
|
||||
targetRoleMethods, err := extractPluginRoleMethods(target.root)
|
||||
if err != nil {
|
||||
fmt.Printf(" ERROR: failed to parse RBAC roles for %s: %v\n", target.displayOrRoot(), err)
|
||||
os.Exit(2)
|
||||
rep.Fatal("failed to parse RBAC roles for %s: %v", target.displayOrRoot(), err)
|
||||
}
|
||||
for method, role := range targetRoleMethods {
|
||||
rbacMethods[method] = role
|
||||
@ -96,8 +88,7 @@ func init() {
|
||||
}
|
||||
pluginProtoMethods, err := extractBufProcedures(target.root)
|
||||
if err != nil {
|
||||
fmt.Printf(" ERROR: failed to parse plugin proto files for %s: %v\n", target.display, err)
|
||||
os.Exit(2)
|
||||
rep.Fatal("failed to parse plugin proto files for %s: %v", target.display, err)
|
||||
}
|
||||
summary.protoMethods = append(summary.protoMethods, pluginProtoMethods...)
|
||||
for _, pm := range pluginProtoMethods {
|
||||
@ -110,8 +101,7 @@ func init() {
|
||||
|
||||
pluginRoleMethods, err := extractPluginRoleMethods(target.root)
|
||||
if err != nil {
|
||||
fmt.Printf(" ERROR: failed to parse plugin RBAC roles for %s: %v\n", target.display, err)
|
||||
os.Exit(2)
|
||||
rep.Fatal("failed to parse plugin RBAC roles for %s: %v", target.display, err)
|
||||
}
|
||||
for method, role := range pluginRoleMethods {
|
||||
rbacMethods[method] = role
|
||||
@ -122,10 +112,9 @@ func init() {
|
||||
|
||||
if len(protoMethods) == 0 {
|
||||
if requiresRPCDiscovery {
|
||||
fmt.Println(" ERROR: no RPC procedures found in proto sources or generated Connect files")
|
||||
os.Exit(2)
|
||||
rep.Fatal("no RPC procedures found in proto sources or generated Connect files")
|
||||
}
|
||||
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 {
|
||||
var missing []string
|
||||
for _, m := range protoMethods {
|
||||
@ -170,45 +159,41 @@ func init() {
|
||||
for _, summary := range rbacSummaries {
|
||||
if len(summary.protoMethods) == 0 {
|
||||
if summary.requiresRPCs {
|
||||
fmt.Printf(" ERROR: %s — no RPC procedures found\n", summary.label)
|
||||
rep.Fail("%s — no RPC procedures found", summary.label)
|
||||
} else {
|
||||
fmt.Printf(" OK: %s — no proto-backed RPC procedures\n", summary.label)
|
||||
rep.OK("%s — no proto-backed RPC procedures", summary.label)
|
||||
}
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" OK: %s — %d RPC methods discovered\n", summary.label, len(summary.protoMethods))
|
||||
printRoleBreakdown(countRBACRoles(summary.protoMethods, summary.roleByMethod))
|
||||
rep.OK("%s — %d RPC methods discovered", summary.label, len(summary.protoMethods))
|
||||
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 {
|
||||
fmt.Printf(" stale RBAC entries: %d\n", summary.staleCount)
|
||||
rep.Findingf("stale RBAC entries: %d", summary.staleCount)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
fmt.Printf(" - %s\n", m)
|
||||
rep.Findingf("- %s", m)
|
||||
}
|
||||
rep.Fail()
|
||||
}
|
||||
|
||||
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 {
|
||||
fmt.Printf(" - %s\n", m)
|
||||
rep.Findingf("- %s", m)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 120,
|
||||
ID: "12",
|
||||
Title: "No reinvented utilities (use helpers)",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 12: No reinvented utilities
|
||||
fmt.Println("=== Check 12: No reinvented utilities (use helpers) ===")
|
||||
var beReinvented []reinventedViolation
|
||||
for _, target := range ctx.backendTargets {
|
||||
for _, v := range checkReinventedBackend(target.root) {
|
||||
@ -32,18 +28,13 @@ func init() {
|
||||
}
|
||||
allReinvented := append(beReinvented, feReinvented...)
|
||||
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 {
|
||||
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 {
|
||||
fmt.Println(" OK: No reinvented utilities detected")
|
||||
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
|
||||
rep.OK("No reinvented utilities detected")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 130,
|
||||
ID: "13",
|
||||
Title: "RPC query/mutation error handling",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 13: RPC error handling
|
||||
fmt.Println("=== Check 13: RPC query/mutation error handling ===")
|
||||
if len(ctx.frontendTargets) > 0 {
|
||||
var rpcViolations []rpcErrorViolation
|
||||
var rpcWarnings []rpcErrorViolation
|
||||
@ -26,31 +22,21 @@ func init() {
|
||||
}
|
||||
|
||||
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 {
|
||||
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))
|
||||
rep.Findingf("%d useQuery call(s) without error destructuring (global handler covers these)", len(rpcWarnings))
|
||||
}
|
||||
|
||||
if len(rpcViolations) == 0 {
|
||||
fmt.Printf(" OK: All mutations have error handling")
|
||||
if len(rpcWarnings) > 0 {
|
||||
fmt.Printf(" (%d queries rely on global error handler)", len(rpcWarnings))
|
||||
}
|
||||
fmt.Println()
|
||||
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
|
||||
} else if len(rpcWarnings) > 0 {
|
||||
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 {
|
||||
fmt.Println(" SKIP: no frontend sources found")
|
||||
rep.Skip("no frontend sources found")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,18 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 22,
|
||||
ID: "2c",
|
||||
Title: "Standalone plugin SDK import boundaries",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 2c: Standalone plugin SDK import boundaries
|
||||
fmt.Println("=== Check 2c: Standalone plugin SDK import boundaries ===")
|
||||
var cmsGoModViolations []pluginGoModViolation
|
||||
var pluginImportViolations []pluginImportViolation
|
||||
var pluginGoModViolations []pluginGoModViolation
|
||||
@ -20,8 +13,7 @@ func init() {
|
||||
checkedStandalonePluginRoots := make(map[string]bool)
|
||||
requiredSDKVersion, requiredSDKVersionErr := currentCMSCoreSDKVersion()
|
||||
if requiredSDKVersionErr != nil {
|
||||
fmt.Printf(" ERROR: failed to resolve CMS SDK version: %v\n", requiredSDKVersionErr)
|
||||
os.Exit(2)
|
||||
rep.Fatal("failed to resolve CMS SDK version: %v", requiredSDKVersionErr)
|
||||
}
|
||||
checkStandalonePluginRoot := func(root string, label string) {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
fmt.Printf(" OK: Standalone plugin imports and go.mod stay on SDK version %s\n", requiredSDKVersion)
|
||||
printPerTargetOKLines(standalonePluginLabels)
|
||||
if ctx.includeCoreTargets {
|
||||
fmt.Printf(" - %s/go.mod does not locally replace %s\n", ctx.backendTargets[0].displayOrRoot(), blockCoreImportPrefix)
|
||||
}
|
||||
rep.OK("Standalone plugin imports and go.mod stay on SDK version %s", requiredSDKVersion)
|
||||
} 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 {
|
||||
fmt.Println(" SKIP: no standalone plugin roots scanned")
|
||||
rep.Skip("no standalone plugin roots scanned")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,22 +1,17 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 180,
|
||||
ID: "18",
|
||||
Title: "Plugin segmentation (safety-rules.yml)",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 18: Plugin segmentation (safety-rules.yml)
|
||||
fmt.Println("=== Check 18: Plugin segmentation (safety-rules.yml) ===")
|
||||
var segViolations []segmentationViolation
|
||||
segPluginsChecked := 0
|
||||
for _, target := range ctx.resolvedPluginTargets {
|
||||
rules, loadErr := loadSafetyRules(target.root)
|
||||
if loadErr != nil {
|
||||
fmt.Printf(" ERROR: %s: %v\n", target.display, loadErr)
|
||||
rep.Fail()
|
||||
rep.Fail("%s: %v", target.display, loadErr)
|
||||
continue
|
||||
}
|
||||
if rules == nil || rules.Segmentation == nil {
|
||||
@ -27,25 +22,18 @@ func init() {
|
||||
segViolations = append(segViolations, vs...)
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
fmt.Printf(" OK: Plugin segmentation clean (%d plugin(s) checked)\n", segPluginsChecked)
|
||||
for _, target := range ctx.resolvedPluginTargets {
|
||||
rules, _ := loadSafetyRules(target.root)
|
||||
if rules != nil && rules.Segmentation != nil {
|
||||
fmt.Printf(" OK: %s\n", target.display)
|
||||
}
|
||||
}
|
||||
rep.OK("Plugin segmentation clean (%d plugin(s) checked)", segPluginsChecked)
|
||||
} else {
|
||||
fmt.Println(" OK: No plugins define safety-rules.yml segmentation rules")
|
||||
rep.OK("No plugins define safety-rules.yml segmentation rules")
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 23,
|
||||
ID: "2d",
|
||||
Title: "sqlc UUID overrides in plugins and cmd",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 2d: sqlc UUID overrides in plugins and cmd
|
||||
fmt.Println("=== Check 2d: sqlc UUID overrides in plugins and cmd ===")
|
||||
var sqlcUUIDViolations []sqlcUUIDViolation
|
||||
var sqlcScanLabels []string
|
||||
for _, target := range ctx.backendTargets {
|
||||
@ -34,20 +30,15 @@ func init() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
fmt.Println(" OK: sqlc UUID overrides use github.com/google/uuid")
|
||||
printPerTargetOKLines(sqlcScanLabels)
|
||||
rep.OK("sqlc UUID overrides use github.com/google/uuid")
|
||||
} else {
|
||||
fmt.Println(" SKIP: no plugin or cmd sqlc configs found")
|
||||
rep.Skip("no plugin or cmd sqlc configs found")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 160,
|
||||
ID: "16",
|
||||
Title: "Services and handlers in correct directories",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 16: Services/handlers in correct directories
|
||||
fmt.Println("=== Check 16: Services and handlers in correct directories ===")
|
||||
var structViolations []structureViolation
|
||||
for _, target := range ctx.backendTargets {
|
||||
for _, v := range checkServiceStructure(target.root) {
|
||||
@ -24,17 +20,13 @@ func init() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
fmt.Println(" OK: All services and handlers in correct directories")
|
||||
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
|
||||
rep.OK("All services and handlers in correct directories")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 70,
|
||||
ID: "7",
|
||||
Title: "No useState for tab state in routes (use URL ?tab= params)",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 7: No useState for tab state in route files
|
||||
fmt.Println("=== Check 7: No useState for tab state in routes (use URL ?tab= params) ===")
|
||||
if len(ctx.frontendTargets) > 0 {
|
||||
var tabViolations []tabStateViolation
|
||||
for _, target := range ctx.frontendTargets {
|
||||
@ -19,21 +15,16 @@ func init() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
fmt.Printf(" %s:%d %s\n", v.file, v.line, v.snippet)
|
||||
}
|
||||
fmt.Println("\n Fix: Use URL search params (?tab=) for bookmarkability. See settings.tsx for reference.")
|
||||
rep.Fail()
|
||||
} else {
|
||||
fmt.Println(" OK: No useState tab state in routes")
|
||||
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
|
||||
rep.Findingf("%s:%d %s", v.file, v.line, v.snippet)
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" SKIP: no frontend sources found")
|
||||
rep.OK("No useState tab state in routes")
|
||||
}
|
||||
} else {
|
||||
rep.Skip("no frontend sources found")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
)
|
||||
import "path/filepath"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
@ -11,8 +8,6 @@ func init() {
|
||||
ID: "20",
|
||||
Title: "Tailwind v4 configuration (PostCSS, CSS directives, @config)",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 20: Tailwind v4 configuration
|
||||
fmt.Println("\n=== Check 20: Tailwind v4 configuration (PostCSS, CSS directives, @config) ===")
|
||||
if len(ctx.frontendTargets) > 0 {
|
||||
var twViolations []tailwindViolation
|
||||
for _, target := range ctx.frontendTargets {
|
||||
@ -25,26 +20,19 @@ func init() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
fmt.Println(" OK: Tailwind v4 configuration is correct")
|
||||
printPerTargetOKLines(frontendTargetLabels(ctx.frontendTargets))
|
||||
rep.OK("Tailwind v4 configuration is correct")
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" SKIP: no frontend sources found")
|
||||
rep.Skip("no frontend sources found")
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@ -1,15 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 170,
|
||||
ID: "17",
|
||||
Title: "No TODO markers in production code",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
// Check 17: No TODO markers in production code
|
||||
fmt.Println("=== Check 17: No TODO markers in production code ===")
|
||||
todoRoots := make([]todoScanRoot, 0, len(ctx.backendTargets)+len(ctx.frontendTargets)+len(ctx.pluginTargets))
|
||||
for _, target := range ctx.backendTargets {
|
||||
todoRoots = append(todoRoots, todoScanRoot{
|
||||
@ -31,18 +27,13 @@ func init() {
|
||||
}
|
||||
todoViolations := checkTODOs(todoRoots)
|
||||
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 {
|
||||
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 {
|
||||
fmt.Println(" OK: No TODO markers found in production code")
|
||||
printPerTargetOKLines(pluginTargetLabels(ctx.pluginTargets))
|
||||
rep.OK("No TODO markers found in production code")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
package main
|
||||
|
||||
import "fmt"
|
||||
|
||||
func init() {
|
||||
register(Check{
|
||||
Seq: 280,
|
||||
ID: "28",
|
||||
Title: "Public page handlers inject admin toolbar data",
|
||||
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||
fmt.Println("=== Check 28: Public page handlers inject admin toolbar data ===")
|
||||
var violations []toolbarViolation
|
||||
for _, target := range ctx.backendTargets {
|
||||
for _, v := range checkToolbarInjection(target.root) {
|
||||
@ -17,21 +14,13 @@ func init() {
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
fmt.Println(" OK: All public page handlers inject admin toolbar data")
|
||||
rep.OK("All public page handlers inject admin toolbar data")
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
147
colors_format.go
147
colors_format.go
@ -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")
|
||||
}
|
||||
@ -3,7 +3,6 @@ package main
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@ -132,44 +131,3 @@ func TestCheckNinjaTplColorsAllowsCSSVariablesAndNeutralOverlays(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
43
lint_test.go
43
lint_test.go
@ -24,7 +24,7 @@ func TestCheckSafetySkipsRPCCheckForPluginWithoutRPCs(t *testing.T) {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("go", "run", ".", pluginRoot)
|
||||
cmd := exec.Command("go", "run", ".", pluginRoot, "--verbose")
|
||||
cmd.Dir = cwd
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@ -32,10 +32,10 @@ func TestCheckSafetySkipsRPCCheckForPluginWithoutRPCs(t *testing.T) {
|
||||
}
|
||||
|
||||
got := string(output)
|
||||
if !strings.Contains(got, "=== Check 2: RPC methods registered in RBAC interceptor ===") {
|
||||
t.Fatalf("output missing Check 2 header:\n%s", got)
|
||||
if !strings.Contains(got, "SKIP 2 ") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -66,7 +66,7 @@ func TestCheckSafetyTypechecksExternalPluginFrontendWithWorkspaceDeps(t *testing
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("go", "run", ".", pluginRoot)
|
||||
cmd := exec.Command("go", "run", ".", pluginRoot, "--verbose")
|
||||
cmd.Dir = cwd
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@ -74,10 +74,7 @@ func TestCheckSafetyTypechecksExternalPluginFrontendWithWorkspaceDeps(t *testing
|
||||
}
|
||||
|
||||
got := string(output)
|
||||
if !strings.Contains(got, "=== Check 4: Frontend auto-format, lint, and typecheck ===") {
|
||||
t.Fatalf("output missing Check 4 header:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "OK: Prettier, ESLint, and TypeScript checks clean for 1 frontend target") {
|
||||
if !strings.Contains(got, "Prettier, ESLint, and TypeScript checks clean for 1 frontend target") {
|
||||
t.Fatalf("output missing successful frontend typecheck result:\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "Cannot find module '@block-ninja/ui'") {
|
||||
@ -110,15 +107,12 @@ func Example() string {
|
||||
}
|
||||
|
||||
got := string(output)
|
||||
if !strings.Contains(got, "=== Check 2c: Standalone plugin SDK import boundaries ===") {
|
||||
t.Fatalf("output missing Check 2c header:\n%s", got)
|
||||
if !strings.Contains(got, "FAIL 2c ") {
|
||||
t.Fatalf("output missing Check 2c fail line:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "imports BlockNinja CMS package") {
|
||||
t.Fatalf("output missing forbidden BlockNinja import message:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "use git.dev.alexdunmow.com/block/core") {
|
||||
t.Fatalf("output missing remediation text:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckSafetyFailsStandalonePluginReplaceDirective(t *testing.T) {
|
||||
@ -144,9 +138,6 @@ func TestCheckSafetyFailsStandalonePluginReplaceDirective(t *testing.T) {
|
||||
}
|
||||
|
||||
got := string(output)
|
||||
if !strings.Contains(got, "no replace directives") {
|
||||
t.Fatalf("output missing no replace guidance:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "replace directive present") {
|
||||
t.Fatalf("output missing replace violation detail:\n%s", got)
|
||||
}
|
||||
@ -189,8 +180,8 @@ sql:
|
||||
}
|
||||
|
||||
got := string(output)
|
||||
if !strings.Contains(got, "=== Check 2d: sqlc UUID overrides in plugins and cmd ===") {
|
||||
t.Fatalf("output missing Check 2d header:\n%s", got)
|
||||
if !strings.Contains(got, "FAIL 2d ") {
|
||||
t.Fatalf("output missing Check 2d fail line:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "github.com/google/uuid.UUID") {
|
||||
t.Fatalf("output missing UUID remediation:\n%s", got)
|
||||
@ -220,7 +211,9 @@ type Payload struct {
|
||||
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
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
@ -228,10 +221,10 @@ type Payload struct {
|
||||
}
|
||||
|
||||
got := string(output)
|
||||
if !strings.Contains(got, "=== Check 2e: Warn on any usage in Go and TypeScript ===") {
|
||||
t.Fatalf("output missing Check 2e header:\n%s", got)
|
||||
if !strings.Contains(got, "WARN 2e ") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@ -269,8 +262,8 @@ sql:
|
||||
}
|
||||
|
||||
got := string(output)
|
||||
if !strings.Contains(got, "=== Check 2f: sqlc compile and buf generate ===") {
|
||||
t.Fatalf("output missing Check 2f header:\n%s", got)
|
||||
if !strings.Contains(got, "FAIL 2f ") {
|
||||
t.Fatalf("output missing Check 2f fail line:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "[sqlc compile]") {
|
||||
t.Fatalf("output missing sqlc compile stage:\n%s", got)
|
||||
|
||||
18
main.go
18
main.go
@ -86,12 +86,15 @@ const maxAnyWarningsPrinted = 120
|
||||
|
||||
func main() {
|
||||
ctx := buildScanContext()
|
||||
rep := &Reporter{}
|
||||
rep := &Reporter{verbose: ctx.verbose}
|
||||
// Checks self-register via init(); run them in declared Seq order.
|
||||
sort.Slice(registry, func(i, j int) bool { return registry[i].Seq < registry[j].Seq })
|
||||
for i := range registry {
|
||||
rep.begin(registry[i].Seq, registry[i].ID, registry[i].Title)
|
||||
registry[i].Run(ctx, rep)
|
||||
rep.end()
|
||||
}
|
||||
rep.render()
|
||||
|
||||
os.Exit(rep.exitCode)
|
||||
}
|
||||
@ -101,6 +104,8 @@ func buildScanContext() *ScanContext {
|
||||
var pluginPageDirs []string
|
||||
var pluginRoots []string
|
||||
orchestratorOnly := false
|
||||
allAny := false
|
||||
verbose := false
|
||||
|
||||
// Parse args: first positional arg is backendDir. Plugin frontend paths may be passed
|
||||
// as direct source dirs via --plugin-pages or as plugin roots via --plugin-dir.
|
||||
@ -108,6 +113,10 @@ func buildScanContext() *ScanContext {
|
||||
for i := 0; i < len(args); i++ {
|
||||
if args[i] == "--orchestrator" {
|
||||
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" {
|
||||
// Consume all following args until next flag or end
|
||||
for i++; i < len(args) && !strings.HasPrefix(args[i], "--"); i++ {
|
||||
@ -161,9 +170,9 @@ func buildScanContext() *ScanContext {
|
||||
scanPath = targetDir
|
||||
}
|
||||
if orchestratorOnly {
|
||||
fmt.Printf("(running checks in %s [orchestrator only])\n", repoRoot)
|
||||
fmt.Printf("check-safety %s [orchestrator only]\n", repoRoot)
|
||||
} else {
|
||||
fmt.Printf("(running checks in %s)\n", scanPath)
|
||||
fmt.Printf("check-safety %s\n", scanPath)
|
||||
}
|
||||
if !orchestratorOnly && len(pluginRoots) == 0 && len(pluginPageDirs) == 0 {
|
||||
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()
|
||||
|
||||
includeCoreTargets := shouldIncludeCoreTargets(targetDir, backendDir, pluginRoots, pluginPageDirs)
|
||||
backendTargets := collectBackendScanTargets(repoRoot, backendDir, includeCoreTargets)
|
||||
@ -186,6 +194,8 @@ func buildScanContext() *ScanContext {
|
||||
backendDir: backendDir,
|
||||
orchestratorOnly: orchestratorOnly,
|
||||
includeCoreTargets: includeCoreTargets,
|
||||
allAny: allAny,
|
||||
verbose: verbose,
|
||||
backendTargets: backendTargets,
|
||||
pluginTargets: pluginTargets,
|
||||
resolvedPluginTargets: resolvedPluginTargets,
|
||||
|
||||
42
pathutil.go
42
pathutil.go
@ -1,11 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@ -113,43 +111,3 @@ func isPluginModuleRoot(root string) bool {
|
||||
func containsString(values []string, target string) bool {
|
||||
return slices.Contains(values, target)
|
||||
}
|
||||
|
||||
func uniqueSortedStrings(values []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, value := range values {
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func printPerTargetOKLines(labels []string) {
|
||||
labels = uniqueSortedStrings(labels)
|
||||
for _, label := range labels {
|
||||
if label == "" || label == "." {
|
||||
continue
|
||||
}
|
||||
fmt.Printf(" OK: %s\n", label)
|
||||
}
|
||||
}
|
||||
|
||||
func frontendTargetLabels(targets []frontendScanTarget) []string {
|
||||
labels := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
labels = append(labels, target.label)
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
func pluginTargetLabels(targets []pluginScanTarget) []string {
|
||||
labels := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
labels = append(labels, target.display)
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
@ -721,21 +721,6 @@ func isExcludedServiceMethod(method string) bool {
|
||||
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) {
|
||||
result := protoFreshnessResult{}
|
||||
if !samePath(repoRoot, blockNinjaRepoRoot()) {
|
||||
@ -781,26 +766,3 @@ func protoGeneratedStatus(repoRoot string) (string, error) {
|
||||
args = append(args, paths...)
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
13
rawsql.go
13
rawsql.go
@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
@ -120,15 +119,3 @@ func checkRawSQL(root string) []rawSQLViolation {
|
||||
|
||||
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.`)
|
||||
}
|
||||
|
||||
199
registry.go
199
registry.go
@ -1,5 +1,11 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ScanContext holds all resolved scan state, built once before any check runs.
|
||||
// It is read-only to checks.
|
||||
type ScanContext struct {
|
||||
@ -7,6 +13,8 @@ type ScanContext struct {
|
||||
backendDir string
|
||||
orchestratorOnly 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
|
||||
pluginTargets []pluginScanTarget // filtered
|
||||
resolvedPluginTargets []pluginScanTarget // pre-filter
|
||||
@ -15,16 +23,193 @@ type ScanContext struct {
|
||||
pluginPageDirs []string
|
||||
}
|
||||
|
||||
// Reporter accumulates the process exit code across checks.
|
||||
type Reporter struct {
|
||||
exitCode int
|
||||
// status is a check's verdict. Ordered by severity so it can only escalate.
|
||||
// statusUnset is the default before any verdict; the first call to any
|
||||
// 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).
|
||||
func (r *Reporter) Fail() { r.exitCode = 1 }
|
||||
// checkReport accumulates one check's verdict and detail lines.
|
||||
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
|
||||
// and calls rep.Fail() on violations, exactly as the original inline block did.
|
||||
// Reporter collects every check's report and renders them once at the end.
|
||||
// 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 {
|
||||
Seq int
|
||||
ID string
|
||||
|
||||
@ -354,30 +354,3 @@ func checkReinventedFrontend(webSrcDir string) []reinventedViolation {
|
||||
|
||||
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 │
|
||||
└────────────────────────────────────────┴────────────────────────────────────────┘`)
|
||||
}
|
||||
|
||||
25
rpcerrors.go
25
rpcerrors.go
@ -1,7 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
@ -142,27 +141,3 @@ func hasGlobalMutationErrorHandler(webSrcDir string) bool {
|
||||
content := string(data)
|
||||
return strings.Contains(content, "MutationCache") && strings.Contains(content, "onError")
|
||||
}
|
||||
|
||||
func printRPCErrorHelp() {
|
||||
fmt.Println(`
|
||||
RPC error handling patterns:
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ Global handlers in App.tsx catch unhandled errors automatically: │
|
||||
│ • QueryCache.onError → toasts on refetch failures │
|
||||
│ • MutationCache.onError → toasts when no onError at call site │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ For custom error UI, destructure 'error' from useQuery: │
|
||||
│ │
|
||||
│ const { data, isLoading, error } = useQuery(myQuery, input) │
|
||||
│ if (error) return <ErrorState message={error.message} /> │
|
||||
├──────────────────────────────────────────────────────────────────────┤
|
||||
│ For mutations, add onError or use try/catch: │
|
||||
│ │
|
||||
│ mutation.mutate(data, { │
|
||||
│ onSuccess: () => toast.success('Saved'), │
|
||||
│ onError: (err) => toast.error('Failed', { │
|
||||
│ description: err.message │
|
||||
│ }), │
|
||||
│ }) │
|
||||
└──────────────────────────────────────────────────────────────────────┘`)
|
||||
}
|
||||
|
||||
94
testdata/golden/clean/expected.stdout
vendored
94
testdata/golden/clean/expected.stdout
vendored
@ -1,92 +1,2 @@
|
||||
(running checks in FIXTURE_DIR)
|
||||
|
||||
=== Check 1: Secret env var reads outside config.Load() ===
|
||||
OK: No secret env var reads outside config.Load()
|
||||
|
||||
=== Check 2: RPC methods registered in RBAC interceptor ===
|
||||
SKIP: no proto-backed RPC procedures found in scanned target(s)
|
||||
|
||||
=== Check 2b: Plugin proto ownership ===
|
||||
OK: Plugin proto ownership is clean
|
||||
|
||||
=== Check 2c: Standalone plugin SDK import boundaries ===
|
||||
OK: FIXTURE_DIR/go.mod does not locally replace git.dev.alexdunmow.com/block/core
|
||||
|
||||
=== Check 2d: sqlc UUID overrides in plugins and cmd ===
|
||||
SKIP: no plugin or cmd sqlc configs found
|
||||
|
||||
=== Check 2e: Warn on any usage in Go and TypeScript ===
|
||||
OK: No any usage found in scanned Go or TypeScript sources
|
||||
|
||||
=== Check 2f: sqlc compile and buf generate ===
|
||||
SKIP: no sqlc or buf codegen targets found
|
||||
|
||||
=== Check 3: Go code compiles and passes go fix, golangci-lint --fix, go vet, and strict lint ===
|
||||
OK: Go lint pipeline clean for 1 module(s)
|
||||
- .
|
||||
|
||||
=== Check 3b: Orchestrator backend tests ===
|
||||
SKIP: orchestrator backend not found or not part of this scan
|
||||
|
||||
=== Check 4: Frontend auto-format, lint, and typecheck ===
|
||||
SKIP: no frontend sources found
|
||||
|
||||
=== Check 5: Frontend uses generated ConnectRPC hooks ===
|
||||
SKIP: no frontend sources found
|
||||
|
||||
=== Check 6: No hardcoded colors in frontend (use theme tokens) ===
|
||||
OK: No hardcoded colors in .templ files
|
||||
|
||||
=== Check 7: No useState for tab state in routes (use URL ?tab= params) ===
|
||||
SKIP: no frontend sources found
|
||||
|
||||
=== Check 8: Import @block-ninja/api from subpaths only ===
|
||||
SKIP: no frontend sources found
|
||||
|
||||
=== Check 9: No npm/yarn lockfiles (pnpm only) ===
|
||||
OK: Only pnpm lockfiles present
|
||||
|
||||
=== Check 10: Button automation attributes ===
|
||||
SKIP: no frontend sources found
|
||||
|
||||
=== Check 10b: No eslint-disable-next-line comments ===
|
||||
SKIP: no frontend sources found
|
||||
|
||||
=== Check 11: No placeholder code; only shipped features ===
|
||||
OK: No placeholder code found
|
||||
|
||||
=== Check 12: No reinvented utilities (use helpers) ===
|
||||
OK: No reinvented utilities detected
|
||||
|
||||
=== Check 13: RPC query/mutation error handling ===
|
||||
SKIP: no frontend sources found
|
||||
|
||||
=== Check 14: No raw SQL outside sqlc/Bob ===
|
||||
OK: No raw SQL outside sqlc/Bob
|
||||
|
||||
=== Check 15: No err.Error() leaked to HTTP clients ===
|
||||
OK: No err.Error() leaked to HTTP clients
|
||||
|
||||
=== Check 16: Services and handlers in correct directories ===
|
||||
OK: All services and handlers in correct directories
|
||||
|
||||
=== Check 17: No TODO markers in production code ===
|
||||
OK: No TODO markers found in production code
|
||||
|
||||
=== Check 18: Plugin segmentation (safety-rules.yml) ===
|
||||
OK: No plugins define safety-rules.yml segmentation rules
|
||||
|
||||
=== Check 19: Bearer token precedence over cookies in auth middleware ===
|
||||
OK: Bearer token takes precedence over cookies in all auth extraction
|
||||
|
||||
=== Check 20: Tailwind v4 configuration (PostCSS, CSS directives, @config) ===
|
||||
SKIP: no frontend sources found
|
||||
|
||||
=== Check 21: Plugin presets.json validation ===
|
||||
OK: No presets.json files found in scanned targets
|
||||
|
||||
=== Check 22: No hand-rolled HTML sanitization (use bluemonday) ===
|
||||
OK: No hand-rolled HTML sanitization detected
|
||||
=== Check 28: Public page handlers inject admin toolbar data ===
|
||||
OK: All public page handlers inject admin toolbar data
|
||||
|
||||
check-safety FIXTURE_DIR
|
||||
32 checks: 20 ok 12 skip -> OK
|
||||
|
||||
116
testdata/golden/nomod/expected.stdout
vendored
116
testdata/golden/nomod/expected.stdout
vendored
@ -1,113 +1,13 @@
|
||||
(running checks in FIXTURE_DIR)
|
||||
|
||||
=== Check 1: Secret env var reads outside config.Load() ===
|
||||
FAIL: internal/service/handler.go:17 — os.Getenv("JWT_SECRET")
|
||||
|
||||
1 violation(s). Secrets must flow through Config → DI.
|
||||
|
||||
=== Check 2: RPC methods registered in RBAC interceptor ===
|
||||
SKIP: no proto-backed RPC procedures found in scanned target(s)
|
||||
|
||||
=== Check 2b: Plugin proto ownership ===
|
||||
OK: Plugin proto ownership is clean
|
||||
|
||||
=== Check 2c: Standalone plugin SDK import boundaries ===
|
||||
FAIL: 1 CMS backend go.mod violation(s):
|
||||
check-safety FIXTURE_DIR
|
||||
FAIL 1 1 secret env var read(s) outside config.Load() — secrets must flow through Config → DI
|
||||
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
|
||||
|
||||
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:
|
||||
FAIL 14 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:
|
||||
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
|
||||
|
||||
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:
|
||||
FAIL 17 1 TODO marker(s) found — ship explicit behavior, not placeholders
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user