- 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>
393 lines
11 KiB
Go
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
|
|
}
|