check-safety/targets.go
Alex Dunmow 2c7fe9aa62 fix: model orchestrator + cli as first-class scan targets (WO-WZ-023)
- resolveScanRoots recognizes the orchestrator layout (backend/go.mod +
  frontend/src) so backendDir resolves to backend/ — fixes the go.mod check,
  classifies the frontend as a standalone app (not a plugin), and routes tsc
  through the plain typecheck. Clears three orchestrator false-positives.
- check 2 (RBAC): only require MethodRoles for proto services a target actually
  serves (mounted connect handler). The orchestrator carries but does not serve
  the blockninja.v1 surface (714 false 'missing' -> 0). Exclude cli
  (definitions-only, vendors a registry client) from check 2.
- Skip buf-generated gen/ output in the placeholder check (real proto enum
  COMING_SOON values are not TODO debt).
- Normalize the color allowlist path so theme-card.tsx swatch previews match
  regardless of scan root.
- Whitelist the gitignored, tool-regenerated styles/package-lock.json.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 22:39:47 +08:00

393 lines
11 KiB
Go

package main
import (
"os"
"path/filepath"
"sort"
)
type pluginScanTarget struct {
root string
display string
frontendDir string
frontendLabel string
}
type backendScanTarget struct {
root string
display string
protoRoot string
allowedPackagePrefixes []string
}
type frontendScanTarget struct {
dir string
label string
pluginRules bool
managedConfigs bool
}
type rbacTargetSummary struct {
label string
// protoMethods is the SERVED procedure surface (filtered to services the
// target actually mounts) — it drives the missing-RBAC (permission_denied)
// finding.
protoMethods []string
// protoMethodsAll is the FULL declared procedure surface (unfiltered) — it
// drives stale-entry detection, so an RBAC role for a real-but-unmounted
// proto method is not reported as stale.
protoMethodsAll []string
roleByMethod map[string]string
missingCount int
staleCount int
requiresRPCs bool
}
func (t backendScanTarget) displayOrRoot() string {
if t.display != "" {
return t.display
}
return t.root
}
func resolveScanRoots(target string) (backendDir string, repoRoot string, err error) {
absTarget, err := filepath.Abs(target)
if err != nil {
return "", "", err
}
if dirExists(filepath.Join(absTarget, "backend", "cmd", "check-safety")) {
return filepath.Join(absTarget, "backend"), absTarget, nil
}
// Consolidated CMS layout — Go module at backend/, frontend at web/src.
// The clause above keys on backend/cmd/check-safety, which left the CMS
// tree when check-safety was hoisted to its own repo; without this one
// the CMS resolves as its own backend, web/ is classified as a plugin
// frontend, and the strict plugin rules bypass allowedFrontendFiles.
if fileExists(filepath.Join(absTarget, "backend", "go.mod")) && dirExists(filepath.Join(absTarget, "web", "src")) {
return filepath.Join(absTarget, "backend"), absTarget, nil
}
// Orchestrator layout — same standalone-app shape as the CMS above, but the
// frontend lives at frontend/src (not web/src) and proto/buf sit at the repo
// root. Without this clause the orchestrator root resolves as its own backend
// (backendDir == repoRoot): backend/go.mod is reported missing, and
// frontend/src is treated as a plugin frontend so the plugin-only rules fire
// on a first-class app.
if fileExists(filepath.Join(absTarget, "backend", "go.mod")) && dirExists(filepath.Join(absTarget, "frontend", "src")) &&
(fileExists(filepath.Join(absTarget, "buf.yaml")) || dirExists(filepath.Join(absTarget, "proto"))) {
return filepath.Join(absTarget, "backend"), absTarget, nil
}
if filepath.Base(absTarget) == "backend" && fileExists(filepath.Join(absTarget, "go.mod")) {
parent := filepath.Clean(filepath.Join(absTarget, ".."))
if fileExists(filepath.Join(parent, "buf.yaml")) || dirExists(filepath.Join(parent, "proto")) {
return absTarget, parent, nil
}
}
if dirExists(filepath.Join(absTarget, "cmd", "check-safety")) {
parent := filepath.Clean(filepath.Join(absTarget, ".."))
if fileExists(filepath.Join(parent, "buf.yaml")) {
return absTarget, parent, nil
}
return absTarget, absTarget, nil
}
return absTarget, absTarget, nil
}
func defaultScanTargetDir(targetDir string, pluginRoots []string, cwd string) string {
if len(pluginRoots) > 0 || targetDir != "." {
return targetDir
}
if fileExists(filepath.Join(cwd, "plugin.mod")) {
return cwd
}
return filepath.Join(blockNinjaRepoRoot(), "backend")
}
func resolvePluginTargets(repoRoot string, roots []string) (targets []pluginScanTarget, unresolved []string) {
seenRoots := make(map[string]bool)
seenFrontends := make(map[string]bool)
for _, root := range roots {
resolvedRoot, err := expandPath(root)
if err != nil {
unresolved = append(unresolved, shortPluginLabel(root))
continue
}
absRoot, err := filepath.Abs(resolvedRoot)
if err != nil {
unresolved = append(unresolved, shortPluginLabel(root))
continue
}
info, err := os.Stat(absRoot)
if err != nil || !info.IsDir() {
unresolved = append(unresolved, shortPluginLabel(root))
continue
}
if seenRoots[absRoot] {
continue
}
seenRoots[absRoot] = true
target := pluginScanTarget{
root: absRoot,
display: shortPluginLabel(absRoot),
}
if frontendDir, ok := findPluginFrontendDir(absRoot); ok {
if !seenFrontends[frontendDir] {
target.frontendDir = frontendDir
target.frontendLabel = target.display
seenFrontends[frontendDir] = true
}
}
targets = append(targets, target)
}
sort.Slice(targets, func(i, j int) bool {
return targets[i].display < targets[j].display
})
return targets, unresolved
}
func collectBackendScanTargets(repoRoot, backendDir string, includeCoreTargets bool) []backendScanTarget {
var targets []backendScanTarget
seen := make(map[string]bool)
if !includeCoreTargets {
return nil
}
addTarget := func(root string, allowedPrefixes []string) {
if root == "" || seen[root] || !dirExists(root) {
return
}
seen[root] = true
targets = append(targets, backendScanTarget{
root: root,
display: normalizeDisplayLabel(displayLintPath(repoRoot, root)),
protoRoot: resolveProtoScanRoot(root),
allowedPackagePrefixes: allowedPrefixes,
})
}
addTarget(backendDir, inferAllowedPackagePrefixes(repoRoot, backendDir))
sort.Slice(targets, func(i, j int) bool {
return targets[i].displayOrRoot() < targets[j].displayOrRoot()
})
return targets
}
func filterPluginTargets(targets []pluginScanTarget, backendDir string) []pluginScanTarget {
filtered := make([]pluginScanTarget, 0, len(targets))
for _, target := range targets {
if samePath(target.root, backendDir) {
continue
}
filtered = append(filtered, target)
}
return filtered
}
func collectFrontendScanTargets(repoRoot, backendDir string, pluginTargets []pluginScanTarget, includeCoreTargets bool) []frontendScanTarget {
var targets []frontendScanTarget
seen := make(map[string]bool)
addTarget := func(dir, label string, pluginRules bool, managedConfigs bool) {
if dir == "" || seen[dir] || !dirExists(dir) {
return
}
seen[dir] = true
targets = append(targets, frontendScanTarget{
dir: dir,
label: normalizeDisplayLabel(label),
pluginRules: pluginRules,
managedConfigs: managedConfigs,
})
}
if includeCoreTargets && samePath(backendDir, filepath.Join(repoRoot, "backend")) {
addTarget(filepath.Join(repoRoot, "web", "src"), "web/src", false, false)
addTarget(filepath.Join(orchestratorRepoRoot(), "frontend", "src"), "orchestrator/frontend/src", false, true)
for _, pluginWebDir := range discoverInternalPluginWebDirs(backendDir) {
rel, _ := filepath.Rel(repoRoot, pluginWebDir)
addTarget(pluginWebDir, rel, true, true)
}
} else if includeCoreTargets {
if frontendDir, ok := findPluginFrontendDir(backendDir); ok {
addTarget(frontendDir, displayLintPath(repoRoot, frontendDir), true, true)
}
}
for _, target := range pluginTargets {
if target.frontendDir == "" {
continue
}
addTarget(target.frontendDir, target.frontendLabel, true, true)
}
sort.Slice(targets, func(i, j int) bool {
return targets[i].label < targets[j].label
})
return targets
}
func shouldIncludeCoreTargets(targetDir, backendDir string, pluginRoots []string, pluginPageDirs []string) bool {
if len(pluginRoots) == 0 && len(pluginPageDirs) == 0 {
return true
}
blockNinjaRepo := blockNinjaRepoRoot()
if samePath(targetDir, blockNinjaRepo) || samePath(targetDir, filepath.Join(blockNinjaRepo, "backend")) {
return false
}
if samePath(backendDir, filepath.Join(blockNinjaRepo, "backend")) &&
(samePath(targetDir, filepath.Join(blockNinjaRepo, "backend")) || samePath(targetDir, blockNinjaRepo)) {
return false
}
return true
}
func appendManualFrontendTargets(repoRoot string, targets []frontendScanTarget, dirs []string) []frontendScanTarget {
if len(dirs) == 0 {
return targets
}
seen := make(map[string]bool, len(targets))
for _, target := range targets {
if target.dir == "" {
continue
}
absDir, err := filepath.Abs(target.dir)
if err != nil {
continue
}
seen[absDir] = true
}
for _, dir := range dirs {
absDir, err := filepath.Abs(dir)
if err != nil || seen[absDir] {
continue
}
seen[absDir] = true
targets = append(targets, frontendScanTarget{
dir: absDir,
label: normalizeDisplayLabel(displayLintPath(repoRoot, absDir)),
pluginRules: true,
managedConfigs: true,
})
}
sort.Slice(targets, func(i, j int) bool {
return targets[i].label < targets[j].label
})
return targets
}
func collectESLintDirectiveTargets(repoRoot string, frontendTargets []frontendScanTarget) []frontendScanTarget {
var targets []frontendScanTarget
seen := make(map[string]bool)
addTarget := func(dir, label string) {
if dir == "" || !dirExists(dir) {
return
}
absDir, err := filepath.Abs(dir)
if err != nil || seen[absDir] {
return
}
seen[absDir] = true
targets = append(targets, frontendScanTarget{
dir: absDir,
label: normalizeDisplayLabel(label),
})
}
for _, target := range frontendTargets {
addTarget(target.dir, target.label)
}
if samePath(repoRoot, blockNinjaRepoRoot()) {
addTarget(filepath.Join(repoRoot, "packages", "ui", "src"), "packages/ui/src")
}
sort.Slice(targets, func(i, j int) bool {
return targets[i].label < targets[j].label
})
return targets
}
func discoverInternalPluginWebDirs(backendDir string) []string {
pluginsDir := filepath.Join(backendDir, "internal", "plugins")
entries, err := os.ReadDir(pluginsDir)
if err != nil {
return nil
}
var dirs []string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
webDir := filepath.Join(pluginsDir, entry.Name(), "web")
if dirLooksLikeFrontendSource(webDir) {
abs, err := filepath.Abs(webDir)
if err != nil {
continue
}
dirs = append(dirs, abs)
}
}
return dirs
}
func findPluginFrontendDir(root string) (string, bool) {
for _, candidate := range []string{
root,
filepath.Join(root, "src"),
filepath.Join(root, "web"),
filepath.Join(root, "web", "src"),
filepath.Join(root, "frontend"),
filepath.Join(root, "frontend", "src"),
} {
if !dirLooksLikeFrontendSource(candidate) {
continue
}
absCandidate, err := filepath.Abs(candidate)
if err != nil {
continue
}
return absCandidate, true
}
return "", false
}
func inferAllowedPackagePrefixes(repoRoot, backendDir string) []string {
switch {
case samePath(repoRoot, orchestratorRepoRoot()):
return []string{"orchestrator."}
case samePath(backendDir, filepath.Join(repoRoot, "backend")):
return []string{"blockninja."}
default:
return nil
}
}
func resolveProtoScanRoot(backendDir string) string {
parent := filepath.Clean(filepath.Join(backendDir, ".."))
if fileExists(filepath.Join(parent, "buf.yaml")) || dirExists(filepath.Join(parent, "proto")) {
return parent
}
return backendDir
}