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 == ".worktrees" || 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" || base == ".worktrees" { 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 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 }