check-safety/plugin_sdk_versions.go
Alex Dunmow e03144e12d feat: codeless plugin repos (plugin.mod, no Go) exempt from go.mod checks (WO-WZ-020)
A codeless .bnp repo has no go.mod by design — mirror the ninja CLI
classifier (core bnp.IsCodelessRepo) and skip both the standalone-plugin
go.mod checks and the backend replace-directive parse for such roots.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 12:43:54 +08:00

186 lines
4.8 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"golang.org/x/mod/modfile"
)
type pluginGoModViolation struct {
file string
line int
rule string
detail string
}
func currentCMSCoreSDKVersion() (string, error) {
return readRequiredModuleVersion(filepath.Join(blockNinjaRepoRoot(), "backend", "go.mod"), blockCoreImportPrefix)
}
func parseGoModForSafety(goModPath string) (*modfile.File, []pluginGoModViolation) {
if !fileExists(goModPath) {
return nil, []pluginGoModViolation{{
file: filepath.Base(goModPath),
rule: "missing-go-mod",
detail: fmt.Sprintf("missing %s", filepath.Base(goModPath)),
}}
}
data, err := os.ReadFile(goModPath)
if err != nil {
return nil, []pluginGoModViolation{{
file: filepath.Base(goModPath),
rule: "read-go-mod",
detail: fmt.Sprintf("failed to read go.mod: %v", err),
}}
}
parsed, err := modfile.Parse(goModPath, data, nil)
if err != nil {
return nil, []pluginGoModViolation{{
file: filepath.Base(goModPath),
rule: "parse-go-mod",
detail: fmt.Sprintf("failed to parse go.mod: %v", err),
}}
}
return parsed, nil
}
func newGoModReplaceViolation(parsed *modfile.File, replace *modfile.Replace, rule string) pluginGoModViolation {
detail := fmt.Sprintf("replace directive present for %s", replace.Old.Path)
if replace.New.Path != "" {
detail += fmt.Sprintf(" -> %s", replace.New.Path)
}
if replace.New.Version != "" {
detail += fmt.Sprintf(" %s", replace.New.Version)
}
line := 0
if replace.Syntax != nil {
line = replace.Syntax.Start.Line
}
return pluginGoModViolation{
file: filepath.Base(parsed.Syntax.Name),
line: line,
rule: rule,
detail: detail,
}
}
func checkGoModForAnyReplaceDirectives(goModPath string) []pluginGoModViolation {
parsed, violations := parseGoModForSafety(goModPath)
if parsed == nil {
return violations
}
for _, replace := range parsed.Replace {
violations = append(violations, newGoModReplaceViolation(parsed, replace, "no-replace-directives"))
}
return violations
}
func checkGoModForModuleReplaceDirective(goModPath, modulePath string) []pluginGoModViolation {
parsed, violations := parseGoModForSafety(goModPath)
if parsed == nil {
return violations
}
for _, replace := range parsed.Replace {
if replace.Old.Path != modulePath {
continue
}
violations = append(violations, newGoModReplaceViolation(parsed, replace, "no-local-block-core-replace"))
}
return violations
}
func readRequiredModuleVersion(goModPath, modulePath string) (string, error) {
data, err := os.ReadFile(goModPath)
if err != nil {
return "", err
}
parsed, err := modfile.Parse(goModPath, data, nil)
if err != nil {
return "", err
}
for _, req := range parsed.Require {
if req.Mod.Path == modulePath {
return req.Mod.Version, nil
}
}
return "", fmt.Errorf("%s does not require %s", goModPath, modulePath)
}
func checkCMSCoreSDKGoMod(root string) []pluginGoModViolation {
return checkGoModForModuleReplaceDirective(filepath.Join(root, "go.mod"), blockCoreImportPrefix)
}
// isCodelessPluginRepo mirrors the ninja CLI classifier (core
// cmd/ninja/internal/bnp.IsCodelessRepo): a plugin.mod with no Go source at
// the repo root is a codeless plugin (WO-WZ-020) — declarative-only, no
// go.mod and no SDK version to pin.
func isCodelessPluginRepo(root string) bool {
if !fileExists(filepath.Join(root, "plugin.mod")) {
return false
}
entries, err := os.ReadDir(root)
if err != nil {
return false
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".go") {
return false
}
}
return true
}
func checkStandalonePluginGoMod(root, requiredSDKVersion string) []pluginGoModViolation {
if isCodelessPluginRepo(root) {
return nil
}
goModPath := filepath.Join(root, "go.mod")
parsed, violations := parseGoModForSafety(goModPath)
if parsed == nil {
if len(violations) == 1 && violations[0].rule == "missing-go-mod" {
violations[0].detail = "standalone plugins must be standalone Go modules with a repo-root go.mod"
}
return violations
}
violations = append(violations, checkGoModForAnyReplaceDirectives(goModPath)...)
currentSDKVersion := ""
for _, req := range parsed.Require {
if req.Mod.Path == blockCoreImportPrefix {
currentSDKVersion = req.Mod.Version
break
}
}
if currentSDKVersion == "" {
violations = append(violations, pluginGoModViolation{
file: "go.mod",
rule: "missing-block-core-require",
detail: fmt.Sprintf("missing required module %s", blockCoreImportPrefix),
})
return violations
}
if requiredSDKVersion != "" && currentSDKVersion != requiredSDKVersion {
violations = append(violations, pluginGoModViolation{
file: "go.mod",
rule: "block-core-version-mismatch",
detail: fmt.Sprintf("requires %s, want %s", currentSDKVersion, requiredSDKVersion),
})
}
return violations
}