integrate.go raw

   1  // Package integrate handles source tree manipulation for self-modification
   2  // (Stage 9). Generated code is always a new file — never modify existing
   3  // files. Rollback is deletion. The organism grows by addition, then dissolves
   4  // what doesn't work.
   5  package integrate
   6  
   7  import (
   8  	"fmt"
   9  	"go/ast"
  10  	"go/parser"
  11  	"go/token"
  12  	"os"
  13  	"os/exec"
  14  	"path/filepath"
  15  	"strings"
  16  )
  17  
  18  // Plan describes a code integration: what file to add and where.
  19  type Plan struct {
  20  	SourceFile  string // target path relative to project root (e.g., "lattice/count_nodes.go")
  21  	GoSource    string // the Go source code to write
  22  	Package     string // package name (extracted from source)
  23  	Description string // English description for audit trail
  24  }
  25  
  26  // VerifyResult captures the outcome of a verification pass.
  27  type VerifyResult struct {
  28  	Compiles  bool   // go build ./... succeeded
  29  	TestsPass bool   // go test ./... succeeded
  30  	Error     string // first error encountered
  31  }
  32  
  33  // TargetFile determines where generated code should be placed based on
  34  // its package declaration and first declared type/function name.
  35  func TargetFile(goSource, projectRoot string) string {
  36  	fset := token.NewFileSet()
  37  	f, err := parser.ParseFile(fset, "", goSource, parser.SkipObjectResolution)
  38  	if err != nil {
  39  		return filepath.Join(projectRoot, "generated.go")
  40  	}
  41  
  42  	pkgName := "main"
  43  	if f.Name != nil {
  44  		pkgName = f.Name.Name
  45  	}
  46  
  47  	// Find a directory that matches the package name.
  48  	dir := pkgName
  49  	if pkgName == "main" {
  50  		dir = filepath.Join("cmd", "dendrite")
  51  	}
  52  
  53  	// Use the first declared type or function name for the filename.
  54  	baseName := "generated"
  55  	for _, d := range f.Decls {
  56  		switch decl := d.(type) {
  57  		case *ast.FuncDecl:
  58  			baseName = toSnake(decl.Name.Name)
  59  			goto named
  60  		case *ast.GenDecl:
  61  			for _, spec := range decl.Specs {
  62  				if ts, ok := spec.(*ast.TypeSpec); ok {
  63  					baseName = toSnake(ts.Name.Name)
  64  					goto named
  65  				}
  66  			}
  67  		}
  68  	}
  69  named:
  70  
  71  	return filepath.Join(projectRoot, dir, baseName+".go")
  72  }
  73  
  74  // Verify checks that integration would succeed by building in a temp copy.
  75  // Does not modify the actual project.
  76  func Verify(plan Plan, projectRoot, goRoot string) *VerifyResult {
  77  	result := &VerifyResult{}
  78  
  79  	// Create a temp copy of the project.
  80  	tmpDir, err := os.MkdirTemp("", "integrate-verify-*")
  81  	if err != nil {
  82  		result.Error = fmt.Sprintf("create temp dir: %v", err)
  83  		return result
  84  	}
  85  	defer os.RemoveAll(tmpDir)
  86  
  87  	// Copy the project to temp.
  88  	if err := copyDir(projectRoot, tmpDir); err != nil {
  89  		result.Error = fmt.Sprintf("copy project: %v", err)
  90  		return result
  91  	}
  92  
  93  	// Write the new file in the temp copy.
  94  	targetPath := plan.SourceFile
  95  	if filepath.IsAbs(targetPath) {
  96  		// Make relative.
  97  		rel, err := filepath.Rel(projectRoot, targetPath)
  98  		if err == nil {
  99  			targetPath = rel
 100  		}
 101  	}
 102  	fullPath := filepath.Join(tmpDir, targetPath)
 103  	os.MkdirAll(filepath.Dir(fullPath), 0o755)
 104  	if err := os.WriteFile(fullPath, []byte(plan.GoSource), 0o644); err != nil {
 105  		result.Error = fmt.Sprintf("write file: %v", err)
 106  		return result
 107  	}
 108  
 109  	// Build.
 110  	goBin := filepath.Join(goRoot, "bin", "go")
 111  	buildCmd := exec.Command(goBin, "build", "./...")
 112  	buildCmd.Dir = tmpDir
 113  	buildCmd.Env = cleanGoEnv(goRoot)
 114  	if out, err := buildCmd.CombinedOutput(); err != nil {
 115  		result.Error = fmt.Sprintf("build: %s", string(out))
 116  		return result
 117  	}
 118  	result.Compiles = true
 119  
 120  	// Test.
 121  	testCmd := exec.Command(goBin, "test", "./...")
 122  	testCmd.Dir = tmpDir
 123  	testCmd.Env = cleanGoEnv(goRoot)
 124  	if out, err := testCmd.CombinedOutput(); err != nil {
 125  		result.Error = fmt.Sprintf("test: %s", string(out))
 126  		// Build passed but tests failed.
 127  		return result
 128  	}
 129  	result.TestsPass = true
 130  
 131  	return result
 132  }
 133  
 134  // Apply writes the new file to the actual project and verifies it builds.
 135  // If the build fails, the file is removed (rollback).
 136  func Apply(plan Plan, projectRoot, goRoot string) error {
 137  	fullPath := plan.SourceFile
 138  	if !filepath.IsAbs(fullPath) {
 139  		fullPath = filepath.Join(projectRoot, fullPath)
 140  	}
 141  
 142  	// Ensure the directory exists.
 143  	os.MkdirAll(filepath.Dir(fullPath), 0o755)
 144  
 145  	// Write the file.
 146  	if err := os.WriteFile(fullPath, []byte(plan.GoSource), 0o644); err != nil {
 147  		return fmt.Errorf("write %s: %w", fullPath, err)
 148  	}
 149  
 150  	// Verify build.
 151  	goBin := filepath.Join(goRoot, "bin", "go")
 152  	cmd := exec.Command(goBin, "build", "./...")
 153  	cmd.Dir = projectRoot
 154  	cmd.Env = cleanGoEnv(goRoot)
 155  	if out, err := cmd.CombinedOutput(); err != nil {
 156  		// Rollback.
 157  		os.Remove(fullPath)
 158  		return fmt.Errorf("build failed (rolled back): %s", string(out))
 159  	}
 160  
 161  	return nil
 162  }
 163  
 164  // Rollback removes an integrated file.
 165  func Rollback(plan Plan, projectRoot string) error {
 166  	fullPath := plan.SourceFile
 167  	if !filepath.IsAbs(fullPath) {
 168  		fullPath = filepath.Join(projectRoot, fullPath)
 169  	}
 170  	return os.Remove(fullPath)
 171  }
 172  
 173  // copyDir copies a Go project directory, skipping .git, _output, vendor,
 174  // and other non-essential dirs to keep it fast.
 175  func copyDir(src, dst string) error {
 176  	return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
 177  		if err != nil {
 178  			return err
 179  		}
 180  
 181  		rel, err := filepath.Rel(src, path)
 182  		if err != nil {
 183  			return err
 184  		}
 185  
 186  		// Skip directories that aren't needed for build.
 187  		if info.IsDir() {
 188  			base := filepath.Base(path)
 189  			switch base {
 190  			case ".git", "_output", "node_modules", "vendor", ".svelte-kit", ".next", "dist", "build", "coverage":
 191  				return filepath.SkipDir
 192  			}
 193  			return os.MkdirAll(filepath.Join(dst, rel), info.Mode())
 194  		}
 195  
 196  		// Only copy Go source and module files.
 197  		ext := filepath.Ext(path)
 198  		if ext != ".go" && ext != ".mod" && ext != ".sum" {
 199  			return nil
 200  		}
 201  
 202  		data, err := os.ReadFile(path)
 203  		if err != nil {
 204  			return err
 205  		}
 206  		return os.WriteFile(filepath.Join(dst, rel), data, info.Mode())
 207  	})
 208  }
 209  
 210  // cleanGoEnv returns an environment with GOROOT, PATH, and GOTOOLCHAIN set.
 211  func cleanGoEnv(root string) []string {
 212  	env := os.Environ()
 213  	clean := make([]string, 0, len(env)+3)
 214  	for _, e := range env {
 215  		if strings.HasPrefix(e, "GOROOT=") ||
 216  			strings.HasPrefix(e, "GOTOOLCHAIN=") ||
 217  			strings.HasPrefix(e, "PATH=") {
 218  			continue
 219  		}
 220  		clean = append(clean, e)
 221  	}
 222  	clean = append(clean,
 223  		"GOROOT="+root,
 224  		"GOTOOLCHAIN=local",
 225  		"PATH="+filepath.Join(root, "bin")+":"+os.Getenv("PATH"),
 226  	)
 227  	return clean
 228  }
 229  
 230  // toSnake converts a CamelCase name to snake_case for filenames.
 231  func toSnake(s string) string {
 232  	var b strings.Builder
 233  	for i, r := range s {
 234  		if r >= 'A' && r <= 'Z' {
 235  			if i > 0 {
 236  				b.WriteByte('_')
 237  			}
 238  			b.WriteRune(r + ('a' - 'A'))
 239  		} else {
 240  			b.WriteRune(r)
 241  		}
 242  	}
 243  	return b.String()
 244  }
 245