check-safety/plugin_sdk_versions.go
Alex Dunmow cd88c808b0 initial: standalone check-safety module hoisted from CMS
Static safety/lint runner for the BlockNinja codebase. ~25 invariant
checks across Go and frontend sources. Was at git.dev.alexdunmow.com:block/ninja
in backend/cmd/check-safety/ until the 2026-06-06 consolidation moved
the BlockNinja repos under a shared ~/src/blockninja/ parent.

This repo is the standalone extraction:
- Own go.mod (git.dev.alexdunmow.com/block/check-safety, go 1.26.4)
- Vendored internal/{helpers,theme} from CMS (Go's internal/ rule
  blocks cross-module imports; vendoring is the workaround)
- CLI contract unchanged: `check-safety <target-dir> [--flags]`
- CMS Makefile shells into ../check-safety for safety-check /
  install-safety-checker targets

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 13:04:02 +08:00

162 lines
4.2 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"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)
}
func checkStandalonePluginGoMod(root, requiredSDKVersion string) []pluginGoModViolation {
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
}