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