Skip to main content

caixa_resolver/
resolve.rs

1//! Top-level resolver — turn a [`Caixa`] into a [`Lacre`] with BLAKE3
2//! fechamento hashes over the full transitive closure.
3
4use std::collections::{BTreeMap, HashSet, VecDeque};
5use std::path::{Path, PathBuf};
6
7use caixa_core::{Caixa, Dep, DepSource};
8use caixa_lacre::{Lacre, LacreEntry, closure_hash, hash_bytes};
9use thiserror::Error;
10
11use crate::cache::CacheDir;
12use crate::config::ResolverConfig;
13use crate::git::{self, GitError};
14use crate::url::expand_shorthand;
15
16#[derive(Debug, Error)]
17pub enum ResolveError {
18    #[error("io: {0}")]
19    Io(#[from] std::io::Error),
20    #[error("git: {0}")]
21    Git(#[from] GitError),
22    #[error("lisp: {0}")]
23    Lisp(#[from] tatara_lisp::LispError),
24    /// A resolved dep's `caixa.lisp` did not read as a package manifest.
25    ///
26    /// Distinct from [`Self::Lisp`] because the interesting case is
27    /// `LeituraError::DialetoEstrangeiro`: a dep whose manifest is a
28    /// repo-surface declaration rather than a package manifest is not a
29    /// syntax error in that dep, it is the wrong KIND of file, and a resolver
30    /// that flattened both into "lisp: …" would send the author hunting for a
31    /// typo that is not there.
32    #[error("manifest: {0}")]
33    Manifesto(#[from] caixa_core::LeituraError),
34    #[error("dep '{nome}' expected a pin (:tag or :rev); got neither")]
35    MissingPin { nome: String },
36    #[error("dep '{nome}' path source {path} does not exist")]
37    MissingPath { nome: String, path: PathBuf },
38    #[error("cyclic dependency detected involving '{0}'")]
39    Cycle(String),
40}
41
42/// Resolve a root caixa's deps into a canonical lacre, offline if the cache
43/// is warm, otherwise cloning/fetching from git.
44pub fn resolve_lacre(
45    root: &Caixa,
46    cfg: &ResolverConfig,
47    cache: &CacheDir,
48) -> Result<Lacre, ResolveError> {
49    // Direct deps from the root caixa. Read both the runtime `:deps`
50    // and dev-only `:deps-dev` lists through the typed
51    // [`Caixa::deps`] / [`Caixa::deps_dev`] `&[Dep]`-return slice
52    // accessors so every projection of the outer-`Caixa`
53    // dependency-slot axes in the top-level closure resolver's
54    // queue-initialization surface routes through one typed
55    // dispatch — any future accessor extension (a per-scope alias
56    // table, per-cluster canary-version overlay, per-Aplicacao
57    // dev-closure-audit projection the M4 CR materializer's
58    // per-CR reconcile pass consumes) reaches both walks by
59    // construction. Peer of the sibling per-`:deps` /
60    // `:deps-dev` accessor family the ad34b4e / f7fd81e lifts
61    // opened on the outer-`Caixa` slot, extended here onto the
62    // caixa-resolver top-level closure resolver's outermost
63    // queue-seeding walks.
64    //
65    // The paired parent-`:nome` label carried on the queue's `from`
66    // arm — the byte-string the cycle-diagnostic re-labeler's
67    // `ResolveError::Cycle(from.clone())` map_err arm surfaces on any
68    // future extension that promotes the closure walker's per-target
69    // cycle-collapse into a first-class refusal — routes through the
70    // typed [`caixa_core::Caixa::nome`] `&str`-return accessor rather
71    // than the raw `root.nome.clone()` `String`-carry read. Byte-equal
72    // today (`Caixa::nome` is `&self.nome`; `.to_string()` on the
73    // borrowed `&str` allocates exactly one `String` byte-equal to the
74    // prior raw `.nome.clone()` read); the queue's per-transitive-target
75    // sibling push at line 77 already routes through the peer
76    // [`caixa_core::Dep::nome`] accessor (`dep.nome().to_string()`), so
77    // this converge closes the last unlifted parent-`:nome` field-read
78    // in the closure walker's queue-carried `from` axis. Peer with every
79    // prior per-`Caixa` universal-axis converge on the outer-`Caixa`
80    // `:nome` scalar (caixa-helm 22461ef / a7420bd, caixa-flux 4a363bf /
81    // 162e2e2, caixa-mesh 54bf2f3 / 980c059, caixa-crd 61d3429,
82    // caixa-tatara e73b19f, caixa-feira 5131203 / 4b05240 / 3219a42 /
83    // 5bc5178) and sibling to 5ce1b94's `Caixa::deps` / `Caixa::deps_dev`
84    // converge in this same crate on the paired outer-`Caixa`
85    // dependency-slot axis.
86    let mut queue: VecDeque<(Dep, String)> = VecDeque::new();
87    let mut seen: HashSet<String> = HashSet::new();
88    for dep in root.deps() {
89        queue.push_back((dep.clone(), root.nome().to_string()));
90    }
91    if cfg.include_dev {
92        for dep in root.deps_dev() {
93            queue.push_back((dep.clone(), root.nome().to_string()));
94        }
95    }
96
97    // Resolved entries keyed by nome, preserving deterministic deps_diretas.
98    let mut resolved: BTreeMap<String, ResolvedDep> = BTreeMap::new();
99
100    while let Some((dep, from)) = queue.pop_front() {
101        if !seen.insert(dep.nome().to_string()) {
102            continue;
103        }
104        let fetched = fetch_dep(&dep, cfg, cache).map_err(|e| match e {
105            ResolveError::Cycle(_) => ResolveError::Cycle(from.clone()),
106            other => other,
107        })?;
108        for t in &fetched.child_deps {
109            queue.push_back((t.clone(), dep.nome().to_string()));
110        }
111        resolved.insert(
112            dep.nome().to_string(),
113            ResolvedDep {
114                dep,
115                child_deps: fetched.child_deps,
116                resolved_fonte: fetched.resolved_fonte,
117                concrete_versao: fetched.concrete_versao,
118                conteudo: fetched.conteudo,
119            },
120        );
121    }
122
123    // Compute closure hashes in reverse-topological order.
124    let mut fechamento: BTreeMap<String, String> = BTreeMap::new();
125    // Simple fixpoint: re-run until all are hashable (acyclic → terminates).
126    let names: Vec<_> = resolved.keys().cloned().collect();
127    for _ in 0..names.len() {
128        let mut all_done = true;
129        for name in &names {
130            if fechamento.contains_key(name) {
131                continue;
132            }
133            let r = &resolved[name];
134            let child_closures: Option<Vec<String>> = r
135                .child_deps
136                .iter()
137                .map(|c| fechamento.get(c.nome()).cloned())
138                .collect();
139            if let Some(closures) = child_closures {
140                fechamento.insert(name.clone(), closure_hash(&r.conteudo, &closures));
141            } else {
142                all_done = false;
143            }
144        }
145        if all_done {
146            break;
147        }
148    }
149
150    // Build entries in sorted-name order.
151    let entries: Vec<LacreEntry> = resolved
152        .values()
153        .map(|r| LacreEntry {
154            nome: r.dep.nome().to_string(),
155            versao: r.concrete_versao.clone(),
156            fonte: r.resolved_fonte.clone(),
157            conteudo: r.conteudo.clone(),
158            fechamento: fechamento
159                .get(r.dep.nome())
160                .cloned()
161                .unwrap_or_else(|| hash_bytes(b"unresolved")),
162            deps_diretas: r.child_deps.iter().map(|c| c.nome().to_string()).collect(),
163        })
164        .collect();
165
166    Ok(Lacre::from_entries(entries))
167}
168
169struct ResolvedDep {
170    dep: Dep,
171    child_deps: Vec<Dep>,
172    resolved_fonte: DepSource,
173    concrete_versao: String,
174    conteudo: String,
175}
176
177struct FetchedDep {
178    child_deps: Vec<Dep>,
179    resolved_fonte: DepSource,
180    concrete_versao: String,
181    conteudo: String,
182}
183
184fn fetch_dep(
185    dep: &Dep,
186    cfg: &ResolverConfig,
187    cache: &CacheDir,
188) -> Result<FetchedDep, ResolveError> {
189    // Expand :fonte — None → default host shorthand.
190    let fonte = dep.fonte.clone().unwrap_or_else(|| {
191        let (host, org) = split_default_host(&cfg.default_host);
192        DepSource::Git {
193            repo: format!("{host}:{org}/{}", dep.nome()),
194            tag: None,
195            rev: None,
196            branch: None,
197        }
198    });
199
200    match &fonte {
201        DepSource::Path { caminho } => fetch_path(dep, caminho),
202        // Route the per-`DepSource::Git`-arm sole-set-pin projection
203        // through the lifted [`DepSource::sole_pin`] substrate
204        // accessor rather than passing broken-out `tag`/`rev`/`branch`
205        // `Option<&str>` triples down for `fetch_git` to re-inline the
206        // `rev.or(tag).or(branch)` cascade on — the two pre-lift
207        // consumers of the sole-set-pin projection (this crate's
208        // `fetch_git` `git checkout` target, caixa-crd's
209        // `dep_into_ref` `CaixaSource.git_ref` fill) now key off
210        // exactly one typed dispatch on the substrate primitive, so
211        // any future rebrand on the precedence axis (a `:commit` pin
212        // peer once signed-commit-verification lands, a `:ref` pin the
213        // M4 operator resolves per-cluster, a promotion of the plain
214        // `Option<String>` pins to a typed `GitPin` newtype) migrates
215        // as a single caixa-core edit rather than a coordinated
216        // rewrite of both consumer sites.
217        DepSource::Git { repo, .. } => fetch_git(dep, repo, fonte.sole_pin(), cache, fonte.clone()),
218    }
219}
220
221fn fetch_path(dep: &Dep, caminho: &str) -> Result<FetchedDep, ResolveError> {
222    let path = PathBuf::from(caminho);
223    if !path.exists() {
224        return Err(ResolveError::MissingPath {
225            nome: dep.nome().to_string(),
226            path,
227        });
228    }
229    let manifest = std::fs::read_to_string(path.join("caixa.lisp"))?;
230    let target = Caixa::from_lisp(&manifest)?;
231    Ok(FetchedDep {
232        // Route the fetched child's `:deps` `Vec<Dep>`-carry projection
233        // through the typed [`Caixa::deps`] `&[Dep]`-return slice
234        // accessor so the transitive walk keys off the same typed
235        // dispatch the outer queue-seeding read at [`resolve_lacre`]
236        // already routes through — a `.to_vec()` on the accessor's
237        // borrowed slice allocates exactly one `Vec<Dep>` per fetched
238        // target, byte-equal in element order to the prior raw
239        // `target.deps.clone()` read.
240        child_deps: target.deps().to_vec(),
241        resolved_fonte: DepSource::Path {
242            caminho: caminho.to_string(),
243        },
244        // Route the fetched child's `:versao` `String`-carry projection
245        // through the typed [`Caixa::versao`] `&str`-return accessor —
246        // sibling to the paired `target.deps().to_vec()` accessor-route
247        // above; a `.to_string()` on the accessor's borrowed `&str`
248        // allocates exactly one `String` byte-equal to the prior raw
249        // `target.versao.clone()` read. Closes the last unlifted per-
250        // `Caixa` universal-axis raw-field-access `String`-carry site on
251        // the caixa-resolver closure-walker's per-fetched-target
252        // [`FetchedDep`] emit surface, sibling to the peer per-`Caixa`
253        // universal-axis converges every peer per-kind renderer (caixa-
254        // helm eb912de, caixa-flux 2fc5f81, caixa-mesh 980c059, caixa-
255        // tatara e73b19f, caixa-crd 41ab9a3) already routes its `:versao`
256        // `String`-carry through.
257        concrete_versao: target.versao().to_string(),
258        conteudo: format!("path:{caminho}"),
259    })
260}
261
262fn fetch_git(
263    dep: &Dep,
264    repo: &str,
265    sole_pin: Option<&str>,
266    cache: &CacheDir,
267    original_fonte: DepSource,
268) -> Result<FetchedDep, ResolveError> {
269    let gitref = sole_pin.ok_or_else(|| ResolveError::MissingPin {
270        nome: dep.nome().to_string(),
271    })?;
272    let full_url = expand_shorthand(repo);
273    let key_bytes = format!("{full_url}#{gitref}");
274    let key = hash_bytes(key_bytes.as_bytes());
275    let short = &key["blake3:".len()..][..16];
276    let dest = cache.source_dir(short);
277
278    git::clone_or_fetch(&full_url, &dest)?;
279    git::checkout(&dest, gitref)?;
280    let sha = git::head_sha(&dest)?;
281    let conteudo = format!("git:{sha}");
282
283    let manifest_path = dest.join("caixa.lisp");
284    let manifest = std::fs::read_to_string(&manifest_path)?;
285    let target = Caixa::from_lisp(&manifest)?;
286
287    // Freeze :fonte into the lacre with the resolved commit — lock files
288    // are reproducible even if the upstream moves the tag.
289    let resolved = match original_fonte {
290        DepSource::Git {
291            repo: r,
292            tag: t,
293            branch: b,
294            ..
295        } => DepSource::Git {
296            repo: r,
297            tag: t,
298            rev: Some(sha),
299            branch: b,
300        },
301        other => other,
302    };
303
304    Ok(FetchedDep {
305        // Same accessor-route as the sibling [`fetch_path`] arm — the
306        // git-fetched child's `:deps` `Vec<Dep>`-carry projection
307        // reaches for the typed [`Caixa::deps`] `&[Dep]`-return slice
308        // accessor so both fetch-arm branches of the transitive walk
309        // key off the same typed dispatch as the outer
310        // [`resolve_lacre`] queue-seeding read.
311        child_deps: target.deps().to_vec(),
312        resolved_fonte: resolved,
313        // Sibling `:versao` `String`-carry converge to the paired
314        // [`fetch_path`] arm above — the git-fetched child's `:versao`
315        // scalar routes through the typed [`Caixa::versao`] `&str`-
316        // return accessor rather than the raw `.versao.clone()` field
317        // access, so both fetch-arm branches of the closure walker land
318        // the concrete-versao carry through the same typed dispatch.
319        concrete_versao: target.versao().to_string(),
320        conteudo,
321    })
322}
323
324/// Split `"github:pleme-io"` → `("github", "pleme-io")`. Unrecognized hosts
325/// return `("github", default_host_as_is)`.
326fn split_default_host(default_host: &str) -> (&str, &str) {
327    default_host
328        .split_once(':')
329        .unwrap_or(("github", default_host))
330}
331
332#[allow(dead_code)]
333fn _unused_path(_p: &Path) {}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use caixa_core::CaixaKind;
339    use tempfile::tempdir;
340
341    #[test]
342    fn split_default_host_parses_github() {
343        assert_eq!(
344            split_default_host("github:pleme-io"),
345            ("github", "pleme-io")
346        );
347    }
348
349    /// Pin that every projection of the outer-`Caixa` `:deps` /
350    /// `:deps-dev` dependency-slot family in [`resolve_lacre`] and the
351    /// per-target [`fetch_path`] transitive walk — the outer
352    /// queue-seeding walks over `root.deps()` and `root.deps_dev()`
353    /// (the two entry points every downstream git-clone target flows
354    /// through), plus the per-fetched-target `target.deps().to_vec()`
355    /// child-dep collection inside [`fetch_path`] the transitive walk
356    /// keys off — routes through the typed [`caixa_core::Caixa::deps`]
357    /// / [`caixa_core::Caixa::deps_dev`] `&[Dep]`-return slice
358    /// accessors rather than raw `&root.deps` / `&root.deps_dev` /
359    /// `target.deps.clone()` field reads. Byte-equal today (each
360    /// accessor returns `self.<field>.as_slice()`); catches any future
361    /// emit-site regression that reintroduces a raw field read, and
362    /// pins the closure-walker's four-site accessor-routing against a
363    /// hermetic tempdir-hosted `defcaixa`-parsed three-caixa closure
364    /// (root → child → grandchild via `:fonte (:tipo path :caminho …)`
365    /// on each edge, plus a dev-only sibling under `:deps-dev` that
366    /// the closure walker admits only when `cfg.include_dev` is true).
367    /// Peer of the sibling caixa-crd's `dep_into_ref_routes_through_dep_accessors`
368    /// (d65d1bf) per-entry `:deps` sub-slot family pin on the paired
369    /// per-`Dep` `Option<&DepSource>` composite-reference axis — same
370    /// "the emit path must route through the substrate-primitive
371    /// typed dispatch" discipline extended onto the outer top-level
372    /// [`Caixa`] `&[Dep]` slice axes at the closure-resolver's
373    /// queue-seeding surface.
374    #[test]
375    #[allow(clippy::too_many_lines)]
376    fn resolve_lacre_routes_dep_slot_family_through_caixa_accessors() {
377        let td = tempdir().expect("tempdir");
378        // Grandchild caixa on disk — no deps.
379        let grandchild_path = td.path().join("grandchild");
380        std::fs::create_dir_all(&grandchild_path).unwrap();
381        std::fs::write(
382            grandchild_path.join("caixa.lisp"),
383            r#"(defcaixa
384                  :nome "grandchild"
385                  :versao "0.1.0"
386                  :kind Biblioteca
387                  :bibliotecas ("lib/grandchild.lisp"))"#,
388        )
389        .unwrap();
390
391        // Child caixa on disk — depends on grandchild via Path.
392        let child_path = td.path().join("child");
393        std::fs::create_dir_all(&child_path).unwrap();
394        let child_lisp = format!(
395            r#"(defcaixa
396                  :nome "child"
397                  :versao "0.1.0"
398                  :kind Biblioteca
399                  :bibliotecas ("lib/child.lisp")
400                  :deps ((:nome "grandchild" :versao "0.1.0"
401                          :fonte (:tipo path :caminho "{}"))))"#,
402            grandchild_path.display()
403        );
404        std::fs::write(child_path.join("caixa.lisp"), child_lisp).unwrap();
405
406        // Dev-only sibling — no deps of its own.
407        let devchild_path = td.path().join("devchild");
408        std::fs::create_dir_all(&devchild_path).unwrap();
409        std::fs::write(
410            devchild_path.join("caixa.lisp"),
411            r#"(defcaixa
412                  :nome "devchild"
413                  :versao "0.1.0"
414                  :kind Biblioteca
415                  :bibliotecas ("lib/devchild.lisp"))"#,
416        )
417        .unwrap();
418
419        let root = Caixa {
420            nome: "root".into(),
421            versao: "0.1.0".into(),
422            kind: CaixaKind::Biblioteca,
423            edicao: None,
424            descricao: None,
425            repositorio: None,
426            licenca: None,
427            autores: vec![],
428            etiquetas: vec![],
429            deps: vec![Dep {
430                nome: "child".into(),
431                versao: "0.1.0".into(),
432                fonte: Some(DepSource::Path {
433                    caminho: child_path.to_string_lossy().into_owned(),
434                }),
435                opcional: false,
436                caracteristicas: vec![],
437            }],
438            deps_dev: vec![Dep {
439                nome: "devchild".into(),
440                versao: "0.1.0".into(),
441                fonte: Some(DepSource::Path {
442                    caminho: devchild_path.to_string_lossy().into_owned(),
443                }),
444                opcional: false,
445                caracteristicas: vec![],
446            }],
447            exe: vec![],
448            bibliotecas: vec![],
449            servicos: vec![],
450            limits: None,
451            behavior: None,
452            upgrade_from: vec![],
453            estrategia: None,
454            max_restarts: None,
455            restart_window: None,
456            children: vec![],
457            membros: vec![],
458            contratos: vec![],
459            politicas: None,
460            placement: None,
461            entrada: None,
462            ci: None,
463        };
464
465        let cache_root = td.path().join("cache");
466        std::fs::create_dir_all(&cache_root).unwrap();
467        let cache = CacheDir::at(&cache_root);
468
469        // include_dev=false: the closure-walker admits only `:deps`
470        // entries + their transitives. `:deps-dev` sibling must NOT
471        // appear, and the walker must have iterated `root.deps()`
472        // (surfacing `"child"`) and then `child.deps().to_vec()` inside
473        // `fetch_path` (surfacing `"grandchild"`).
474        let cfg = ResolverConfig::default();
475        let lacre = resolve_lacre(&root, &cfg, &cache).expect("resolve runtime-only closure");
476        let names: Vec<&str> = lacre.entradas.iter().map(|e| e.nome.as_str()).collect();
477        assert!(
478            names.contains(&"child"),
479            "resolve_lacre must surface `child` via root.deps() — got {names:?}"
480        );
481        assert!(
482            names.contains(&"grandchild"),
483            "resolve_lacre must surface `grandchild` via the transitive \
484             fetch_path target.deps().to_vec() walk — got {names:?}"
485        );
486        assert!(
487            !names.contains(&"devchild"),
488            "resolve_lacre must NOT surface `devchild` when \
489             include_dev=false (deps_dev accessor must be walked only \
490             under the include_dev cfg arm) — got {names:?}"
491        );
492
493        // include_dev=true: the closure-walker also admits `:deps-dev`
494        // entries. The `devchild` sibling must appear via
495        // `root.deps_dev()`.
496        let cfg_with_dev = ResolverConfig {
497            include_dev: true,
498            ..Default::default()
499        };
500        let lacre_with_dev =
501            resolve_lacre(&root, &cfg_with_dev, &cache).expect("resolve with dev closure");
502        let names_with_dev: Vec<&str> = lacre_with_dev
503            .entradas
504            .iter()
505            .map(|e| e.nome.as_str())
506            .collect();
507        assert!(
508            names_with_dev.contains(&"child"),
509            "include_dev=true closure must still surface `child` via \
510             root.deps() — got {names_with_dev:?}"
511        );
512        assert!(
513            names_with_dev.contains(&"grandchild"),
514            "include_dev=true closure must still surface `grandchild` \
515             via the transitive walk — got {names_with_dev:?}"
516        );
517        assert!(
518            names_with_dev.contains(&"devchild"),
519            "include_dev=true closure must surface `devchild` via \
520             root.deps_dev() — got {names_with_dev:?}"
521        );
522    }
523
524    /// Pin that both fetch-arm branches of the closure walker's per-
525    /// fetched-target [`FetchedDep`] emit surface — the [`fetch_path`]
526    /// arm and the [`fetch_git`] arm — carry each target's `:versao`
527    /// scalar into the resulting [`crate::LacreEntry`] via the typed
528    /// [`caixa_core::Caixa::versao`] `&str`-return accessor rather than a
529    /// raw `target.versao.clone()` field access. Byte-equal today
530    /// (`Caixa::versao` is `&self.versao`); catches any future emit-side
531    /// regression that reintroduces a raw field read and pins the two
532    /// [`FetchedDep::concrete_versao`] construction sites against a
533    /// hermetic tempdir-hosted three-caixa closure whose `:versao` bytes
534    /// distinguish each layer (root `"0.9.0"` → child `"0.5.2"` →
535    /// grandchild `"0.1.3"`), so a stub-that-hardcodes-a-fixed-string
536    /// regression trips on any layer.
537    ///
538    /// Peer of the sibling [`resolve_lacre_routes_dep_slot_family_through_caixa_accessors`]
539    /// per-`Caixa` `:deps` / `:deps-dev` slot-family pin on the sibling
540    /// per-`Caixa` `&[Dep]` slice-return accessor axis — same "the
541    /// emit path must route through the substrate-primitive typed
542    /// dispatch" discipline extended onto the outer top-level [`Caixa`]
543    /// `:versao` `&str`-return universal-axis at the closure-resolver's
544    /// per-fetched-target [`FetchedDep`] emit surface. Sibling to the
545    /// peer per-`Caixa` universal-axis converges every peer per-kind
546    /// renderer (caixa-helm eb912de, caixa-flux 2fc5f81, caixa-mesh
547    /// 980c059, caixa-tatara e73b19f, caixa-crd 41ab9a3) already routes
548    /// its `:versao` `String`-carry through.
549    #[test]
550    fn resolve_lacre_fetched_target_versao_routes_through_caixa_versao_accessor() {
551        let td = tempdir().expect("tempdir");
552        // Grandchild caixa on disk — `:versao "0.1.3"`.
553        let grandchild_path = td.path().join("grandchild");
554        std::fs::create_dir_all(&grandchild_path).unwrap();
555        std::fs::write(
556            grandchild_path.join("caixa.lisp"),
557            r#"(defcaixa
558                  :nome "grandchild"
559                  :versao "0.1.3"
560                  :kind Biblioteca
561                  :bibliotecas ("lib/grandchild.lisp"))"#,
562        )
563        .unwrap();
564
565        // Child caixa on disk — `:versao "0.5.2"`, depends on grandchild
566        // via Path so the closure walker traverses through
567        // [`fetch_path`] on both edges.
568        let child_path = td.path().join("child");
569        std::fs::create_dir_all(&child_path).unwrap();
570        let child_lisp = format!(
571            r#"(defcaixa
572                  :nome "child"
573                  :versao "0.5.2"
574                  :kind Biblioteca
575                  :bibliotecas ("lib/child.lisp")
576                  :deps ((:nome "grandchild" :versao "0.1.3"
577                          :fonte (:tipo path :caminho "{}"))))"#,
578            grandchild_path.display()
579        );
580        std::fs::write(child_path.join("caixa.lisp"), child_lisp).unwrap();
581
582        let root = Caixa {
583            nome: "root".into(),
584            versao: "0.9.0".into(),
585            kind: CaixaKind::Biblioteca,
586            edicao: None,
587            descricao: None,
588            repositorio: None,
589            licenca: None,
590            autores: vec![],
591            etiquetas: vec![],
592            deps: vec![Dep {
593                nome: "child".into(),
594                versao: "0.5.2".into(),
595                fonte: Some(DepSource::Path {
596                    caminho: child_path.to_string_lossy().into_owned(),
597                }),
598                opcional: false,
599                caracteristicas: vec![],
600            }],
601            deps_dev: vec![],
602            exe: vec![],
603            bibliotecas: vec![],
604            servicos: vec![],
605            limits: None,
606            behavior: None,
607            upgrade_from: vec![],
608            estrategia: None,
609            max_restarts: None,
610            restart_window: None,
611            children: vec![],
612            membros: vec![],
613            contratos: vec![],
614            politicas: None,
615            placement: None,
616            entrada: None,
617            ci: None,
618        };
619
620        let cache_root = td.path().join("cache");
621        std::fs::create_dir_all(&cache_root).unwrap();
622        let cache = CacheDir::at(&cache_root);
623        let cfg = ResolverConfig::default();
624        let lacre = resolve_lacre(&root, &cfg, &cache).expect("resolve closure");
625
626        // Every fetched target's `:versao` scalar must land in the
627        // resulting `LacreEntry.versao` byte-verbatim to what
628        // `Caixa::versao()` returns for the source caixa on disk. A
629        // regression that hardcoded a fixed string on either fetch-arm
630        // side of the closure walker trips on the layer whose byte-shape
631        // it drifted off.
632        let pairs: Vec<(&str, &str)> = lacre
633            .entradas
634            .iter()
635            .map(|e| (e.nome.as_str(), e.versao.as_str()))
636            .collect();
637        assert!(
638            pairs.contains(&("child", "0.5.2")),
639            "child entry must carry `:versao \"0.5.2\"` verbatim through \
640             fetch_path -> Caixa::versao() -> FetchedDep::concrete_versao \
641             -> LacreEntry.versao — got {pairs:?}"
642        );
643        assert!(
644            pairs.contains(&("grandchild", "0.1.3")),
645            "grandchild entry must carry `:versao \"0.1.3\"` verbatim \
646             through the transitive walk's Caixa::versao() accessor route \
647             — got {pairs:?}"
648        );
649    }
650
651    /// Pin that the closure-walker's queue-seeding parent-`:nome` label
652    /// axis — the `String` carried on the queue's `from` arm at both the
653    /// outer `for dep in root.deps()` runtime seed and the
654    /// `cfg.include_dev`-gated `for dep in root.deps_dev()` dev-only
655    /// seed inside [`resolve_lacre`] — routes through the typed
656    /// [`caixa_core::Caixa::nome`] `&str`-return accessor rather than a
657    /// raw `root.nome.clone()` field read. The queue's per-transitive-
658    /// target sibling push already routes the paired parent-`:nome`
659    /// label through the peer [`caixa_core::Dep::nome`] accessor
660    /// (`dep.nome().to_string()` at the transitive-walk head), so this
661    /// pin closes the last unlifted parent-`:nome` field-read axis in
662    /// the closure walker's queue-carried `from` surface.
663    ///
664    /// Byte-equal today (`Caixa::nome` is `&self.nome`; `.to_string()`
665    /// on the borrowed `&str` allocates exactly one `String` byte-equal
666    /// to the prior raw `.nome.clone()` read); pin catches any future
667    /// silent detour that reintroduces a raw field read at either queue-
668    /// seeding site. The hermetic tempdir fixture uses a byte-
669    /// distinctive `:nome` on the root (`"root-abc"` — pairing DNS-1123
670    /// legality with three ASCII bytes distinct from every child's
671    /// `:nome` so a stub-that-hardcodes-a-fixed-string regression on
672    /// either seeding site can only pass by accident), and asserts
673    /// [`caixa_core::Caixa::nome`] returns byte-verbatim to what
674    /// [`resolve_lacre`]'s queue-seed reads. The end-to-end assertion
675    /// on the resulting [`crate::LacreEntry`] set pins that both
676    /// `:deps` and `:deps-dev` walks reach their transitive targets
677    /// after the accessor-route substitution, so a regression that
678    /// broke the queue-seeding shape by dropping the second push arm
679    /// (e.g. mis-collapsing the two guarded arms onto one) surfaces
680    /// as a missing child in the resolved closure.
681    ///
682    /// Peer of the sibling [`resolve_lacre_routes_dep_slot_family_through_caixa_accessors`]
683    /// per-`:deps` / `:deps-dev` slot-family pin above and of the
684    /// [`resolve_lacre_fetched_target_versao_routes_through_caixa_versao_accessor`]
685    /// per-`:versao` axis pin (0556249) on the same
686    /// closure-walker emit surface — same "the emit path must route
687    /// through the substrate-primitive typed dispatch" discipline the
688    /// per-`Caixa` `.nome` universal-axis converges every peer per-kind
689    /// renderer already carry, extended onto the outer top-level
690    /// [`Caixa`] `:nome` `&str`-return universal-axis at the closure-
691    /// resolver's queue-seeded per-transitive-target `from` label.
692    #[test]
693    fn resolve_lacre_queue_seed_parent_nome_routes_through_caixa_nome_accessor() {
694        let td = tempdir().expect("tempdir");
695        // Runtime-child caixa on disk — no deps of its own, distinct
696        // `:nome` bytes so it can be identified in the resolved closure.
697        let child_path = td.path().join("child");
698        std::fs::create_dir_all(&child_path).unwrap();
699        std::fs::write(
700            child_path.join("caixa.lisp"),
701            r#"(defcaixa
702                  :nome "child"
703                  :versao "0.1.0"
704                  :kind Biblioteca
705                  :bibliotecas ("lib/child.lisp"))"#,
706        )
707        .unwrap();
708
709        // Dev-only sibling — distinct `:nome`, guards the second
710        // seeding-arm branch.
711        let devchild_path = td.path().join("devchild");
712        std::fs::create_dir_all(&devchild_path).unwrap();
713        std::fs::write(
714            devchild_path.join("caixa.lisp"),
715            r#"(defcaixa
716                  :nome "devchild"
717                  :versao "0.1.0"
718                  :kind Biblioteca
719                  :bibliotecas ("lib/devchild.lisp"))"#,
720        )
721        .unwrap();
722
723        let root = Caixa {
724            nome: "root-abc".into(),
725            versao: "0.9.0".into(),
726            kind: CaixaKind::Biblioteca,
727            edicao: None,
728            descricao: None,
729            repositorio: None,
730            licenca: None,
731            autores: vec![],
732            etiquetas: vec![],
733            deps: vec![Dep {
734                nome: "child".into(),
735                versao: "0.1.0".into(),
736                fonte: Some(DepSource::Path {
737                    caminho: child_path.to_string_lossy().into_owned(),
738                }),
739                opcional: false,
740                caracteristicas: vec![],
741            }],
742            deps_dev: vec![Dep {
743                nome: "devchild".into(),
744                versao: "0.1.0".into(),
745                fonte: Some(DepSource::Path {
746                    caminho: devchild_path.to_string_lossy().into_owned(),
747                }),
748                opcional: false,
749                caracteristicas: vec![],
750            }],
751            exe: vec![],
752            bibliotecas: vec![],
753            servicos: vec![],
754            limits: None,
755            behavior: None,
756            upgrade_from: vec![],
757            estrategia: None,
758            max_restarts: None,
759            restart_window: None,
760            children: vec![],
761            membros: vec![],
762            contratos: vec![],
763            politicas: None,
764            placement: None,
765            entrada: None,
766            ci: None,
767        };
768
769        // Substrate-primitive byte-parity pin: `Caixa::nome()` returns
770        // the raw `:nome` byte-string verbatim. Both queue-seeded push
771        // arms compose `root.nome().to_string()` off this accessor, so
772        // a future accessor extension (a canonicalization pass, an
773        // aliasing overlay) reaches both call sites through one edit.
774        assert_eq!(
775            root.nome(),
776            "root-abc",
777            "Caixa::nome() must return the :nome byte-string verbatim — \
778             pins the substrate contract both queue-seeded push arms \
779             compose the parent-`:nome` label off of"
780        );
781        assert_eq!(
782            root.nome().to_string(),
783            "root-abc",
784            "root.nome().to_string() must equal the raw :nome byte-string \
785             verbatim — pins the accessor-routed String-carry the queue's \
786             `from` arm receives at both seeding sites"
787        );
788
789        // End-to-end pin on the closure walker's two-arm seeding surface:
790        // both `:deps` and `:deps-dev` walks must reach their transitive
791        // targets after the accessor-route substitution. A regression
792        // that dropped either arm's queue-seed push surfaces here as a
793        // missing child in the resolved lacre.
794        let cache_root = td.path().join("cache");
795        std::fs::create_dir_all(&cache_root).unwrap();
796        let cache = CacheDir::at(&cache_root);
797        let cfg_with_dev = ResolverConfig {
798            include_dev: true,
799            ..Default::default()
800        };
801        let lacre =
802            resolve_lacre(&root, &cfg_with_dev, &cache).expect("resolve closure with dev deps");
803        let names: Vec<&str> = lacre.entradas.iter().map(|e| e.nome.as_str()).collect();
804        assert!(
805            names.contains(&"child"),
806            "resolve_lacre with the accessor-routed queue-seed must \
807             surface `child` via the runtime `:deps` arm — got {names:?}"
808        );
809        assert!(
810            names.contains(&"devchild"),
811            "resolve_lacre with the accessor-routed queue-seed must \
812             surface `devchild` via the `cfg.include_dev`-gated \
813             `:deps-dev` arm — got {names:?}"
814        );
815    }
816}