diff --git a/check_sdkboundaries.go b/check_sdkboundaries.go index 22eaae9..c92febe 100644 --- a/check_sdkboundaries.go +++ b/check_sdkboundaries.go @@ -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 { diff --git a/lint_test.go b/lint_test.go index 2056804..2d734ad 100644 --- a/lint_test.go +++ b/lint_test.go @@ -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) } } diff --git a/main_test.go b/main_test.go index 24014d4..c58b943 100644 --- a/main_test.go +++ b/main_test.go @@ -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() diff --git a/plugin_imports.go b/plugin_imports.go index 4c31c14..b686f28 100644 --- a/plugin_imports.go +++ b/plugin_imports.go @@ -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 } diff --git a/plugin_imports_test.go b/plugin_imports_test.go index c2fb913..7dab6ec 100644 --- a/plugin_imports_test.go +++ b/plugin_imports_test.go @@ -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) } } diff --git a/targets.go b/targets.go index 2f01581..e9c7dc7 100644 --- a/targets.go +++ b/targets.go @@ -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 }