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    //
191    // Route the per-`Dep` `:fonte` presence-projection through the
192    // lifted [`expand_fonte`] helper rather than an inline
193    // `dep.fonte.clone().unwrap_or_else(...)` cascade. The helper reads
194    // through the typed [`caixa_core::Dep::fonte`] `Option<&DepSource>`
195    // accessor and materializes the `github:<org>/<nome>`-shaped
196    // `default_host`-derived fallback for the author-omitted arm,
197    // keeping the closure-walker's two per-`Dep` `:fonte`-expansion
198    // consumers (this crate's [`fetch_dep`], caixa-feira/src/cmd/lock.rs's
199    // `resolve_stub` — 33e5d9a converged the peer site onto the same
200    // accessor) both routed through the substrate-primitive typed
201    // dispatch rather than each open-coding its own `.clone()` cascade.
202    let fonte = expand_fonte(dep, &cfg.default_host);
203
204    match &fonte {
205        DepSource::Path { caminho } => fetch_path(dep, caminho),
206        // Route the per-`DepSource::Git`-arm sole-set-pin projection
207        // through the lifted [`DepSource::sole_pin`] substrate
208        // accessor rather than passing broken-out `tag`/`rev`/`branch`
209        // `Option<&str>` triples down for `fetch_git` to re-inline the
210        // `rev.or(tag).or(branch)` cascade on — the two pre-lift
211        // consumers of the sole-set-pin projection (this crate's
212        // `fetch_git` `git checkout` target, caixa-crd's
213        // `dep_into_ref` `CaixaSource.git_ref` fill) now key off
214        // exactly one typed dispatch on the substrate primitive, so
215        // any future rebrand on the precedence axis (a `:commit` pin
216        // peer once signed-commit-verification lands, a `:ref` pin the
217        // M4 operator resolves per-cluster, a promotion of the plain
218        // `Option<String>` pins to a typed `GitPin` newtype) migrates
219        // as a single caixa-core edit rather than a coordinated
220        // rewrite of both consumer sites.
221        DepSource::Git { repo, .. } => fetch_git(dep, repo, fonte.sole_pin(), cache, fonte.clone()),
222    }
223}
224
225fn fetch_path(dep: &Dep, caminho: &str) -> Result<FetchedDep, ResolveError> {
226    let path = PathBuf::from(caminho);
227    if !path.exists() {
228        return Err(ResolveError::MissingPath {
229            nome: dep.nome().to_string(),
230            path,
231        });
232    }
233    let manifest = std::fs::read_to_string(path.join("caixa.lisp"))?;
234    let target = Caixa::from_lisp(&manifest)?;
235    Ok(FetchedDep {
236        // Route the fetched child's `:deps` `Vec<Dep>`-carry projection
237        // through the typed [`Caixa::deps`] `&[Dep]`-return slice
238        // accessor so the transitive walk keys off the same typed
239        // dispatch the outer queue-seeding read at [`resolve_lacre`]
240        // already routes through — a `.to_vec()` on the accessor's
241        // borrowed slice allocates exactly one `Vec<Dep>` per fetched
242        // target, byte-equal in element order to the prior raw
243        // `target.deps.clone()` read.
244        child_deps: target.deps().to_vec(),
245        resolved_fonte: DepSource::Path {
246            caminho: caminho.to_string(),
247        },
248        // Route the fetched child's `:versao` `String`-carry projection
249        // through the typed [`Caixa::versao`] `&str`-return accessor —
250        // sibling to the paired `target.deps().to_vec()` accessor-route
251        // above; a `.to_string()` on the accessor's borrowed `&str`
252        // allocates exactly one `String` byte-equal to the prior raw
253        // `target.versao.clone()` read. Closes the last unlifted per-
254        // `Caixa` universal-axis raw-field-access `String`-carry site on
255        // the caixa-resolver closure-walker's per-fetched-target
256        // [`FetchedDep`] emit surface, sibling to the peer per-`Caixa`
257        // universal-axis converges every peer per-kind renderer (caixa-
258        // helm eb912de, caixa-flux 2fc5f81, caixa-mesh 980c059, caixa-
259        // tatara e73b19f, caixa-crd 41ab9a3) already routes its `:versao`
260        // `String`-carry through.
261        concrete_versao: target.versao().to_string(),
262        conteudo: format!("path:{caminho}"),
263    })
264}
265
266fn fetch_git(
267    dep: &Dep,
268    repo: &str,
269    sole_pin: Option<&str>,
270    cache: &CacheDir,
271    original_fonte: DepSource,
272) -> Result<FetchedDep, ResolveError> {
273    let gitref = sole_pin.ok_or_else(|| ResolveError::MissingPin {
274        nome: dep.nome().to_string(),
275    })?;
276    let full_url = expand_shorthand(repo);
277    let key_bytes = format!("{full_url}#{gitref}");
278    let key = hash_bytes(key_bytes.as_bytes());
279    let short = &key["blake3:".len()..][..16];
280    let dest = cache.source_dir(short);
281
282    git::clone_or_fetch(&full_url, &dest)?;
283    git::checkout(&dest, gitref)?;
284    let sha = git::head_sha(&dest)?;
285    let conteudo = format!("git:{sha}");
286
287    let manifest_path = dest.join("caixa.lisp");
288    let manifest = std::fs::read_to_string(&manifest_path)?;
289    let target = Caixa::from_lisp(&manifest)?;
290
291    // Freeze :fonte into the lacre with the resolved commit — lock files
292    // are reproducible even if the upstream moves the tag.
293    let resolved = match original_fonte {
294        DepSource::Git {
295            repo: r,
296            tag: t,
297            branch: b,
298            ..
299        } => DepSource::Git {
300            repo: r,
301            tag: t,
302            rev: Some(sha),
303            branch: b,
304        },
305        other => other,
306    };
307
308    Ok(FetchedDep {
309        // Same accessor-route as the sibling [`fetch_path`] arm — the
310        // git-fetched child's `:deps` `Vec<Dep>`-carry projection
311        // reaches for the typed [`Caixa::deps`] `&[Dep]`-return slice
312        // accessor so both fetch-arm branches of the transitive walk
313        // key off the same typed dispatch as the outer
314        // [`resolve_lacre`] queue-seeding read.
315        child_deps: target.deps().to_vec(),
316        resolved_fonte: resolved,
317        // Sibling `:versao` `String`-carry converge to the paired
318        // [`fetch_path`] arm above — the git-fetched child's `:versao`
319        // scalar routes through the typed [`Caixa::versao`] `&str`-
320        // return accessor rather than the raw `.versao.clone()` field
321        // access, so both fetch-arm branches of the closure walker land
322        // the concrete-versao carry through the same typed dispatch.
323        concrete_versao: target.versao().to_string(),
324        conteudo,
325    })
326}
327
328/// Split `"github:pleme-io"` → `("github", "pleme-io")`. Unrecognized hosts
329/// return `("github", default_host_as_is)`.
330fn split_default_host(default_host: &str) -> (&str, &str) {
331    default_host
332        .split_once(':')
333        .unwrap_or(("github", default_host))
334}
335
336/// Per-`Dep` `:fonte` presence-projection with the resolver's
337/// `default_host`-derived fallback baked in — the substrate-primitive
338/// typed dispatch every caixa-resolver closure-walker per-`Dep`
339/// `:fonte`-expansion consumer keys off. The helper reads through the
340/// typed [`caixa_core::Dep::fonte`] `Option<&DepSource>`-return accessor
341/// rather than the raw `.fonte.clone()` field-access, so any future
342/// extension of the accessor's semantics (a per-scope source-override
343/// table on `:fonte` the M4 CR materializer resolves at admission time,
344/// a per-tenant `:fonte` rewrite overlay the roadmap acknowledges, a
345/// lacre-projected concrete-source pin resolver) reaches this fallback
346/// surface through exactly one caixa-core edit rather than a coordinated
347/// rewrite of both open-coded expansions.
348///
349/// Sibling of caixa-feira/src/cmd/lock.rs's `resolve_stub`
350/// (33e5d9a) per-`Dep` `:fonte` accessor-route on the peer stub-resolver
351/// site — same "the emit path must route through the substrate-primitive
352/// typed dispatch" discipline extended onto the caixa-resolver
353/// closure-walker's per-target `:fonte`-expansion surface. Byte-equal to
354/// the prior inline cascade today: on the author-set arm the accessor
355/// returns `Some(&<verbatim>)` and `.cloned()` allocates exactly one
356/// `DepSource` byte-equal to the pre-lift `.fonte.clone()` read; on the
357/// author-omitted arm the accessor returns `None` and the fallback
358/// materializes `DepSource::Git { repo: "<host>:<org>/<nome>", tag/rev/
359/// branch: None }` byte-equal to the pre-lift `unwrap_or_else(...)`
360/// tail. The `default_host` split cascades through
361/// [`split_default_host`] so `"github:pleme-io"` → `github:pleme-io/…`
362/// and `"acme-org"` → `github:acme-org/…` (the unrecognized-host arm
363/// defaults `host` to `"github"`).
364///
365/// The fallback deliberately does NOT compose through
366/// [`caixa_core::DepSource::default_github`] — that helper hardcodes
367/// `"github"` as the host segment (`"github:{org}/{nome}"`), whereas
368/// this helper honors an author-configurable `default_host` (e.g. a
369/// `"codeberg:acme"` config would produce `codeberg:acme/<nome>`,
370/// which `default_github` cannot express).
371pub(crate) fn expand_fonte(dep: &Dep, default_host: &str) -> DepSource {
372    dep.fonte().cloned().unwrap_or_else(|| {
373        let (host, org) = split_default_host(default_host);
374        DepSource::Git {
375            repo: format!("{host}:{org}/{}", dep.nome()),
376            tag: None,
377            rev: None,
378            branch: None,
379        }
380    })
381}
382
383#[allow(dead_code)]
384fn _unused_path(_p: &Path) {}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use caixa_core::CaixaKind;
390    use tempfile::tempdir;
391
392    #[test]
393    fn split_default_host_parses_github() {
394        assert_eq!(
395            split_default_host("github:pleme-io"),
396            ("github", "pleme-io")
397        );
398    }
399
400    /// Pin that the per-`Dep` `:fonte` presence-projection under
401    /// [`expand_fonte`] routes through the typed [`Dep::fonte`]
402    /// `Option<&DepSource>`-return accessor rather than the raw
403    /// `.fonte.clone()` field-access, and that the accessor's `None` arm
404    /// materializes the `<host>:<org>/<nome>`-shaped `default_host`-
405    /// derived fallback with `tag`/`rev`/`branch` all `None`. Sweeps
406    /// three arms of the per-`Dep` `:fonte` presence bit × per-config
407    /// `default_host` shape lattice the closure-walker's
408    /// [`fetch_dep`] call-site keys off:
409    ///
410    /// 1. Author-omitted arm × the canonical `"github:pleme-io"`
411    ///    default-host shape — accessor projects `None`, helper falls
412    ///    back to `DepSource::Git { repo: "github:pleme-io/<nome>",
413    ///    tag/rev/branch: None }`, byte-equal to the pre-lift
414    ///    inline `format!("{host}:{org}/{}", dep.nome())` on the
415    ///    canonical default-host arm.
416    /// 2. Author-omitted arm × an unrecognized single-segment
417    ///    `default_host` (`"acme-org"`) — [`split_default_host`]
418    ///    defaults `host` to `"github"` and carries the whole string as
419    ///    `org`, so the fallback is `DepSource::Git { repo:
420    ///    "github:acme-org/<nome>", … }`. Pins the unrecognized-host
421    ///    arm's fall-through under the helper.
422    /// 3. Author-set arm × any `default_host` — accessor projects
423    ///    `Some(&<verbatim>)`, helper carries the source through
424    ///    `.cloned()` byte-verbatim regardless of `default_host` (the
425    ///    fallback branch never fires when the author declared
426    ///    `:fonte`).
427    ///
428    /// Byte-equal today (`.fonte()` returns `self.fonte.as_ref()`;
429    /// `.cloned()` on `Option<&DepSource>` allocates exactly one
430    /// `DepSource` byte-equal to the prior raw `.fonte.clone()` read);
431    /// catches any future emit-side regression that re-introduces the
432    /// raw `.fonte.clone()` cascade at the [`fetch_dep`] call site (a
433    /// future accessor extension — a per-scope alias table, a per-tenant
434    /// `:fonte` rewrite overlay, a lacre-projected concrete-source pin
435    /// resolver — would then reach only the open-coded cascade and
436    /// silently diverge from the helper's typed dispatch, tripping the
437    /// author-set-arm pin below).
438    ///
439    /// Peer of the sibling caixa-feira/src/cmd/lock.rs's
440    /// `resolve_stub_fonte_routes_through_dep_fonte_accessor` (33e5d9a)
441    /// per-`Dep` `:fonte` accessor-route pin on the stub-resolver
442    /// surface — same "the emit path must route through the substrate-
443    /// primitive typed dispatch" discipline extended onto the
444    /// caixa-resolver closure-walker's per-target `:fonte`-expansion
445    /// helper.
446    #[test]
447    #[allow(clippy::too_many_lines)]
448    fn expand_fonte_routes_through_dep_fonte_accessor() {
449        // Arm 1: author-omitted `:fonte` × canonical `"github:pleme-io"`
450        // default-host. Accessor must project `None` and the helper's
451        // fallback must materialize the canonical
452        // `github:pleme-io/<nome>` shorthand.
453        let bare = Dep::simple("caixa-teia", "^0.1");
454        assert!(
455            bare.fonte().is_none(),
456            "author-omitted :fonte must project None through the accessor \
457             — pins the substrate contract the helper's unwrap_or_else \
458             cascade discriminates on",
459        );
460        let expanded = expand_fonte(&bare, "github:pleme-io");
461        assert_eq!(
462            expanded,
463            DepSource::Git {
464                repo: "github:pleme-io/caixa-teia".to_string(),
465                tag: None,
466                rev: None,
467                branch: None,
468            },
469            "author-omitted :fonte on the canonical github:pleme-io \
470             default-host must fall back to the github:<org>/<nome>-shaped \
471             Git shorthand byte-verbatim",
472        );
473
474        // Arm 2: author-omitted `:fonte` × unrecognized single-segment
475        // `default_host` (`"acme-org"`). `split_default_host`'s
476        // unrecognized-host arm defaults `host` to `"github"` and carries
477        // the whole string as `org`, so the helper's fallback must land
478        // `github:acme-org/<nome>` — pins that the helper reads the
479        // `default_host` through the shared [`split_default_host`]
480        // parser rather than re-inlining a `"github:"`-hardcoded
481        // shorthand (which would produce the same output for this input
482        // by coincidence but would diverge on a `"codeberg:acme"`-shaped
483        // config the arm-3 assertion below stresses).
484        let expanded_unrecognized_host = expand_fonte(&bare, "acme-org");
485        assert_eq!(
486            expanded_unrecognized_host,
487            DepSource::Git {
488                repo: "github:acme-org/caixa-teia".to_string(),
489                tag: None,
490                rev: None,
491                branch: None,
492            },
493            "author-omitted :fonte on an unrecognized single-segment \
494             default_host must default the host segment to `github` and \
495             carry the whole config string as the org",
496        );
497
498        // Arm 3: author-set `:fonte` × any `default_host`. Accessor must
499        // project `Some(&<verbatim>)`, and the helper must carry the
500        // source through `.cloned()` byte-verbatim — the fallback branch
501        // never fires when the author declared `:fonte` at authoring
502        // time. Any regression that re-inlined the raw `.fonte.clone()`
503        // cascade would still pass this arm today (both routes are
504        // byte-equal on the `Some` arm) but a future accessor extension
505        // (a per-scope alias table, a per-tenant rewrite overlay)
506        // would silently diverge here — the pin catches that
507        // divergence at compile time as soon as the accessor's return
508        // shape widens beyond `self.fonte.as_ref()`.
509        let with_path = Dep {
510            nome: "child".into(),
511            versao: "0.1.0".into(),
512            fonte: Some(DepSource::Path {
513                caminho: "/tmp/child".into(),
514            }),
515            opcional: false,
516            caracteristicas: vec![],
517        };
518        assert_eq!(
519            with_path.fonte(),
520            Some(&DepSource::Path {
521                caminho: "/tmp/child".into(),
522            }),
523            "author-set :fonte must project Some(&<verbatim>) through \
524             the accessor — pins the substrate contract the helper's \
525             .cloned() carry discriminates on",
526        );
527        let expanded_author_set = expand_fonte(&with_path, "github:pleme-io");
528        assert_eq!(
529            expanded_author_set,
530            DepSource::Path {
531                caminho: "/tmp/child".into(),
532            },
533            "author-set :fonte must carry through the helper .cloned() \
534             byte-verbatim regardless of default_host — the fallback \
535             branch must not fire when the accessor projects Some(&…)",
536        );
537        // Cross-check: swapping `default_host` on the author-set arm
538        // must not perturb the output — the fallback string is dead
539        // code on the `Some` arm.
540        let expanded_author_set_alt_host = expand_fonte(&with_path, "codeberg:acme");
541        assert_eq!(
542            expanded_author_set, expanded_author_set_alt_host,
543            "author-set :fonte must be default_host-agnostic — the \
544             fallback shorthand must not leak into the output when the \
545             accessor projects Some(&…)",
546        );
547    }
548
549    /// Pin that every projection of the outer-`Caixa` `:deps` /
550    /// `:deps-dev` dependency-slot family in [`resolve_lacre`] and the
551    /// per-target [`fetch_path`] transitive walk — the outer
552    /// queue-seeding walks over `root.deps()` and `root.deps_dev()`
553    /// (the two entry points every downstream git-clone target flows
554    /// through), plus the per-fetched-target `target.deps().to_vec()`
555    /// child-dep collection inside [`fetch_path`] the transitive walk
556    /// keys off — routes through the typed [`caixa_core::Caixa::deps`]
557    /// / [`caixa_core::Caixa::deps_dev`] `&[Dep]`-return slice
558    /// accessors rather than raw `&root.deps` / `&root.deps_dev` /
559    /// `target.deps.clone()` field reads. Byte-equal today (each
560    /// accessor returns `self.<field>.as_slice()`); catches any future
561    /// emit-site regression that reintroduces a raw field read, and
562    /// pins the closure-walker's four-site accessor-routing against a
563    /// hermetic tempdir-hosted `defcaixa`-parsed three-caixa closure
564    /// (root → child → grandchild via `:fonte (:tipo path :caminho …)`
565    /// on each edge, plus a dev-only sibling under `:deps-dev` that
566    /// the closure walker admits only when `cfg.include_dev` is true).
567    /// Peer of the sibling caixa-crd's `dep_into_ref_routes_through_dep_accessors`
568    /// (d65d1bf) per-entry `:deps` sub-slot family pin on the paired
569    /// per-`Dep` `Option<&DepSource>` composite-reference axis — same
570    /// "the emit path must route through the substrate-primitive
571    /// typed dispatch" discipline extended onto the outer top-level
572    /// [`Caixa`] `&[Dep]` slice axes at the closure-resolver's
573    /// queue-seeding surface.
574    #[test]
575    #[allow(clippy::too_many_lines)]
576    fn resolve_lacre_routes_dep_slot_family_through_caixa_accessors() {
577        let td = tempdir().expect("tempdir");
578        // Grandchild caixa on disk — no deps.
579        let grandchild_path = td.path().join("grandchild");
580        std::fs::create_dir_all(&grandchild_path).unwrap();
581        std::fs::write(
582            grandchild_path.join("caixa.lisp"),
583            r#"(defcaixa
584                  :nome "grandchild"
585                  :versao "0.1.0"
586                  :kind Biblioteca
587                  :bibliotecas ("lib/grandchild.lisp"))"#,
588        )
589        .unwrap();
590
591        // Child caixa on disk — depends on grandchild via Path.
592        let child_path = td.path().join("child");
593        std::fs::create_dir_all(&child_path).unwrap();
594        let child_lisp = format!(
595            r#"(defcaixa
596                  :nome "child"
597                  :versao "0.1.0"
598                  :kind Biblioteca
599                  :bibliotecas ("lib/child.lisp")
600                  :deps ((:nome "grandchild" :versao "0.1.0"
601                          :fonte (:tipo path :caminho "{}"))))"#,
602            grandchild_path.display()
603        );
604        std::fs::write(child_path.join("caixa.lisp"), child_lisp).unwrap();
605
606        // Dev-only sibling — no deps of its own.
607        let devchild_path = td.path().join("devchild");
608        std::fs::create_dir_all(&devchild_path).unwrap();
609        std::fs::write(
610            devchild_path.join("caixa.lisp"),
611            r#"(defcaixa
612                  :nome "devchild"
613                  :versao "0.1.0"
614                  :kind Biblioteca
615                  :bibliotecas ("lib/devchild.lisp"))"#,
616        )
617        .unwrap();
618
619        let root = Caixa {
620            nome: "root".into(),
621            versao: "0.1.0".into(),
622            kind: CaixaKind::Biblioteca,
623            edicao: None,
624            descricao: None,
625            repositorio: None,
626            licenca: None,
627            autores: vec![],
628            etiquetas: vec![],
629            deps: vec![Dep {
630                nome: "child".into(),
631                versao: "0.1.0".into(),
632                fonte: Some(DepSource::Path {
633                    caminho: child_path.to_string_lossy().into_owned(),
634                }),
635                opcional: false,
636                caracteristicas: vec![],
637            }],
638            deps_dev: vec![Dep {
639                nome: "devchild".into(),
640                versao: "0.1.0".into(),
641                fonte: Some(DepSource::Path {
642                    caminho: devchild_path.to_string_lossy().into_owned(),
643                }),
644                opcional: false,
645                caracteristicas: vec![],
646            }],
647            exe: vec![],
648            bibliotecas: vec![],
649            servicos: vec![],
650            limits: None,
651            behavior: None,
652            upgrade_from: vec![],
653            estrategia: None,
654            max_restarts: None,
655            restart_window: None,
656            children: vec![],
657            membros: vec![],
658            contratos: vec![],
659            politicas: None,
660            placement: None,
661            entrada: None,
662            ci: None,
663        };
664
665        let cache_root = td.path().join("cache");
666        std::fs::create_dir_all(&cache_root).unwrap();
667        let cache = CacheDir::at(&cache_root);
668
669        // include_dev=false: the closure-walker admits only `:deps`
670        // entries + their transitives. `:deps-dev` sibling must NOT
671        // appear, and the walker must have iterated `root.deps()`
672        // (surfacing `"child"`) and then `child.deps().to_vec()` inside
673        // `fetch_path` (surfacing `"grandchild"`).
674        let cfg = ResolverConfig::default();
675        let lacre = resolve_lacre(&root, &cfg, &cache).expect("resolve runtime-only closure");
676        let names: Vec<&str> = lacre.entradas.iter().map(|e| e.nome.as_str()).collect();
677        assert!(
678            names.contains(&"child"),
679            "resolve_lacre must surface `child` via root.deps() — got {names:?}"
680        );
681        assert!(
682            names.contains(&"grandchild"),
683            "resolve_lacre must surface `grandchild` via the transitive \
684             fetch_path target.deps().to_vec() walk — got {names:?}"
685        );
686        assert!(
687            !names.contains(&"devchild"),
688            "resolve_lacre must NOT surface `devchild` when \
689             include_dev=false (deps_dev accessor must be walked only \
690             under the include_dev cfg arm) — got {names:?}"
691        );
692
693        // include_dev=true: the closure-walker also admits `:deps-dev`
694        // entries. The `devchild` sibling must appear via
695        // `root.deps_dev()`.
696        let cfg_with_dev = ResolverConfig {
697            include_dev: true,
698            ..Default::default()
699        };
700        let lacre_with_dev =
701            resolve_lacre(&root, &cfg_with_dev, &cache).expect("resolve with dev closure");
702        let names_with_dev: Vec<&str> = lacre_with_dev
703            .entradas
704            .iter()
705            .map(|e| e.nome.as_str())
706            .collect();
707        assert!(
708            names_with_dev.contains(&"child"),
709            "include_dev=true closure must still surface `child` via \
710             root.deps() — got {names_with_dev:?}"
711        );
712        assert!(
713            names_with_dev.contains(&"grandchild"),
714            "include_dev=true closure must still surface `grandchild` \
715             via the transitive walk — got {names_with_dev:?}"
716        );
717        assert!(
718            names_with_dev.contains(&"devchild"),
719            "include_dev=true closure must surface `devchild` via \
720             root.deps_dev() — got {names_with_dev:?}"
721        );
722    }
723
724    /// Pin that both fetch-arm branches of the closure walker's per-
725    /// fetched-target [`FetchedDep`] emit surface — the [`fetch_path`]
726    /// arm and the [`fetch_git`] arm — carry each target's `:versao`
727    /// scalar into the resulting [`crate::LacreEntry`] via the typed
728    /// [`caixa_core::Caixa::versao`] `&str`-return accessor rather than a
729    /// raw `target.versao.clone()` field access. Byte-equal today
730    /// (`Caixa::versao` is `&self.versao`); catches any future emit-side
731    /// regression that reintroduces a raw field read and pins the two
732    /// [`FetchedDep::concrete_versao`] construction sites against a
733    /// hermetic tempdir-hosted three-caixa closure whose `:versao` bytes
734    /// distinguish each layer (root `"0.9.0"` → child `"0.5.2"` →
735    /// grandchild `"0.1.3"`), so a stub-that-hardcodes-a-fixed-string
736    /// regression trips on any layer.
737    ///
738    /// Peer of the sibling [`resolve_lacre_routes_dep_slot_family_through_caixa_accessors`]
739    /// per-`Caixa` `:deps` / `:deps-dev` slot-family pin on the sibling
740    /// per-`Caixa` `&[Dep]` slice-return accessor axis — same "the
741    /// emit path must route through the substrate-primitive typed
742    /// dispatch" discipline extended onto the outer top-level [`Caixa`]
743    /// `:versao` `&str`-return universal-axis at the closure-resolver's
744    /// per-fetched-target [`FetchedDep`] emit surface. Sibling to the
745    /// peer per-`Caixa` universal-axis converges every peer per-kind
746    /// renderer (caixa-helm eb912de, caixa-flux 2fc5f81, caixa-mesh
747    /// 980c059, caixa-tatara e73b19f, caixa-crd 41ab9a3) already routes
748    /// its `:versao` `String`-carry through.
749    #[test]
750    fn resolve_lacre_fetched_target_versao_routes_through_caixa_versao_accessor() {
751        let td = tempdir().expect("tempdir");
752        // Grandchild caixa on disk — `:versao "0.1.3"`.
753        let grandchild_path = td.path().join("grandchild");
754        std::fs::create_dir_all(&grandchild_path).unwrap();
755        std::fs::write(
756            grandchild_path.join("caixa.lisp"),
757            r#"(defcaixa
758                  :nome "grandchild"
759                  :versao "0.1.3"
760                  :kind Biblioteca
761                  :bibliotecas ("lib/grandchild.lisp"))"#,
762        )
763        .unwrap();
764
765        // Child caixa on disk — `:versao "0.5.2"`, depends on grandchild
766        // via Path so the closure walker traverses through
767        // [`fetch_path`] on both edges.
768        let child_path = td.path().join("child");
769        std::fs::create_dir_all(&child_path).unwrap();
770        let child_lisp = format!(
771            r#"(defcaixa
772                  :nome "child"
773                  :versao "0.5.2"
774                  :kind Biblioteca
775                  :bibliotecas ("lib/child.lisp")
776                  :deps ((:nome "grandchild" :versao "0.1.3"
777                          :fonte (:tipo path :caminho "{}"))))"#,
778            grandchild_path.display()
779        );
780        std::fs::write(child_path.join("caixa.lisp"), child_lisp).unwrap();
781
782        let root = Caixa {
783            nome: "root".into(),
784            versao: "0.9.0".into(),
785            kind: CaixaKind::Biblioteca,
786            edicao: None,
787            descricao: None,
788            repositorio: None,
789            licenca: None,
790            autores: vec![],
791            etiquetas: vec![],
792            deps: vec![Dep {
793                nome: "child".into(),
794                versao: "0.5.2".into(),
795                fonte: Some(DepSource::Path {
796                    caminho: child_path.to_string_lossy().into_owned(),
797                }),
798                opcional: false,
799                caracteristicas: vec![],
800            }],
801            deps_dev: vec![],
802            exe: vec![],
803            bibliotecas: vec![],
804            servicos: vec![],
805            limits: None,
806            behavior: None,
807            upgrade_from: vec![],
808            estrategia: None,
809            max_restarts: None,
810            restart_window: None,
811            children: vec![],
812            membros: vec![],
813            contratos: vec![],
814            politicas: None,
815            placement: None,
816            entrada: None,
817            ci: None,
818        };
819
820        let cache_root = td.path().join("cache");
821        std::fs::create_dir_all(&cache_root).unwrap();
822        let cache = CacheDir::at(&cache_root);
823        let cfg = ResolverConfig::default();
824        let lacre = resolve_lacre(&root, &cfg, &cache).expect("resolve closure");
825
826        // Every fetched target's `:versao` scalar must land in the
827        // resulting `LacreEntry.versao` byte-verbatim to what
828        // `Caixa::versao()` returns for the source caixa on disk. A
829        // regression that hardcoded a fixed string on either fetch-arm
830        // side of the closure walker trips on the layer whose byte-shape
831        // it drifted off.
832        let pairs: Vec<(&str, &str)> = lacre
833            .entradas
834            .iter()
835            .map(|e| (e.nome.as_str(), e.versao.as_str()))
836            .collect();
837        assert!(
838            pairs.contains(&("child", "0.5.2")),
839            "child entry must carry `:versao \"0.5.2\"` verbatim through \
840             fetch_path -> Caixa::versao() -> FetchedDep::concrete_versao \
841             -> LacreEntry.versao — got {pairs:?}"
842        );
843        assert!(
844            pairs.contains(&("grandchild", "0.1.3")),
845            "grandchild entry must carry `:versao \"0.1.3\"` verbatim \
846             through the transitive walk's Caixa::versao() accessor route \
847             — got {pairs:?}"
848        );
849    }
850
851    /// Pin that the closure-walker's queue-seeding parent-`:nome` label
852    /// axis — the `String` carried on the queue's `from` arm at both the
853    /// outer `for dep in root.deps()` runtime seed and the
854    /// `cfg.include_dev`-gated `for dep in root.deps_dev()` dev-only
855    /// seed inside [`resolve_lacre`] — routes through the typed
856    /// [`caixa_core::Caixa::nome`] `&str`-return accessor rather than a
857    /// raw `root.nome.clone()` field read. The queue's per-transitive-
858    /// target sibling push already routes the paired parent-`:nome`
859    /// label through the peer [`caixa_core::Dep::nome`] accessor
860    /// (`dep.nome().to_string()` at the transitive-walk head), so this
861    /// pin closes the last unlifted parent-`:nome` field-read axis in
862    /// the closure walker's queue-carried `from` surface.
863    ///
864    /// Byte-equal today (`Caixa::nome` is `&self.nome`; `.to_string()`
865    /// on the borrowed `&str` allocates exactly one `String` byte-equal
866    /// to the prior raw `.nome.clone()` read); pin catches any future
867    /// silent detour that reintroduces a raw field read at either queue-
868    /// seeding site. The hermetic tempdir fixture uses a byte-
869    /// distinctive `:nome` on the root (`"root-abc"` — pairing DNS-1123
870    /// legality with three ASCII bytes distinct from every child's
871    /// `:nome` so a stub-that-hardcodes-a-fixed-string regression on
872    /// either seeding site can only pass by accident), and asserts
873    /// [`caixa_core::Caixa::nome`] returns byte-verbatim to what
874    /// [`resolve_lacre`]'s queue-seed reads. The end-to-end assertion
875    /// on the resulting [`crate::LacreEntry`] set pins that both
876    /// `:deps` and `:deps-dev` walks reach their transitive targets
877    /// after the accessor-route substitution, so a regression that
878    /// broke the queue-seeding shape by dropping the second push arm
879    /// (e.g. mis-collapsing the two guarded arms onto one) surfaces
880    /// as a missing child in the resolved closure.
881    ///
882    /// Peer of the sibling [`resolve_lacre_routes_dep_slot_family_through_caixa_accessors`]
883    /// per-`:deps` / `:deps-dev` slot-family pin above and of the
884    /// [`resolve_lacre_fetched_target_versao_routes_through_caixa_versao_accessor`]
885    /// per-`:versao` axis pin (0556249) on the same
886    /// closure-walker emit surface — same "the emit path must route
887    /// through the substrate-primitive typed dispatch" discipline the
888    /// per-`Caixa` `.nome` universal-axis converges every peer per-kind
889    /// renderer already carry, extended onto the outer top-level
890    /// [`Caixa`] `:nome` `&str`-return universal-axis at the closure-
891    /// resolver's queue-seeded per-transitive-target `from` label.
892    #[test]
893    fn resolve_lacre_queue_seed_parent_nome_routes_through_caixa_nome_accessor() {
894        let td = tempdir().expect("tempdir");
895        // Runtime-child caixa on disk — no deps of its own, distinct
896        // `:nome` bytes so it can be identified in the resolved closure.
897        let child_path = td.path().join("child");
898        std::fs::create_dir_all(&child_path).unwrap();
899        std::fs::write(
900            child_path.join("caixa.lisp"),
901            r#"(defcaixa
902                  :nome "child"
903                  :versao "0.1.0"
904                  :kind Biblioteca
905                  :bibliotecas ("lib/child.lisp"))"#,
906        )
907        .unwrap();
908
909        // Dev-only sibling — distinct `:nome`, guards the second
910        // seeding-arm branch.
911        let devchild_path = td.path().join("devchild");
912        std::fs::create_dir_all(&devchild_path).unwrap();
913        std::fs::write(
914            devchild_path.join("caixa.lisp"),
915            r#"(defcaixa
916                  :nome "devchild"
917                  :versao "0.1.0"
918                  :kind Biblioteca
919                  :bibliotecas ("lib/devchild.lisp"))"#,
920        )
921        .unwrap();
922
923        let root = Caixa {
924            nome: "root-abc".into(),
925            versao: "0.9.0".into(),
926            kind: CaixaKind::Biblioteca,
927            edicao: None,
928            descricao: None,
929            repositorio: None,
930            licenca: None,
931            autores: vec![],
932            etiquetas: vec![],
933            deps: vec![Dep {
934                nome: "child".into(),
935                versao: "0.1.0".into(),
936                fonte: Some(DepSource::Path {
937                    caminho: child_path.to_string_lossy().into_owned(),
938                }),
939                opcional: false,
940                caracteristicas: vec![],
941            }],
942            deps_dev: vec![Dep {
943                nome: "devchild".into(),
944                versao: "0.1.0".into(),
945                fonte: Some(DepSource::Path {
946                    caminho: devchild_path.to_string_lossy().into_owned(),
947                }),
948                opcional: false,
949                caracteristicas: vec![],
950            }],
951            exe: vec![],
952            bibliotecas: vec![],
953            servicos: vec![],
954            limits: None,
955            behavior: None,
956            upgrade_from: vec![],
957            estrategia: None,
958            max_restarts: None,
959            restart_window: None,
960            children: vec![],
961            membros: vec![],
962            contratos: vec![],
963            politicas: None,
964            placement: None,
965            entrada: None,
966            ci: None,
967        };
968
969        // Substrate-primitive byte-parity pin: `Caixa::nome()` returns
970        // the raw `:nome` byte-string verbatim. Both queue-seeded push
971        // arms compose `root.nome().to_string()` off this accessor, so
972        // a future accessor extension (a canonicalization pass, an
973        // aliasing overlay) reaches both call sites through one edit.
974        assert_eq!(
975            root.nome(),
976            "root-abc",
977            "Caixa::nome() must return the :nome byte-string verbatim — \
978             pins the substrate contract both queue-seeded push arms \
979             compose the parent-`:nome` label off of"
980        );
981        assert_eq!(
982            root.nome().to_string(),
983            "root-abc",
984            "root.nome().to_string() must equal the raw :nome byte-string \
985             verbatim — pins the accessor-routed String-carry the queue's \
986             `from` arm receives at both seeding sites"
987        );
988
989        // End-to-end pin on the closure walker's two-arm seeding surface:
990        // both `:deps` and `:deps-dev` walks must reach their transitive
991        // targets after the accessor-route substitution. A regression
992        // that dropped either arm's queue-seed push surfaces here as a
993        // missing child in the resolved lacre.
994        let cache_root = td.path().join("cache");
995        std::fs::create_dir_all(&cache_root).unwrap();
996        let cache = CacheDir::at(&cache_root);
997        let cfg_with_dev = ResolverConfig {
998            include_dev: true,
999            ..Default::default()
1000        };
1001        let lacre =
1002            resolve_lacre(&root, &cfg_with_dev, &cache).expect("resolve closure with dev deps");
1003        let names: Vec<&str> = lacre.entradas.iter().map(|e| e.nome.as_str()).collect();
1004        assert!(
1005            names.contains(&"child"),
1006            "resolve_lacre with the accessor-routed queue-seed must \
1007             surface `child` via the runtime `:deps` arm — got {names:?}"
1008        );
1009        assert!(
1010            names.contains(&"devchild"),
1011            "resolve_lacre with the accessor-routed queue-seed must \
1012             surface `devchild` via the `cfg.include_dev`-gated \
1013             `:deps-dev` arm — got {names:?}"
1014        );
1015    }
1016}