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 }