claw-code/.guardrails/mcp-server/internal/validation/game_validator.go

378 lines
11 KiB
Go

package validation
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/thearchitectit/guardrail-mcp/internal/models"
)
// GameEngineDetector detects game engine projects
type GameEngineDetector struct{}
// DetectResult contains the detected engine and project path
type DetectResult struct {
Engine models.GameEngine
ProjectPath string
ConfigFile string
}
// DetectGameEngine scans a directory for game engine project files
func (d *GameEngineDetector) DetectGameEngine(repoPath string) (*DetectResult, error) {
entries, err := os.ReadDir(repoPath)
if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
for _, entry := range entries {
if entry.IsDir() {
continue
}
switch entry.Name() {
case "project.godot":
return &DetectResult{
Engine: models.EngineGodot,
ProjectPath: repoPath,
ConfigFile: entry.Name(),
}, nil
}
}
// Check subdirectories (common in monorepos: game/ or godot/ subdirs)
subdirs := []string{"game", "godot", "src", "project"}
for _, subdir := range subdirs {
subPath := filepath.Join(repoPath, subdir)
if info, err := os.Stat(subPath); err == nil && info.IsDir() {
if result, _ := d.DetectGameEngine(subPath); result != nil {
return result, nil
}
}
}
return nil, nil
}
// GodotValidator validates Godot projects using headless mode
type GodotValidator struct {
GodotPath string // Path to Godot binary
Timeout time.Duration
}
// NewGodotValidator creates a new Godot validator
func NewGodotValidator(godotPath string) *GodotValidator {
if godotPath == "" {
godotPath = "godot" // Rely on PATH
}
return &GodotValidator{
GodotPath: godotPath,
Timeout: 60 * time.Second,
}
}
var (
// Regex patterns for parsing Godot headless output
reTestPass = regexp.MustCompile(`✅\s*PASS:\s*(.+)`)
reTestFail = regexp.MustCompile(`❌\s*FAIL:\s*(.+)`)
reScriptError = regexp.MustCompile(`SCRIPT ERROR:\s*(.+?)(?:\n|$)`)
reParseError = regexp.MustCompile(`Parse Error:\s*(.+?)(?:\n|$)`)
reSceneError = regexp.MustCompile(`Error loading resource:\s*(.+?)(?:\n|$)`)
)
// ValidateProject runs headless Godot validation on a project
func (v *GodotValidator) ValidateProject(ctx context.Context, projectPath string) (*models.GameBuildResult, error) {
start := time.Now()
result := &models.GameBuildResult{
Engine: models.EngineGodot,
ProjectPath: projectPath,
Status: models.BuildPassed,
ValidatedAt: time.Now(),
}
// Step 1: Validate project.godot exists and parses
if err := v.validateProjectFile(projectPath, result); err != nil {
result.DurationMs = time.Since(start).Milliseconds()
result.Status = models.BuildErrored
}
// Step 2: Run headless script check (validates .gd files parse)
if err := v.runHeadlessCheck(ctx, projectPath, result); err != nil {
result.DurationMs = time.Since(start).Milliseconds()
return result, err
}
// Step 3: Run test script if available
v.runHeadlessTests(ctx, projectPath, result)
result.DurationMs = time.Since(start).Milliseconds()
if result.TestsFailed > 0 {
result.Status = models.BuildFailed
}
return result, nil
}
// validateProjectFile checks that project.godot exists
func (v *GodotValidator) validateProjectFile(projectPath string, result *models.GameBuildResult) error {
projFile := filepath.Join(projectPath, "project.godot")
if _, err := os.Stat(projFile); os.IsNotExist(err) {
result.Status = models.BuildErrored
result.Errors = append(result.Errors, models.BuildError{
Type: models.GameErrorExport,
File: "project.godot",
Message: "project.godot not found",
Severity: "error",
})
return fmt.Errorf("project.godot not found in %s", projectPath)
}
return nil
}
// runHeadlessCheck runs Godot in headless mode to validate scripts and scenes
func (v *GodotValidator) runHeadlessCheck(ctx context.Context, projectPath string, result *models.GameBuildResult) error {
checkCtx, cancel := context.WithTimeout(ctx, v.Timeout)
defer cancel()
cmd := exec.CommandContext(checkCtx, v.GodotPath,
"--headless",
"--path", projectPath,
"--quit-after", "100", // Quit after 100 frames
)
output, err := cmd.CombinedOutput()
outputStr := string(output)
// Parse script errors
for _, match := range reScriptError.FindAllStringSubmatch(outputStr, -1) {
result.Errors = append(result.Errors, models.BuildError{
Type: models.GameErrorScript,
Message: match[1],
Severity: "error",
})
}
// Parse parse errors
for _, match := range reParseError.FindAllStringSubmatch(outputStr, -1) {
result.Errors = append(result.Errors, models.BuildError{
Type: models.GameErrorScript,
Message: match[1],
Severity: "error",
})
}
// Parse scene errors
for _, match := range reSceneError.FindAllStringSubmatch(outputStr, -1) {
result.Errors = append(result.Errors, models.BuildError{
Type: models.GameErrorScene,
Message: match[1],
Severity: "error",
})
}
if err != nil && len(result.Errors) == 0 {
// Command failed but no parseable errors — might be timeout or missing binary
return fmt.Errorf("godot headless check failed: %w (output: %s)", err, truncate(outputStr, 500))
}
return nil
}
// runHeadlessTests runs a Godot headless test script if one exists
func (v *GodotValidator) runHeadlessTests(ctx context.Context, projectPath string, result *models.GameBuildResult) {
// Look for common test script names
testScripts := []string{
"test_runner.gd",
"tests/test_runner.gd",
"test_pong_headless.gd", // Legacy from playgodot-pong
}
var testScript string
for _, script := range testScripts {
if _, err := os.Stat(filepath.Join(projectPath, script)); err == nil {
testScript = script
break
}
}
if testScript == "" {
// No test script found — skip tests
result.TestsRun = 0
return
}
testCtx, cancel := context.WithTimeout(ctx, v.Timeout)
defer cancel()
cmd := exec.CommandContext(testCtx, v.GodotPath,
"--headless",
"--path", projectPath,
"--script", testScript,
)
output, _ := cmd.CombinedOutput()
outputStr := string(output)
// Parse test results
scanner := bufio.NewScanner(strings.NewReader(outputStr))
for scanner.Scan() {
line := scanner.Text()
if reTestPass.MatchString(line) {
result.TestsRun++
result.TestsPassed++
} else if reTestFail.MatchString(line) {
result.TestsRun++
result.TestsFailed++
matches := reTestFail.FindStringSubmatch(line)
if len(matches) > 1 {
result.Errors = append(result.Errors, models.BuildError{
Type: models.GameErrorTest,
Message: matches[1],
Severity: "error",
})
}
}
}
}
// ValidateSceneFiles scans for .tscn files and validates their basic structure
func (v *GodotValidator) ValidateSceneFiles(projectPath string, result *models.GameBuildResult) error {
return filepath.Walk(projectPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() || !strings.HasSuffix(path, ".tscn") {
return nil
}
// Basic validation: check file starts with [gd_scene or [gd_resource
data, err := os.ReadFile(path)
if err != nil {
result.Errors = append(result.Errors, models.BuildError{
Type: models.GameErrorScene,
File: path,
Message: fmt.Sprintf("Cannot read scene file: %v", err),
Severity: "error",
})
return nil
}
content := strings.TrimSpace(string(data))
if !strings.HasPrefix(content, "[gd_scene") && !strings.HasPrefix(content, "[gd_resource") {
result.Errors = append(result.Errors, models.BuildError{
Type: models.GameErrorScene,
File: path,
Message: "Invalid scene file: missing [gd_scene or [gd_resource header",
Severity: "error",
})
}
return nil
})
}
// ValidateGDScripts scans for .gd files and checks for syntax issues
// Implements guardrails from Sentinel Profile - GDScript - Godot
func (v *GodotValidator) ValidateGDScripts(projectPath string, result *models.GameBuildResult) error {
return filepath.Walk(projectPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() || !strings.HasSuffix(path, ".gd") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
result.Errors = append(result.Errors, models.BuildError{
Type: models.GameErrorScript,
File: path,
Message: fmt.Sprintf("Cannot read script file: %v", err),
Severity: "error",
})
return nil
}
content := string(data)
// Check 1: Empty script file
if len(content) == 0 {
result.Errors = append(result.Errors, models.BuildError{
Type: models.GameErrorScript,
File: path,
Message: "Empty script file",
Severity: "warning",
})
return nil
}
// Check 2: Missing 'extends' declaration (GDScript Mandate rule)
if !strings.Contains(content, "extends ") && !strings.Contains(content, "@tool") {
result.Errors = append(result.Errors, models.BuildError{
Type: models.GameErrorScript,
File: path,
Message: "Script missing 'extends' declaration",
Severity: "warning",
})
}
// Check 3: Direct .free() on nodes (use queue_free instead)
if strings.Contains(content, ".free()") && !strings.Contains(content, "queue_free") {
result.Errors = append(result.Errors, models.BuildError{
Type: models.GameErrorScript,
File: path,
Message: "Direct .free() detected — use queue_free() for safe deferred deletion",
Severity: "warning",
})
}
// Check 4: String-based get_node() (fragile, use @onready)
if strings.Contains(content, "get_node(\"") || strings.Contains(content, "get_node('") {
result.Errors = append(result.Errors, models.BuildError{
Type: models.GameErrorScript,
File: path,
Message: "String-based get_node() detected — prefer @onready with $ or % syntax",
Severity: "warning",
})
}
// Check 5: Heavy operations in _process (file I/O, HTTP)
processFuncs := []string{"_process", "_physics_process"}
for _, fn := range processFuncs {
if idx := strings.Index(content, "func "+fn); idx >= 0 {
// Find end of function (rough heuristic: next 'func' or end of file)
funcBody := content[idx:]
if nextFunc := strings.Index(funcBody[10:], "func "); nextFunc > 0 {
funcBody = funcBody[:nextFunc+10]
}
heavyOps := []string{"FileAccess", "HTTPRequest.request", "load(", "ResourceLoader.load"}
for _, op := range heavyOps {
if strings.Contains(funcBody, op) {
result.Errors = append(result.Errors, models.BuildError{
Type: models.GameErrorScript,
File: path,
Message: fmt.Sprintf("Heavy operation '%s' in %s — move to _ready() or background thread", op, fn),
Severity: "warning",
})
}
}
}
}
return nil
})
}
// truncate truncates a string to maxLen characters
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}