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}