Skip to main content

gen_gomod/
interp.rs

1//! The triplet interpreter (item 3) — `apply(env, ctx) -> BuildSpec`.
2//!
3//! Walks the encoder phases over `go list -deps -json` output:
4//!
5//! ```text
6//! read-go-mod → go-list → parse-go-list → reject-cgo + reject-asm (Go-I12)
7//!   → per-node: relative-path (Go-I3) → read-source + source_hash (Go-I8)
8//!   → resolve-imports (Go-I1) → embed (Go-I9) → tree (Go-I2)
9//!   → roots/members → compact target_resolves → go_sum tie (Go-I7)
10//! ```
11//!
12//! Every side effect — the `go list` subprocess AND source-file reads —
13//! is abstracted behind the [`GoBuildEnv`] trait, so the whole encoder
14//! is driven by a [`MockGoBuildEnv`](../tests) in unit tests with ZERO
15//! `go` / filesystem dependency. The trait IS the testability contract
16//! (TYPED-SPEC + INTERPRETER TRIPLET). A phase that cannot complete
17//! returns a typed [`GomodError::Interp`] naming the phase — never a
18//! silent wrong answer, never a `panic!`.
19
20use std::collections::HashMap;
21use std::path::{Path, PathBuf};
22use std::process::Command;
23
24use indexmap::IndexMap;
25
26use crate::build_spec::{
27    self, BuildSpec, BuildTree, DepMode, EmbedSpec, GoCompactTargetResolves, GoPackageArgs,
28    GoTargetEdges, GoTargetResolve, ModuleSpec, PackageKind, PackageModuleRef, PackageSource,
29    PackageSpec, Renderer, TargetTuple, SCHEMA_VERSION,
30};
31use crate::error::GomodError;
32use crate::golist::parse_stream;
33use crate::gomod::GoMod;
34
35/// The Environment seam — the ONLY place the encoder touches the world.
36/// Real builds shell `go list` + read files; tests mock both.
37pub trait GoBuildEnv {
38    /// Run `go list -deps -json` for the tuple over the module at `root`,
39    /// returning the concatenated-JSON-object stream. MUST be hermetic
40    /// (`-mod=vendor`, `GOPROXY=off`) on the real impl.
41    fn go_list(&self, root: &Path, tuple: &TargetTuple) -> Result<String, GomodError>;
42
43    /// Read a file's bytes — go.mod, go.sum, and each source/embed file
44    /// (for `source_hash`). Errors are surfaced typed, never swallowed.
45    fn read_file(&self, path: &Path) -> Result<Vec<u8>, GomodError>;
46}
47
48/// Production [`GoBuildEnv`]: shells `go` and reads the real filesystem.
49/// `mod_mode` defaults to `"vendor"` — the M1 hermetic contract
50/// (vendored tree, no proxy). The one legitimate non-vendor use is a
51/// dep-free module (nothing to vendor), which the integration test
52/// selects explicitly.
53pub struct RealGoBuildEnv {
54    pub mod_mode: String,
55}
56
57impl Default for RealGoBuildEnv {
58    fn default() -> Self {
59        Self { mod_mode: "vendor".to_string() }
60    }
61}
62
63impl GoBuildEnv for RealGoBuildEnv {
64    fn go_list(&self, root: &Path, tuple: &TargetTuple) -> Result<String, GomodError> {
65        let mut cmd = Command::new("go");
66        cmd.arg("list").arg("-deps").arg("-json");
67        if !tuple.tags.is_empty() {
68            cmd.arg("-tags").arg(tuple.tags.join(","));
69        }
70        cmd.arg("./...");
71        cmd.current_dir(root)
72            .env("GOOS", &tuple.goos)
73            .env("GOARCH", &tuple.goarch)
74            .env("CGO_ENABLED", "0")
75            .env("GOFLAGS", format!("-mod={}", self.mod_mode))
76            .env("GOPROXY", "off")
77            // No interactive prompts, ever.
78            .env("GIT_TERMINAL_PROMPT", "0");
79        let out = cmd
80            .output()
81            .map_err(|e| GomodError::GoList(format!("spawn `go list`: {e}")))?;
82        if !out.status.success() {
83            return Err(GomodError::GoList(format!(
84                "`go list` exited {}: {}",
85                out.status,
86                String::from_utf8_lossy(&out.stderr).trim()
87            )));
88        }
89        Ok(String::from_utf8_lossy(&out.stdout).into_owned())
90    }
91
92    fn read_file(&self, path: &Path) -> Result<Vec<u8>, GomodError> {
93        std::fs::read(path).map_err(|source| GomodError::Io { path: path.to_path_buf(), source })
94    }
95}
96
97/// Encoder inputs: the module root + the single target tuple M1 builds.
98#[derive(Clone, Debug)]
99pub struct EncodeCtx {
100    pub root: PathBuf,
101    pub tuple: TargetTuple,
102}
103
104/// The encoder. Produces a v2 `Incremental` [`BuildSpec`] — the
105/// per-package build graph — from `go list` output + go.mod, hashing
106/// every node's sources for the incremental cache key.
107pub fn apply(env: &dyn GoBuildEnv, ctx: &EncodeCtx) -> Result<BuildSpec, GomodError> {
108    // ── read-go-mod ──────────────────────────────────────────────────
109    let gomod_path = ctx.root.join("go.mod");
110    let gomod_bytes = env
111        .read_file(&gomod_path)
112        .map_err(|_| GomodError::ManifestNotFound(gomod_path.clone()))?;
113    let gomod_text = String::from_utf8_lossy(&gomod_bytes);
114    let module: ModuleSpec = GoMod::parse(&gomod_text).to_module_spec(DepMode::Vendored, None);
115
116    // ── go-list → parse-go-list ──────────────────────────────────────
117    let json = env.go_list(&ctx.root, &ctx.tuple)?;
118    let listed = parse_stream(&json)?;
119
120    // ── reject-cgo / reject-asm (Go-I12: fail closed, never mis-build) ─
121    for p in &listed {
122        if !p.standard && !p.cgo_files.is_empty() {
123            return Err(GomodError::Interp {
124                phase: "reject-cgo",
125                detail: format!(
126                    "package `{}` has cgo sources ({} file(s)); cgo nodes are deferred to M-cgo — \
127                     an M1 (incremental, vendored) build must be a cgo-free subgraph",
128                    p.import_path,
129                    p.cgo_files.len()
130                ),
131            });
132        }
133        if !p.standard && !p.s_files.is_empty() {
134            return Err(GomodError::Interp {
135                phase: "reject-asm",
136                detail: format!(
137                    "package `{}` has assembly sources; per-node asm is deferred to M-asm \
138                     (std asm lives inside the opaque std-tree)",
139                    p.import_path
140                ),
141            });
142        }
143    }
144
145    // import-path → kind index, for resolving edges to node keys.
146    let kind_of: HashMap<&str, PackageKind> =
147        listed.iter().map(|p| (p.import_path.as_str(), p.kind())).collect();
148
149    let mut packages: std::collections::BTreeMap<String, PackageSpec> =
150        std::collections::BTreeMap::new();
151    let mut resolve: std::collections::BTreeMap<String, GoTargetEdges> =
152        std::collections::BTreeMap::new();
153    let mut mains: Vec<String> = Vec::new();
154
155    for p in &listed {
156        let kind = p.kind();
157        let key = build_spec::node_key(&p.import_path, kind, &ctx.tuple);
158
159        // ── resolve-imports (Go-I1) ──────────────────────────────────
160        // Map each direct import through ImportMap (vendor/replace
161        // rewrite), then to the imported package's node key.
162        let mut import_keys: Vec<String> = Vec::with_capacity(p.imports.len());
163        for imp in &p.imports {
164            let actual = p.import_map.get(imp).map_or(imp.as_str(), String::as_str);
165            // A resolved import absent from the closure keeps its raw
166            // path (no fabricated key) so invariants::Go-I1 flags it
167            // rather than the encoder silently inventing a node.
168            let ik = kind_of.get(actual).copied().unwrap_or(PackageKind::Module);
169            import_keys.push(build_spec::node_key(actual, ik, &ctx.tuple));
170        }
171        import_keys.sort();
172        import_keys.dedup();
173
174        // ── relative-path (Go-I3) + read-source + source_hash (Go-I8) ─
175        let (source, source_hash) = if kind.is_std() {
176            // Std nodes are content-addressed by the shared std-tree
177            // derivation, not per-file — no source, no per-node hash.
178            (PackageSource::Std, String::new())
179        } else {
180            let rel = relative_under(&p.dir, &p.root)
181                .or_else(|| relative_under(&p.dir, &ctx.root.to_string_lossy()))
182                .ok_or_else(|| GomodError::Interp {
183                    phase: "relative-path",
184                    detail: format!(
185                        "package dir `{}` is not under module root `{}`",
186                        p.dir, p.root
187                    ),
188                })?;
189            let mut entries: Vec<(String, Vec<u8>)> = Vec::new();
190            for f in p.go_files.iter().chain(p.embed_files.iter()) {
191                let abs = Path::new(&p.dir).join(f);
192                let bytes = env.read_file(&abs).map_err(|e| GomodError::Interp {
193                    phase: "read-source",
194                    detail: format!("{}: {e}", abs.display()),
195                })?;
196                entries.push((f.clone(), bytes));
197            }
198            (
199                PackageSource::Vendored { relative_path: rel },
200                build_spec::source_hash(&entries),
201            )
202        };
203
204        let mut env_map: IndexMap<String, String> = IndexMap::new();
205        env_map.insert("GOOS".to_string(), ctx.tuple.goos.clone());
206        env_map.insert("GOARCH".to_string(), ctx.tuple.goarch.clone());
207        env_map.insert("CGO_ENABLED".to_string(), "0".to_string());
208
209        let spec = PackageSpec {
210            import_path: p.import_path.clone(),
211            kind,
212            source,
213            tree: tree_for(kind),
214            go_files: p.go_files.clone(),
215            build_tags: ctx.tuple.tags.clone(),
216            embed: EmbedSpec {
217                patterns: p.embed_patterns.clone(),
218                files: p.embed_files.clone(),
219            },
220            imports: import_keys.clone(),
221            import_map: p.import_map.clone(),
222            module: p.module.as_ref().map(|m| PackageModuleRef {
223                path: m.path.clone(),
224                version: m.version.clone(),
225            }),
226            source_hash,
227            args: GoPackageArgs { gcflags: Vec::new(), ldflags: Vec::new(), env: env_map },
228            quirks: Vec::new(),
229        };
230
231        resolve.insert(
232            key.clone(),
233            GoTargetEdges { imports: import_keys, import_map: p.import_map.clone() },
234        );
235        if matches!(kind, PackageKind::Main) && !p.dep_only {
236            mains.push(key.clone());
237        }
238        packages.insert(key, spec);
239    }
240
241    // ── roots/members — deterministic (sorted); root = smallest main,
242    //    else the first node (a lib-only module has no main). ──────────
243    mains.sort();
244    mains.dedup();
245    let root_package = mains
246        .first()
247        .cloned()
248        .or_else(|| packages.keys().next().cloned())
249        .unwrap_or_default();
250    let workspace_members = mains;
251
252    // ── compact target_resolves (single tuple; M-multitarget-ready) ───
253    let mut full: IndexMap<String, GoTargetResolve> = IndexMap::new();
254    full.insert(ctx.tuple.suffix(), GoTargetResolve { packages: resolve });
255    let target_resolves = Some(GoCompactTargetResolves::from_full(full));
256
257    // ── go_sum freshness tie (Go-I7). Absent go.sum ⇒ empty content ⇒
258    //    the canonical empty-string SHA-256 the delta gate matches. ────
259    let go_sum_bytes = env.read_file(&ctx.root.join("go.sum")).unwrap_or_default();
260    let go_sum_sha256 = Some(build_spec::go_sum_sha256(&go_sum_bytes));
261
262    Ok(BuildSpec {
263        version: SCHEMA_VERSION,
264        renderer: Renderer::Incremental,
265        module,
266        packages,
267        root_package,
268        workspace_members,
269        target_resolves,
270        go_sum_sha256,
271    })
272}
273
274/// Build-tree placement (Go-I2). M1 has only `Std`/`Module`/`Main`, all
275/// `Target`. The seam: when `Cgo`/`Tool` kinds land (M-cgo), they return
276/// `Host` — a classifier-arm addition, not a rewrite.
277fn tree_for(kind: PackageKind) -> BuildTree {
278    match kind {
279        PackageKind::Std | PackageKind::Module | PackageKind::Main => BuildTree::Target,
280    }
281}
282
283/// `dir` relative to `root` (both absolute). `dir == root` ⇒ `"."`.
284/// `None` when `dir` is not under `root`.
285fn relative_under(dir: &str, root: &str) -> Option<String> {
286    let root = root.trim_end_matches('/');
287    if dir == root {
288        return Some(".".to_string());
289    }
290    dir.strip_prefix(&format!("{root}/")).map(str::to_string)
291}