feat(2c): forbid all first-party imports in standalone plugins; fix --plugin-dir-only scans

Import boundary now covers the whole first-party tree, not just block/cms:
block/core and block/orchestrator imports are violations too, with an
explicit carve-out for the packages that deliberately stayed core after
the pluginsdk extraction (core/captcha, core/backup — calcomblock uses
captcha today). The .templ import scan gets the same rule.

defaultScanTargetDir: --plugin-dir with no positional target used to
leave targetDir at cwd, so the checker scanned its own repo and
self-reported failures while never scanning the plugin. It now targets
the first plugin root, matching the positional form.

Verified green across cms + all 11 v0.2.1 fleet repos (bidmasters still
fails the version anchor as expected — it pins v0.2.0 on a detached
HEAD, unrelated to these changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-07-07 21:48:01 +08:00
parent 338c41c3c8
commit 0749c22d30
6 changed files with 130 additions and 17 deletions

View File

@ -60,7 +60,7 @@ func init() {
if len(pluginImportViolations) > 0 {
rep.Fail("%d standalone plugin import violation(s)", len(pluginImportViolations))
for _, v := range pluginImportViolations {
rep.Findingf("%s:%d imports BlockNinja CMS package %q", v.file, v.line, v.importPath)
rep.Findingf("%s:%d imports first-party BlockNinja package %q (plugins may only use block/pluginsdk)", v.file, v.line, v.importPath)
}
}
if len(pluginGoModViolations) > 0 {

View File

@ -104,7 +104,7 @@ func Example() string {
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") {
if !strings.Contains(got, "imports first-party BlockNinja package") {
t.Fatalf("output missing forbidden BlockNinja import message:\n%s", got)
}
}

View File

@ -20,6 +20,26 @@ func TestDefaultScanTargetDirUsesCWDPlugin(t *testing.T) {
}
}
func TestDefaultScanTargetDirUsesPluginRootWhenOnlyPluginDirGiven(t *testing.T) {
cwd := t.TempDir() // no plugin.mod — simulates running from the check-safety repo itself
pluginRoot := t.TempDir()
got := defaultScanTargetDir(".", []string{pluginRoot}, cwd)
if got != pluginRoot {
t.Fatalf("defaultScanTargetDir() = %q, want plugin root %q", got, pluginRoot)
}
}
func TestDefaultScanTargetDirKeepsExplicitTargetOverPluginRoots(t *testing.T) {
cwd := t.TempDir()
pluginRoot := t.TempDir()
got := defaultScanTargetDir("/some/backend", []string{pluginRoot}, cwd)
if got != "/some/backend" {
t.Fatalf("defaultScanTargetDir() = %q, want explicit target /some/backend", got)
}
}
func TestDefaultScanTargetDirFallsBackToCoreBackend(t *testing.T) {
cwd := t.TempDir()

View File

@ -11,11 +11,43 @@ import (
)
const (
blockCoreImportPrefix = "git.dev.alexdunmow.com/block/core"
pluginSDKImportPrefix = "git.dev.alexdunmow.com/block/pluginsdk"
blockNinjaImportPrefix = "git.dev.alexdunmow.com/block/cms"
blockCoreImportPrefix = "git.dev.alexdunmow.com/block/core"
pluginSDKImportPrefix = "git.dev.alexdunmow.com/block/pluginsdk"
blockNinjaImportPrefix = "git.dev.alexdunmow.com/block/cms"
orchestratorImportPrefix = "git.dev.alexdunmow.com/block/orchestrator"
firstPartyImportPrefix = "git.dev.alexdunmow.com/block/"
)
// allowedCorePluginImports are the block/core packages that deliberately
// stayed on core after the pluginsdk extraction (2026-07-07) and remain
// plugin-usable; everything else first-party is host-only.
var allowedCorePluginImports = []string{
blockCoreImportPrefix + "/captcha",
blockCoreImportPrefix + "/backup",
}
func hasImportPrefix(importPath, prefix string) bool {
return importPath == prefix || strings.HasPrefix(importPath, prefix+"/")
}
// isForbiddenPluginImport reports whether a standalone plugin may not import
// the package: all BlockNinja first-party Go code (cms, orchestrator, core)
// is off-limits except the plugin SDK and the explicitly kept core packages.
func isForbiddenPluginImport(importPath string) bool {
if hasImportPrefix(importPath, blockNinjaImportPrefix) || hasImportPrefix(importPath, orchestratorImportPrefix) {
return true
}
if hasImportPrefix(importPath, blockCoreImportPrefix) {
for _, allowed := range allowedCorePluginImports {
if hasImportPrefix(importPath, allowed) {
return false
}
}
return true
}
return false
}
type pluginImportViolation struct {
file string
line int
@ -74,13 +106,13 @@ func checkStandalonePluginImports(root string) []pluginImportViolation {
}
lines := strings.Split(string(data), "\n")
for i, line := range lines {
if !strings.Contains(line, blockNinjaImportPrefix) {
if !strings.Contains(line, firstPartyImportPrefix) {
continue
}
matches := templImportPattern.FindAllStringSubmatch(line, -1)
for _, match := range matches {
importPath := match[1]
if !strings.HasPrefix(importPath, blockNinjaImportPrefix) {
if !isForbiddenPluginImport(importPath) {
continue
}
violations = append(violations, pluginImportViolation{
@ -108,7 +140,7 @@ func checkStandalonePluginImports(root string) []pluginImportViolation {
if err != nil {
continue
}
if !strings.HasPrefix(importPath, blockNinjaImportPrefix) {
if !isForbiddenPluginImport(importPath) {
continue
}

View File

@ -11,7 +11,7 @@ func TestCheckStandalonePluginImportsFlagsBlockNinjaCMSImports(t *testing.T) {
writeTestFile(t, filepath.Join(root, "main.go"), `package example
import (
"git.dev.alexdunmow.com/block/core/plugin"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"git.dev.alexdunmow.com/block/cms/internal/helpers"
)
@ -30,7 +30,7 @@ func Example() {
}
}
func TestCheckStandalonePluginImportsAllowsCoreSDKImports(t *testing.T) {
func TestCheckStandalonePluginImportsFlagsCoreImports(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
writeTestFile(t, filepath.Join(root, "main.go"), `package example
@ -40,6 +40,35 @@ import "git.dev.alexdunmow.com/block/core/plugin"
func Example() {
_ = plugin.PluginRegistration{}
}
`, 0644)
violations := checkStandalonePluginImports(root)
if len(violations) != 1 {
t.Fatalf("checkStandalonePluginImports() returned %d violations, want 1: %#v", len(violations), violations)
}
if violations[0].importPath != "git.dev.alexdunmow.com/block/core/plugin" {
t.Fatalf("importPath = %q, want forbidden core import", violations[0].importPath)
}
}
func TestCheckStandalonePluginImportsAllowsSDKAndKeptCoreImports(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
writeTestFile(t, filepath.Join(root, "main.go"), `package example
import (
"git.dev.alexdunmow.com/block/core/backup"
"git.dev.alexdunmow.com/block/core/captcha"
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
"git.dev.alexdunmow.com/block/pluginsdk/render"
)
func Example() {
_ = plugin.PluginRegistration{}
_ = captcha.Config{}
_ = backup.Config{}
_ = render.BlockNoteToHTML
}
`, 0644)
violations := checkStandalonePluginImports(root)
@ -48,6 +77,27 @@ func Example() {
}
}
func TestCheckStandalonePluginImportsFlagsOrchestratorImports(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
writeTestFile(t, filepath.Join(root, "main.go"), `package example
import "git.dev.alexdunmow.com/block/orchestrator/backend/internal/services"
func Example() {
_ = services.InstanceService{}
}
`, 0644)
violations := checkStandalonePluginImports(root)
if len(violations) != 1 {
t.Fatalf("checkStandalonePluginImports() returned %d violations, want 1: %#v", len(violations), violations)
}
if violations[0].importPath != "git.dev.alexdunmow.com/block/orchestrator/backend/internal/services" {
t.Fatalf("importPath = %q, want forbidden orchestrator import", violations[0].importPath)
}
}
func TestCheckStandalonePluginImportsFlagsBlockNinjaCMSImportsInTemplFiles(t *testing.T) {
root := t.TempDir()
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
@ -64,13 +114,16 @@ templ Page() {
`, 0644)
violations := checkStandalonePluginImports(root)
if len(violations) != 1 {
t.Fatalf("checkStandalonePluginImports() returned %d violations, want 1: %#v", len(violations), violations)
if len(violations) != 2 {
t.Fatalf("checkStandalonePluginImports() returned %d violations, want 2: %#v", len(violations), violations)
}
if violations[0].importPath != "git.dev.alexdunmow.com/block/cms/internal/templates" {
t.Fatalf("importPath = %q, want forbidden BlockNinja templ import", violations[0].importPath)
if violations[0].importPath != "git.dev.alexdunmow.com/block/core/templates/bn" {
t.Fatalf("importPath[0] = %q, want forbidden core templ import", violations[0].importPath)
}
if violations[0].line != 5 {
t.Fatalf("line = %d, want 5", violations[0].line)
if violations[1].importPath != "git.dev.alexdunmow.com/block/cms/internal/templates" {
t.Fatalf("importPath[1] = %q, want forbidden BlockNinja templ import", violations[1].importPath)
}
if violations[1].line != 5 {
t.Fatalf("line = %d, want 5", violations[1].line)
}
}

View File

@ -99,9 +99,17 @@ func resolveScanRoots(target string) (backendDir string, repoRoot string, err er
}
func defaultScanTargetDir(targetDir string, pluginRoots []string, cwd string) string {
if len(pluginRoots) > 0 || targetDir != "." {
if targetDir != "." {
return targetDir
}
// --plugin-dir with no positional target means "scan that plugin", not
// "scan the cwd" (which is usually the check-safety repo itself).
if len(pluginRoots) > 0 {
if expanded, err := expandPath(pluginRoots[0]); err == nil {
return expanded
}
return pluginRoots[0]
}
if fileExists(filepath.Join(cwd, "plugin.mod")) {
return cwd
}