check-safety/segmentation.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

297 lines
8.1 KiB
Go

package main
import (
"bufio"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"gopkg.in/yaml.v3"
)
// SafetyRules represents the safety-rules.yml schema for a plugin.
type SafetyRules struct {
Segmentation *SegmentationRules `yaml:"segmentation"`
}
// SegmentationRules defines cross-plugin isolation constraints.
type SegmentationRules struct {
NoGoImportsFrom []string `yaml:"no_go_imports_from"`
NoFrontendImportsFrom []string `yaml:"no_frontend_imports_from"`
MigrationTablePrefix string `yaml:"migration_table_prefix"`
RequireFiles []string `yaml:"require_files"`
FederationName string `yaml:"federation_name"`
}
type segmentationViolation struct {
pluginName string
rule string
file string
line int
detail string
}
// loadSafetyRules reads safety-rules.yml from a plugin root. Returns nil if the file does not exist.
func loadSafetyRules(pluginRoot string) (*SafetyRules, error) {
path := filepath.Join(pluginRoot, "safety-rules.yml")
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read %s: %w", path, err)
}
var rules SafetyRules
if err := yaml.Unmarshal(data, &rules); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return &rules, nil
}
// checkSegmentation runs all segmentation rules for a plugin and returns violations.
func checkSegmentation(pluginRoot, pluginName string, rules *SegmentationRules) []segmentationViolation {
var violations []segmentationViolation
// Rule: no_go_imports_from
if len(rules.NoGoImportsFrom) > 0 {
violations = append(violations, checkNoGoImports(pluginRoot, pluginName, rules.NoGoImportsFrom)...)
}
// Rule: no_frontend_imports_from
if len(rules.NoFrontendImportsFrom) > 0 {
violations = append(violations, checkNoFrontendImports(pluginRoot, pluginName, rules.NoFrontendImportsFrom)...)
}
// Rule: migration_table_prefix
if rules.MigrationTablePrefix != "" {
violations = append(violations, checkMigrationFKs(pluginRoot, pluginName, rules.MigrationTablePrefix)...)
}
// Rule: require_files
for _, reqFile := range rules.RequireFiles {
fullPath := filepath.Join(pluginRoot, reqFile)
if !fileExists(fullPath) {
violations = append(violations, segmentationViolation{
pluginName: pluginName,
rule: "require_files",
file: reqFile,
detail: fmt.Sprintf("required file %s does not exist", reqFile),
})
}
}
// Rule: federation_name
if rules.FederationName != "" {
violations = append(violations, checkFederationName(pluginRoot, pluginName, rules.FederationName)...)
}
return violations
}
// checkNoGoImports scans Go source files for references to forbidden plugin names.
func checkNoGoImports(pluginRoot, pluginName string, forbidden []string) []segmentationViolation {
var violations []segmentationViolation
_ = filepath.Walk(pluginRoot, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
// Skip non-Go, test files, vendor, .git, web directory
if info.IsDir() {
base := filepath.Base(path)
if base == ".git" || base == "vendor" || base == "web" || base == "node_modules" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
rel, _ := filepath.Rel(pluginRoot, path)
f, openErr := os.Open(path)
if openErr != nil {
return nil
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
for _, name := range forbidden {
if strings.Contains(line, name) {
violations = append(violations, segmentationViolation{
pluginName: pluginName,
rule: "no_go_imports_from",
file: rel,
line: lineNum,
detail: fmt.Sprintf("references forbidden plugin %q", name),
})
}
}
}
return nil
})
return violations
}
// checkNoFrontendImports scans TS/TSX files for references to forbidden plugin names.
func checkNoFrontendImports(pluginRoot, pluginName string, forbidden []string) []segmentationViolation {
var violations []segmentationViolation
webDir := filepath.Join(pluginRoot, "web")
if !dirExists(webDir) {
return nil
}
_ = filepath.Walk(webDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
base := filepath.Base(path)
if base == "node_modules" || base == "dist" || base == ".git" {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".ts") && !strings.HasSuffix(path, ".tsx") {
return nil
}
// Skip generated files
if strings.Contains(path, "/generated/") {
return nil
}
rel, _ := filepath.Rel(pluginRoot, path)
f, openErr := os.Open(path)
if openErr != nil {
return nil
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
for _, name := range forbidden {
if strings.Contains(line, name) {
violations = append(violations, segmentationViolation{
pluginName: pluginName,
rule: "no_frontend_imports_from",
file: rel,
line: lineNum,
detail: fmt.Sprintf("references forbidden plugin %q", name),
})
}
}
}
return nil
})
return violations
}
// checkMigrationFKs scans migration SQL for REFERENCES to tables outside the allowed prefix.
func checkMigrationFKs(pluginRoot, pluginName, tablePrefix string) []segmentationViolation {
var violations []segmentationViolation
migrationsDir := filepath.Join(pluginRoot, "migrations")
if !dirExists(migrationsDir) {
return nil
}
// Match REFERENCES <table_name>
refRe := regexp.MustCompile(`(?i)REFERENCES\s+(\w+)`)
_ = filepath.Walk(migrationsDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".sql") {
return nil
}
// Skip down migrations
if strings.Contains(filepath.Base(path), "_down") {
return nil
}
rel, _ := filepath.Rel(pluginRoot, path)
f, openErr := os.Open(path)
if openErr != nil {
return nil
}
defer func() { _ = f.Close() }()
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
// Skip down-migration blocks
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "--") {
continue
}
matches := refRe.FindAllStringSubmatch(line, -1)
for _, match := range matches {
tableName := match[1]
// Allow references to own tables and public_users (common cross-reference)
if strings.HasPrefix(tableName, tablePrefix) || tableName == "public_users" {
continue
}
violations = append(violations, segmentationViolation{
pluginName: pluginName,
rule: "migration_table_prefix",
file: rel,
line: lineNum,
detail: fmt.Sprintf("FK references table %q which does not have prefix %q", tableName, tablePrefix),
})
}
}
return nil
})
return violations
}
// checkFederationName verifies the vite.config.ts federation name matches the expected value.
func checkFederationName(pluginRoot, pluginName, expectedName string) []segmentationViolation {
viteConfig := filepath.Join(pluginRoot, "web", "vite.config.ts")
if !fileExists(viteConfig) {
return nil // require_files check handles missing vite config
}
data, err := os.ReadFile(viteConfig)
if err != nil {
return nil
}
// Look for name: "messenger" or name: 'messenger' in federation config
nameRe := regexp.MustCompile(`name:\s*["'](\w+)["']`)
matches := nameRe.FindSubmatch(data)
if len(matches) < 2 {
return []segmentationViolation{{
pluginName: pluginName,
rule: "federation_name",
file: "web/vite.config.ts",
detail: "could not find federation name declaration",
}}
}
actualName := string(matches[1])
if actualName != expectedName {
return []segmentationViolation{{
pluginName: pluginName,
rule: "federation_name",
file: "web/vite.config.ts",
detail: fmt.Sprintf("federation name is %q, expected %q", actualName, expectedName),
}}
}
return nil
}