caixa_core/layout.rs
1//! Layout invariants — the Rust-enforced package structure.
2//!
3//! This is the caixa analog of Cargo's implicit `src/lib.rs` vs `src/main.rs`
4//! rule: the Rust type system dictates the package shape, and the invariant
5//! checker runs before any build step. [`StandardLayout`] encodes the
6//! canonical layout:
7//!
8//! - `caixa.lisp` — always required
9//! - `lib/<nome>.lisp` — required when `:kind Biblioteca` and
10//! `:bibliotecas` is empty
11//! - each `:bibliotecas` — must resolve on disk
12//! - each `:exe` — must resolve on disk, under `exe/`
13//! - each `:servicos` — must resolve on disk, under `servicos/`
14//!
15//! Filesystem I/O is injected through [`StandardLayout::with_path_exists`]
16//! so tests can run without touching disk.
17
18use std::path::{Path, PathBuf};
19use std::sync::Arc;
20
21use thiserror::Error;
22
23use crate::{Caixa, CaixaKind};
24
25/// Contract — a caixa layout checker.
26pub trait LayoutInvariants {
27 /// Verify every declared path resolves + kind-specific invariants hold.
28 fn verify(&self, caixa: &Caixa, root: &Path) -> Result<(), LayoutError>;
29}
30
31type ExistsFn = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
32
33/// The default layout contract.
34#[derive(Default, Clone)]
35pub struct StandardLayout {
36 path_exists: Option<ExistsFn>,
37}
38
39impl StandardLayout {
40 #[must_use]
41 pub fn new() -> Self {
42 Self::default()
43 }
44
45 /// Override how file existence is tested. Useful for in-memory tests.
46 #[must_use]
47 pub fn with_path_exists<F>(mut self, f: F) -> Self
48 where
49 F: Fn(&Path) -> bool + Send + Sync + 'static,
50 {
51 self.path_exists = Some(Arc::new(f));
52 self
53 }
54
55 fn exists(&self, p: &Path) -> bool {
56 self.path_exists
57 .as_ref()
58 .map_or_else(|| p.exists(), |f| f(p))
59 }
60}
61
62impl std::fmt::Debug for StandardLayout {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 f.debug_struct("StandardLayout")
65 .field("custom_exists", &self.path_exists.is_some())
66 .finish()
67 }
68}
69
70impl LayoutInvariants for StandardLayout {
71 fn verify(&self, caixa: &Caixa, root: &Path) -> Result<(), LayoutError> {
72 let manifest = root.join("caixa.lisp");
73 if !self.exists(&manifest) {
74 return Err(LayoutError::MissingManifest(manifest));
75 }
76
77 // Caixa-identity value-shape gates on the two universal axes
78 // (`:nome`, `:versao`) every substrate-side artifact's
79 // `metadata.name` / version derivation flows through. The
80 // [`Caixa::validate_nome`] / [`Caixa::validate_versao`] doc-
81 // comments name the canonical authoring footguns verbatim —
82 // `:nome` "MyApp" / "my_app" / "team.app" / "-app" / "café"
83 // (DNS-1123 violations the K8s apiserver refuses at admission
84 // time on every derived `metadata.name`: `lareira-<nome>`,
85 // programs.yaml entry, CiliumNetworkPolicy / HTTPRoute names,
86 // `LABEL_APLICACAO` value); `:versao` "0.1" / "v0.1.0" / "latest"
87 // / "^0.1" / "0.1.0.0" (SemVer-2 violations Helm / OCI tag /
88 // `feira publish` git tag / lacre `concrete_versao` /
89 // `:upgrade-from :from` peer matching each refuse downstream).
90 // Until this wire-up landed both validators existed as `pub fn`
91 // on [`Caixa`] (with full per-arm test coverage in
92 // `manifest::tests`) but no production code path called them —
93 // `feira build` (the canonical author-time gate) silently
94 // accepted a malformed `:nome` / `:versao` and the failure
95 // surfaced at `helm install` / `kubectl apply` / `feira publish`
96 // time on the *first* downstream consumer to strict-parse the
97 // value, far from the source `caixa.lisp` and without any field
98 // naming the offending Caixa identity axis. The gate runs
99 // *after* [`LayoutError::MissingManifest`] (no caixa to check
100 // when the manifest is missing) and *before* every kind-coherence
101 // gate (each of which carries `caixa.nome` verbatim in its
102 // diagnostic — running them first on a structurally-invalid
103 // identity would surface a "this kind has slot X" diagnostic
104 // against an unrecoverable name). Cross-axis precedence is
105 // `:nome` → `:versao` — the canonical declaration order on
106 // [`Caixa`] and the same author-grep ordering the
107 // [`ManifestError`] family uses. Same per-axis `*Violation
108 // { caixa, issue }` envelope every peer per-axis wrap exposes
109 // ([`LayoutError::CodePathViolation`] b868442,
110 // [`LayoutError::LimitsViolation`] / [`LayoutError::BehaviorViolation`]
111 // / [`LayoutError::UpgradeViolation`] / [`LayoutError::SupervisorViolation`]
112 // / [`LayoutError::AplicacaoViolation`]).
113 caixa
114 .validate_nome()
115 .map_err(|err| LayoutError::NomeViolation {
116 caixa: caixa.nome().to_string(),
117 issue: err.to_string(),
118 })?;
119 // `:nome`-side joint-length budget on the canonical
120 // `lareira-<nome>` chart-name shape — the second arm on the
121 // shared `:nome` axis after the bare-DNS-1123 gate above. Runs
122 // through the same [`LayoutError::NomeViolation`] envelope so
123 // every per-axis diagnostic on `:nome` carries one wrap shape,
124 // peer with the [`Caixa::validate_nome`] → `NomeInvalid`
125 // routing already at this site. The chart-name budget is the
126 // second-axis ceiling [`Caixa::validate_nome`] cannot see — a
127 // 56-byte DNS-1123-valid `:nome` passes the bare-`:nome` shape
128 // but produces a 64-byte `lareira-<nome>` chart name the
129 // apiserver / `helm lint` rejects at admission, far from the
130 // source `caixa.lisp` and naming none of the joint-length
131 // overflow's three carriers (DNS-1123 cap, prefix, `:nome`
132 // length). Closing it at this wire-up turns the
133 // [`lareira_chart_name`] doc-comment's explicit M4-admission
134 // deferral (caixa-core/src/render.rs:3198) into a build-time
135 // structural property of every emitted artifact.
136 caixa
137 .validate_nome_chart_name_budget()
138 .map_err(|err| LayoutError::NomeViolation {
139 caixa: caixa.nome().to_string(),
140 issue: err.to_string(),
141 })?;
142 caixa
143 .validate_versao()
144 .map_err(|err| LayoutError::VersaoViolation {
145 caixa: caixa.nome().to_string(),
146 issue: err.to_string(),
147 })?;
148
149 // `:deps` / `:deps-dev` per-entry shape gate. The third Caixa-
150 // level orphan validator on the universal authoring surface (peer
151 // of [`Caixa::validate_nome`] / [`Caixa::validate_versao`] wired
152 // immediately above): [`Caixa::validate_deps`] walks every
153 // [`Dep::validate`] arm — empty / non-DNS-1123 `:nome`, empty /
154 // unparseable `:versao` requirement, malformed `:fonte` repo /
155 // pin / `:caminho`, malformed `:caracteristicas` Cargo-feature
156 // name (de68c0c) — and then closes the per-list set-not-multiset
157 // duplicate-`:nome` invariant on each of `:deps` and `:deps-dev`
158 // (359fba5). Until this wire-up landed `validate_deps` existed as
159 // `pub fn` on [`Caixa`] with full per-arm unit coverage in
160 // `manifest::tests` + `dep::tests` (validate_deps_rejects_*,
161 // 53 dep-axis tests) but no production code path called it —
162 // `feira build` (the canonical author-time gate;
163 // `caixa-feira/src/cmd/build.rs:29` routes through
164 // `StandardLayout::verify`) silently accepted a malformed `:deps`
165 // entry and the failure surfaced at the *first* downstream
166 // consumer to strict-parse it: at lacre-resolve time as a
167 // `semver::Error` not naming the offending dep (`:versao` per-
168 // entry); at `git clone` time as a fetch failure quoting the
169 // shell-escape `repo` (`:fonte :repo`); at the resolver's
170 // `HashMap<:nome>` collapse as a silent "second-wins" overwrite
171 // (within-list `:nome` duplicate); at `cargo metadata` time as a
172 // feature-name rejection on the *target* caixa rather than the
173 // dep entry referencing it (`:caracteristicas`); at `helm
174 // install` / `kubectl apply` time as an apiserver `metadata.name`
175 // rejection on the rendered `lareira-<nome>` chart's per-dep
176 // derivation (DNS-1123-violating `:deps :nome`) — each far from
177 // the source `caixa.lisp`, none naming the offending `:deps` /
178 // `:deps-dev` axis. Runs *after* the Caixa-identity gates (the
179 // diagnostic carries `caixa.nome().to_string()` verbatim, which the
180 // peer [`Caixa::validate_nome`] gate above has just guaranteed is
181 // a valid DNS-1123 label) and *before* every kind-coherence gate
182 // (the dep surface is universal — every kind has `:deps` /
183 // `:deps-dev` — so its shape diagnostic is more fundamental than
184 // the kind-coherence partitions on `:bibliotecas` / `:exe` /
185 // `:servicos` / `:membros` / `:children` / M2 slots that follow).
186 // Same per-axis `*Violation { caixa, issue }` envelope every peer
187 // per-axis wrap exposes ([`LayoutError::NomeViolation`] /
188 // [`LayoutError::VersaoViolation`] (1f74a5f),
189 // [`LayoutError::CodePathViolation`] (b868442),
190 // [`LayoutError::LimitsViolation`] / [`LayoutError::BehaviorViolation`]
191 // / [`LayoutError::UpgradeViolation`] / [`LayoutError::SupervisorViolation`]
192 // / [`LayoutError::AplicacaoViolation`]). Threads [`DepError`]
193 // Display through verbatim — every per-arm reason already names
194 // the offending dep's `:nome` (e.g. `":deps entry "caixa-teia"
195 // :versao "^bad" is not a valid semver requirement: …"`), so the
196 // wrap envelope's `issue` carries a self-locating "which dep,
197 // which axis, why" without re-shaping the per-arm parser-side
198 // reason. With this wire-up the canonical author-time gate
199 // refuses every ill-formed `:deps` / `:deps-dev` value-shape by
200 // construction — closing the second-to-last orphan-validator gap
201 // on the typed Caixa surface (`validate_restart_window` is the
202 // remaining orphan, Supervisor-axis specific and wired into the
203 // Supervisor branch below alongside `view.validate()`).
204 // Compound per-Caixa entry gate on the dep-graph axis: the
205 // layout pipeline's two-dispatch `:deps` / `:deps-dev` cascade
206 // — the per-entry + within-list duplicate-`:nome` gate (the
207 // [`crate::Dep::validate`] + [`crate::render::insert_first_seen`]
208 // cascade `Caixa::validate_deps` opened on, 359fba5) and the
209 // cross-slot self-edge gate
210 // ([`crate::dep::validate_no_self_dep`], ad4abf1) — folded
211 // onto the [`crate::Caixa::validate_deps`] substrate primitive.
212 // The two arms run in the same canonical order at the primitive
213 // (per-entry + cross-entry duplicate → cross-slot self-edge) so
214 // the fold is byte-for-byte equivalent to the pre-fold
215 // two-block cascade this call site formerly carried, pinned by
216 // the paired
217 // `validate_deps_folds_{per_entry,self_edge}_arm_matches_gate`
218 // equivalence pins and the
219 // `validate_deps_per_entry_arm_fires_before_self_edge_arm`
220 // ordering pin in the [`crate::Caixa::validate_deps`] pin
221 // family (`manifest.rs`).
222 //
223 // Same lift discipline the peer per-slot compound gates
224 // ([`crate::AplicacaoSpec::validate_contratos`] and its
225 // `:membros` / `:entrada` / `:placement` / `:politicas` peers,
226 // [`crate::MeshPolicy::validate`],
227 // [`crate::SupervisorSpec::validate_children`],
228 // [`crate::Caixa::validate_upgrade_from`] d6801df) each carry —
229 // one named substrate-primitive gate per typed slot folds every
230 // structural + cross-slot axis on that slot onto one call, so
231 // every future consumer that wants to re-check the dep-graph
232 // after a per-entry patch (the deferred
233 // `caixa.pleme.io/v1alpha1/Caixa` CR materializer's admission
234 // webhook, a future `feira validate --deps` per-caixa admission
235 // verb, a per-`:deps` overlay resolver) reaches the two-arm
236 // compound gate through one dispatch rather than re-inlining
237 // the two-dispatch cascade in lockstep with this wire-up.
238 caixa
239 .validate_deps()
240 .map_err(|err| LayoutError::DepsViolation {
241 caixa: caixa.nome().to_string(),
242 issue: err.to_string(),
243 })?;
244
245 // `:etiquetas` per-entry empty + cross-entry duplicate gate. The
246 // fourth universal-axis Caixa-level value-shape gate (peer of
247 // [`Caixa::validate_nome`] / [`Caixa::validate_versao`] /
248 // [`Caixa::validate_deps`] wired immediately above and
249 // [`Caixa::validate_code_paths`] wired below the kind-coherence
250 // gates) on the typed Caixa surface. `:etiquetas` is the
251 // registry-search-tag axis every kind carries (universal
252 // `Vec<String>` slot on [`Caixa`]) and lands verbatim as the
253 // Helm chart `Chart.yaml` `keywords:` array on every Servico
254 // (`caixa-helm/src/lib.rs:236` folds it through a
255 // [`std::collections::BTreeSet`]). Until this wire-up landed
256 // `:etiquetas` had no shape gate at any layer — an empty entry
257 // (`(:etiquetas (""))` — the canonical paste-from-blank-doc
258 // footgun) silently rendered as `keywords: [""]` in `Chart.yaml`,
259 // and duplicate entries (`(:etiquetas ("demo" "demo"))` — the
260 // copy-paste-the-wrong-tag footgun) were silently dedup'd by
261 // the renderer's `BTreeSet` collect — a "second wins / one
262 // silently disappears" shape divergent from every peer typed-
263 // graph set gate (`:membros :caixa`, `:placement :clusters`,
264 // `:entrada :paths`, `:contratos`, `:deps :nome`,
265 // `:upgrade-from :from`, the per-instruction-class singularity
266 // gates on `:upgrade-from :instructions`). Runs *after* the
267 // peer universal `:nome` / `:versao` / `:deps` gates (declaration
268 // order on [`Caixa`] is `:nome` → `:versao` → `:edicao` →
269 // `:descricao` → `:repositorio` → `:licenca` → `:autores` →
270 // `:etiquetas` → `:deps` → `:deps-dev`, but the gate order
271 // follows the same identity-axis-first cascade the peer gates
272 // establish: `:nome` → `:versao` are the load-bearing identity
273 // axes that flow into every diagnostic's caixa prefix, and
274 // `:deps` is the universal dep surface that dominates every
275 // kind-coherence gate; `:etiquetas` runs after this trio so the
276 // diagnostic carries an already-validated `:nome` and the
277 // peer universal axes' narrower diagnostics surface first when
278 // multiple axes are malformed) and *before* the kind-coherence
279 // gates ([`Self::MeshSlotsOnNonAplicacao`] /
280 // [`Self::SupervisorSlotsOnNonSupervisor`] /
281 // [`Self::ServicoSlotsOnNonServico`] / [`Self::ForeignCodeSlot`])
282 // — `:etiquetas` is universal so its shape diagnostic is more
283 // fundamental than the kind-coherence partitions on kind-
284 // exclusive slot sets.
285 //
286 // Same per-axis `*Violation { caixa, issue }` envelope every peer
287 // per-axis wrap exposes ([`Self::NomeViolation`] /
288 // [`Self::VersaoViolation`] 1f74a5f, [`Self::DepsViolation`]
289 // aa77d0f, [`Self::CodePathViolation`] b868442,
290 // [`Self::RestartWindowViolation`] 10e321a). Threads
291 // [`ManifestError::EtiquetaEmpty`] / [`ManifestError::EtiquetaDuplicate`]
292 // Display through verbatim — each per-arm reason already names
293 // the offending tag (for the duplicate arm) or the structural
294 // "empty entry" defect (for the empty arm), so the wrap
295 // envelope's `issue` carries a self-locating "which axis, which
296 // entry, why" without re-shaping the per-arm reason.
297 caixa
298 .validate_etiquetas()
299 .map_err(|err| LayoutError::EtiquetasViolation {
300 caixa: caixa.nome().to_string(),
301 issue: err.to_string(),
302 })?;
303
304 // `:autores` per-entry empty + cross-entry duplicate gate. The
305 // fifth universal-axis Caixa-level value-shape gate (peer of
306 // [`Caixa::validate_nome`] / [`Caixa::validate_versao`] /
307 // [`Caixa::validate_deps`] / [`Caixa::validate_etiquetas`] wired
308 // immediately above and [`Caixa::validate_code_paths`] wired
309 // below the kind-coherence gates) on the typed Caixa surface.
310 // `:autores` is the maintainer-axis every kind carries
311 // (universal `Vec<String>` slot on [`Caixa`]) and lands verbatim
312 // as the Helm chart `Chart.yaml` `maintainers:` array on every
313 // Servico (`caixa-helm/src/lib.rs:251` maps each entry to a
314 // `Maintainer { name, email: None }` without dedup). Until this
315 // wire-up landed `:autores` had no shape gate at any layer — an
316 // empty entry (`(:autores (""))` — the canonical paste-from-
317 // blank-doc footgun) silently rendered as
318 // `maintainers: [{name: "", email: null}]` in `Chart.yaml`, and
319 // duplicate entries (`(:autores ("pleme-io" "pleme-io"))` —
320 // the copy-paste-the-wrong-author footgun) stacked verbatim in
321 // the chart. Unlike the peer `:etiquetas` axis (where the
322 // renderer's `BTreeSet` collect silently dedups the `keywords:`
323 // array at chart render — a "second wins / one silently
324 // disappears" shape), `maintainers:` has *no* renderer-side
325 // dedup, so duplicate `:autores` entries render as two identical
326 // maintainer records by construction — a strictly worse footgun
327 // than the peer `:etiquetas` shape. Runs *after* the peer
328 // universal `:nome` / `:versao` / `:deps` / `:etiquetas` gates
329 // (the gate order follows the canonical identity-axis-first
330 // cascade the peer gates establish; `:autores` and `:etiquetas`
331 // are the two Vec-shaped universal metadata axes — they sit
332 // adjacent in the cascade after the load-bearing identity +
333 // dep trio) and *before* the kind-coherence gates
334 // ([`Self::MeshSlotsOnNonAplicacao`] /
335 // [`Self::SupervisorSlotsOnNonSupervisor`] /
336 // [`Self::ServicoSlotsOnNonServico`] / [`Self::ForeignCodeSlot`])
337 // — `:autores` is universal so its shape diagnostic is more
338 // fundamental than the kind-coherence partitions on kind-
339 // exclusive slot sets.
340 //
341 // Same per-axis `*Violation { caixa, issue }` envelope every peer
342 // per-axis wrap exposes ([`Self::NomeViolation`] /
343 // [`Self::VersaoViolation`] 1f74a5f, [`Self::DepsViolation`]
344 // aa77d0f, [`Self::EtiquetasViolation`] 360a499,
345 // [`Self::CodePathViolation`] b868442,
346 // [`Self::RestartWindowViolation`] 10e321a). Threads
347 // [`ManifestError::AutorEmpty`] / [`ManifestError::AutorDuplicate`]
348 // Display through verbatim — each per-arm reason already names
349 // the offending author (for the duplicate arm) or the structural
350 // "empty entry" defect (for the empty arm), so the wrap
351 // envelope's `issue` carries a self-locating "which axis, which
352 // entry, why" without re-shaping the per-arm reason.
353 caixa
354 .validate_autores()
355 .map_err(|err| LayoutError::AutoresViolation {
356 caixa: caixa.nome().to_string(),
357 issue: err.to_string(),
358 })?;
359
360 // `:repositorio` git-repo-URL shape gate. The sixth
361 // universal-axis Caixa-level value-shape gate (peer of
362 // [`Caixa::validate_nome`] / [`Caixa::validate_versao`] /
363 // [`Caixa::validate_deps`] / [`Caixa::validate_etiquetas`] /
364 // [`Caixa::validate_autores`] wired immediately above and
365 // [`Caixa::validate_code_paths`] wired below the kind-coherence
366 // gates) on the typed Caixa surface. `:repositorio` is the
367 // universal git-shaped homepage axis every kind carries
368 // (universal `Option<String>` slot on [`Caixa`]) and routes
369 // through two load-bearing substrate consumers:
370 // [`caixa-helm`] folds it verbatim into the rendered
371 // `lareira-<nome>` Helm chart's `Chart.yaml` `home:` field
372 // (`build_chart_yaml` at `caixa-helm/src/lib.rs:268`) and into
373 // the chart `README.md` `repo = …` interpolation
374 // (`caixa-helm/src/lib.rs:359`); [`caixa-flux`] folds it
375 // verbatim into the standalone `ClusterBundleOpts::for_caixa`
376 // `git_url:` field (`caixa-flux/src/lib.rs:293`), which
377 // becomes the FluxCD `GitRepository.spec.url` the cluster's
378 // source-controller polls — the load-bearing deploy-time axis.
379 // Both consumers use `Option::unwrap_or_else(|| <fallback>)`
380 // to substitute a placeholder when the slot is absent (`None`
381 // → the fallback fires); a `Some("")` *skips the fallback*
382 // and silently passes the empty string through to
383 // `Chart.yaml home: ""` / `GitRepository url: ""`. Until this
384 // wire-up landed `:repositorio` had no shape gate at any
385 // layer — empty (`(:repositorio "")` — the canonical
386 // paste-from-blank-doc footgun) and malformed (whitespace,
387 // control char / CRLF, leading `-` CLI-arg-injection,
388 // missing `:` separator) values silently landed in the
389 // rendered artifacts and broke at `helm template` / FluxCD
390 // reconcile time far from the source `caixa.lisp`.
391 //
392 // Runs *after* the peer universal `:nome` / `:versao` /
393 // `:deps` / `:etiquetas` / `:autores` gates (the gate order
394 // follows the canonical identity-axis-first cascade the peer
395 // gates establish; `:repositorio` is the universal git-URL
396 // axis — it sits adjacent to `:autores` in the cascade after
397 // the load-bearing identity + dep trio + the two Vec-shaped
398 // universal metadata axes) and *before* the kind-coherence
399 // gates ([`Self::MeshSlotsOnNonAplicacao`] /
400 // [`Self::SupervisorSlotsOnNonSupervisor`] /
401 // [`Self::ServicoSlotsOnNonServico`] / [`Self::ForeignCodeSlot`])
402 // — `:repositorio` is universal so its shape diagnostic is
403 // more fundamental than the kind-coherence partitions on
404 // kind-exclusive slot sets.
405 //
406 // Same per-axis `*Violation { caixa, issue }` envelope every
407 // peer per-axis wrap exposes ([`Self::NomeViolation`] /
408 // [`Self::VersaoViolation`] 1f74a5f, [`Self::DepsViolation`]
409 // aa77d0f, [`Self::EtiquetasViolation`] 360a499,
410 // [`Self::AutoresViolation`] 86c769b, [`Self::CodePathViolation`]
411 // b868442, [`Self::RestartWindowViolation`] 10e321a). Threads
412 // [`ManifestError::RepositorioEmpty`] /
413 // [`ManifestError::RepositorioInvalid`] Display through
414 // verbatim — each per-arm reason already names the offending
415 // `:repositorio` value (for the invalid arm) or the
416 // structural "empty entry" defect (for the empty arm), so
417 // the wrap envelope's `issue` carries a self-locating "which
418 // axis, which value, why" without re-shaping the per-arm
419 // reason. With this gate the two `git URL`-shaped surfaces on
420 // the typed Caixa (`:repositorio` here, `:deps :fonte :repo`
421 // peer routed through the same shared
422 // [`crate::render::is_git_repo_url`] predicate via
423 // [`crate::DepSource::validate`]) are now structurally
424 // equivalent — every value past validate is
425 // guaranteed-acceptable by the shared predicate's constraint
426 // union, by construction.
427 caixa
428 .validate_repositorio()
429 .map_err(|err| LayoutError::RepositorioViolation {
430 caixa: caixa.nome().to_string(),
431 issue: err.to_string(),
432 })?;
433
434 // `:descricao` non-empty shape gate. The seventh universal-
435 // axis Caixa-level value-shape gate (peer of
436 // [`Caixa::validate_nome`] / [`Caixa::validate_versao`] /
437 // [`Caixa::validate_deps`] / [`Caixa::validate_etiquetas`] /
438 // [`Caixa::validate_autores`] / [`Caixa::validate_repositorio`]
439 // wired immediately above and [`Caixa::validate_code_paths`]
440 // wired below the kind-coherence gates) on the typed Caixa
441 // surface. `:descricao` is the universal free-form-prose
442 // summary axis every kind carries (universal `Option<String>`
443 // slot on [`Caixa`]) and routes through two load-bearing
444 // [`caixa-helm`] consumers: `build_chart_yaml` folds it
445 // verbatim into the rendered `lareira-<nome>` Helm chart's
446 // `Chart.yaml` `description:` field
447 // (`caixa-helm/src/lib.rs:232-235`), and `build_readme` folds
448 // it verbatim into the chart `README.md` header
449 // (`caixa-helm/src/lib.rs:333-336`). Both consumers use
450 // `Option::unwrap_or_else(|| <fallback>)` to substitute a
451 // `caixa.nome`-derived placeholder when the slot is absent
452 // (`None` → the fallback fires); a `Some("")` *skips the
453 // fallback* and silently passes the empty string through to
454 // `Chart.yaml description: ""` / a blank `README.md` header
455 // — exact same footgun shape as the peer `:repositorio`
456 // surface above. Until this wire-up landed `:descricao` had
457 // no shape gate at any layer — the empty
458 // (`(:descricao "")` — the canonical paste-from-blank-doc
459 // footgun) silently landed in the rendered artifacts and
460 // broke at `helm lint` time (`WARNING [chart.metadata.description]:
461 // description is required` on `apiVersion: v2` charts) far
462 // from the source `caixa.lisp`.
463 //
464 // Runs *after* the peer universal `:nome` / `:versao` /
465 // `:deps` / `:etiquetas` / `:autores` / `:repositorio` gates
466 // (the gate order follows the canonical identity-axis-first
467 // cascade the peer gates establish; `:descricao` is the
468 // universal free-form-prose axis — it sits adjacent to
469 // `:repositorio` in the cascade after the load-bearing
470 // identity + dep trio + the two Vec-shaped universal
471 // metadata axes + the universal git-URL axis) and *before*
472 // the kind-coherence gates ([`Self::MeshSlotsOnNonAplicacao`]
473 // / [`Self::SupervisorSlotsOnNonSupervisor`] /
474 // [`Self::ServicoSlotsOnNonServico`] /
475 // [`Self::ForeignCodeSlot`]) — `:descricao` is universal so
476 // its shape diagnostic is more fundamental than the kind-
477 // coherence partitions on kind-exclusive slot sets.
478 //
479 // Same per-axis `*Violation { caixa, issue }` envelope every
480 // peer per-axis wrap exposes ([`Self::NomeViolation`] /
481 // [`Self::VersaoViolation`] 1f74a5f, [`Self::DepsViolation`]
482 // aa77d0f, [`Self::EtiquetasViolation`] 360a499,
483 // [`Self::AutoresViolation`] 86c769b,
484 // [`Self::RepositorioViolation`] 577b0a9,
485 // [`Self::CodePathViolation`] b868442,
486 // [`Self::RestartWindowViolation`] 10e321a). Threads
487 // [`ManifestError::DescricaoEmpty`] Display through verbatim
488 // — the per-arm reason already names the offending
489 // `:descricao` slot + cites the renderer-side footgun, so
490 // the wrap envelope's `issue` carries a self-locating
491 // "which axis, why" without re-shaping the per-arm reason.
492 caixa
493 .validate_descricao()
494 .map_err(|err| LayoutError::DescricaoViolation {
495 caixa: caixa.nome().to_string(),
496 issue: err.to_string(),
497 })?;
498
499 // `:licenca` non-empty shape gate. The eighth universal-axis
500 // Caixa-level value-shape gate (peer of [`Caixa::validate_nome`]
501 // / [`Caixa::validate_versao`] / [`Caixa::validate_deps`] /
502 // [`Caixa::validate_etiquetas`] / [`Caixa::validate_autores`] /
503 // [`Caixa::validate_repositorio`] / [`Caixa::validate_descricao`]
504 // wired immediately above and [`Caixa::validate_code_paths`]
505 // wired below the kind-coherence gates) on the typed Caixa
506 // surface. `:licenca` is the universal SPDX-shaped license-
507 // expression axis every kind carries (universal `Option<String>`
508 // slot on [`Caixa`]) and routes through one load-bearing
509 // [`caixa-helm`] consumer: `build_readme` folds it verbatim into
510 // the rendered `lareira-<nome>` Helm chart's `README.md` `##
511 // License` section (`caixa-helm/src/lib.rs:361`) via
512 // `caixa.licenca.clone().unwrap_or_else(|| "MIT".into())`. The
513 // consumer's fallback only fires when the slot is absent (`None`
514 // → the `MIT` fallback fires); a `Some("")` *skips the
515 // fallback* and silently passes the empty string through to a
516 // chart `README.md` whose `License` section renders as a bare
517 // trailing period — exact same footgun shape as the peer
518 // `:repositorio` (577b0a9) and `:descricao` (4e6db38) surfaces
519 // above. Until this wire-up landed `:licenca` had no shape
520 // gate at any layer — the empty (`(:licenca "")` — the
521 // canonical paste-from-blank-doc footgun) silently landed in
522 // the rendered chart `README.md` far from the source
523 // `caixa.lisp`.
524 //
525 // Runs *after* the peer universal `:nome` / `:versao` /
526 // `:deps` / `:etiquetas` / `:autores` / `:repositorio` /
527 // `:descricao` gates (the gate order follows the canonical
528 // identity-axis-first cascade the peer gates establish;
529 // `:licenca` sits adjacent to `:descricao` in the cascade
530 // after the load-bearing identity + dep trio + the two
531 // Vec-shaped universal metadata axes + the universal
532 // git-URL + free-form-prose axes) and *before* the kind-
533 // coherence gates ([`Self::MeshSlotsOnNonAplicacao`] /
534 // [`Self::SupervisorSlotsOnNonSupervisor`] /
535 // [`Self::ServicoSlotsOnNonServico`] /
536 // [`Self::ForeignCodeSlot`]) — `:licenca` is universal so
537 // its shape diagnostic is more fundamental than the kind-
538 // coherence partitions on kind-exclusive slot sets.
539 //
540 // Same per-axis `*Violation { caixa, issue }` envelope every
541 // peer per-axis wrap exposes ([`Self::NomeViolation`] /
542 // [`Self::VersaoViolation`] 1f74a5f, [`Self::DepsViolation`]
543 // aa77d0f, [`Self::EtiquetasViolation`] 360a499,
544 // [`Self::AutoresViolation`] 86c769b,
545 // [`Self::RepositorioViolation`] 577b0a9,
546 // [`Self::DescricaoViolation`] 4e6db38,
547 // [`Self::CodePathViolation`] b868442,
548 // [`Self::RestartWindowViolation`] 10e321a). Threads
549 // [`ManifestError::LicencaEmpty`] Display through verbatim
550 // — the per-arm reason already names the offending
551 // `:licenca` slot + cites the renderer-side footgun, so
552 // the wrap envelope's `issue` carries a self-locating
553 // "which axis, why" without re-shaping the per-arm reason.
554 caixa
555 .validate_licenca()
556 .map_err(|err| LayoutError::LicencaViolation {
557 caixa: caixa.nome().to_string(),
558 issue: err.to_string(),
559 })?;
560
561 // `:edicao` non-empty shape gate. The ninth (and last
562 // un-gated) universal-axis Caixa-level value-shape gate
563 // (peer of [`Caixa::validate_nome`] / [`Caixa::validate_versao`]
564 // / [`Caixa::validate_deps`] / [`Caixa::validate_etiquetas`] /
565 // [`Caixa::validate_autores`] / [`Caixa::validate_repositorio`]
566 // / [`Caixa::validate_descricao`] / [`Caixa::validate_licenca`]
567 // wired immediately above and [`Caixa::validate_code_paths`]
568 // wired below the kind-coherence gates) on the typed Caixa
569 // surface. `:edicao` is the universal language-edition axis
570 // every kind carries (universal `Option<String>` slot on
571 // [`Caixa`]) that selects the tatara-lisp macro surface +
572 // compatibility flags the substrate applies when building
573 // the caixa. The canonical [`Caixa::template`] scaffold every
574 // `feira init` emits carries `:edicao "2026"` verbatim
575 // (`caixa-core/src/manifest.rs:1193`) and every renderer-side
576 // fixture carries `edicao: Some("2026".into())` by
577 // construction (`caixa-helm/src/lib.rs:375`,
578 // `caixa-flux/src/lib.rs:445`, `caixa-mesh/src/lib.rs:629`,
579 // `caixa-core/src/render.rs:2510`). Until this wire-up landed
580 // `:edicao` had no shape gate at any layer — the empty
581 // (`(:edicao "")` — the canonical paste-from-blank-doc
582 // footgun) silently landed as a bare `(:edicao "")` line
583 // in the rendered caixa.lisp and a future renderer-side
584 // consumer that folds the value through
585 // `Option::unwrap_or_else` would skip its fallback (which
586 // only fires on `None`) and pass the empty edition through
587 // to the substrate's build-time edition selector far from
588 // the source `caixa.lisp` — exact same
589 // `Some("")`-skips-`unwrap_or_else` footgun shape as the
590 // peer `:repositorio` (577b0a9), `:descricao` (4e6db38),
591 // and `:licenca` (3d1e535) surfaces above.
592 //
593 // Runs *after* the peer universal `:nome` / `:versao` /
594 // `:deps` / `:etiquetas` / `:autores` / `:repositorio` /
595 // `:descricao` / `:licenca` gates (the gate order follows
596 // the canonical identity-axis-first cascade the peer gates
597 // establish; `:edicao` sits at the tail of the cascade
598 // after the load-bearing identity + dep trio + the two
599 // Vec-shaped universal metadata axes + the three universal
600 // `Option<String>` chart-metadata axes) and *before* the
601 // kind-coherence gates ([`Self::MeshSlotsOnNonAplicacao`] /
602 // [`Self::SupervisorSlotsOnNonSupervisor`] /
603 // [`Self::ServicoSlotsOnNonServico`] /
604 // [`Self::ForeignCodeSlot`]) — `:edicao` is universal so
605 // its shape diagnostic is more fundamental than the kind-
606 // coherence partitions on kind-exclusive slot sets.
607 //
608 // Same per-axis `*Violation { caixa, issue }` envelope every
609 // peer per-axis wrap exposes ([`Self::NomeViolation`] /
610 // [`Self::VersaoViolation`] 1f74a5f, [`Self::DepsViolation`]
611 // aa77d0f, [`Self::EtiquetasViolation`] 360a499,
612 // [`Self::AutoresViolation`] 86c769b,
613 // [`Self::RepositorioViolation`] 577b0a9,
614 // [`Self::DescricaoViolation`] 4e6db38,
615 // [`Self::LicencaViolation`] 3d1e535,
616 // [`Self::CodePathViolation`] b868442,
617 // [`Self::RestartWindowViolation`] 10e321a). Threads
618 // [`ManifestError::EdicaoEmpty`] Display through verbatim
619 // — the per-arm reason already names the offending
620 // `:edicao` slot + cites the renderer-side footgun, so
621 // the wrap envelope's `issue` carries a self-locating
622 // "which axis, why" without re-shaping the per-arm reason.
623 // With this gate every universal-axis `Option<String>`
624 // surface on the typed Caixa (`:repositorio` 577b0a9,
625 // `:descricao` 4e6db38, `:licenca` 3d1e535, `:edicao` here)
626 // now carries the same structural empty-arm gate by
627 // construction.
628 caixa
629 .validate_edicao()
630 .map_err(|err| LayoutError::EdicaoViolation {
631 caixa: caixa.nome().to_string(),
632 issue: err.to_string(),
633 })?;
634
635 // Supervisors, Aplicacaos, and Acaos don't run code; reject
636 // bibliotecas/exe/servicos declarations BEFORE checking those
637 // paths exist (which would otherwise produce a less-helpful
638 // "missing entry" error first).
639 let has_code = !caixa.bibliotecas().is_empty()
640 || !caixa.exe().is_empty()
641 || !caixa.servicos().is_empty();
642 if caixa.kind().is_supervisor() && has_code {
643 return Err(LayoutError::SupervisorOwnsCode(caixa.nome().to_string()));
644 }
645 if caixa.kind().is_aplicacao() && has_code {
646 return Err(LayoutError::AplicacaoOwnsCode(caixa.nome().to_string()));
647 }
648 // An Acao's sole payload is its `:ci` slot (CANTEIRO §7.1-C) —
649 // like Supervisor/Aplicacao it runs no code of its own, so a
650 // declared :bibliotecas/:exe/:servicos is the same "silently
651 // ignored" footgun the two gates above already close for the
652 // other two no-code kinds.
653 if caixa.kind().is_acao() && has_code {
654 return Err(LayoutError::AcaoOwnsCode(caixa.nome().to_string()));
655 }
656
657 // Kind ↔ slot coherence: the M3 mesh slots (:membros,
658 // :contratos, :politicas, :placement, :entrada) compose the
659 // typed graph of a :kind Aplicacao (MESH-COMPOSITION §III.1).
660 // `Caixa::aplicacao_view` only folds them into a validatable
661 // AplicacaoSpec when the kind is Aplicacao, and the
662 // caixa-mesh/-flux/-helm renderers only emit them for an
663 // Aplicacao — so on any *other* kind a declared mesh slot is the
664 // manifest field's documented "ignored otherwise": it silently
665 // passes verify and then vanishes (never validated, never
666 // rendered), far from the source caixa.lisp. Reject it here —
667 // before the path-existence loops — mirroring the
668 // SupervisorOwnsCode / AplicacaoOwnsCode kind-coherence gates
669 // above: a slot foreign to the kind is a build error, not a
670 // silent drop. `declared_mesh_slots` is the single typed source
671 // of the mesh-slot set + its canonical diagnostic order.
672 if !caixa.kind().is_aplicacao() {
673 let mesh_slots = caixa.declared_mesh_slots();
674 if !mesh_slots.is_empty() {
675 return Err(LayoutError::MeshSlotsOnNonAplicacao {
676 caixa: caixa.nome().to_string(),
677 kind: caixa.kind(),
678 slots: mesh_slots.join(" "),
679 });
680 }
681 }
682
683 // Kind ↔ slot coherence (mirror of the mesh-slot gate above on
684 // the supervisor-tree slot set): the supervisor slots
685 // (:estrategia, :max-restarts, :restart-window, :children)
686 // compose the typed OTP supervisor of a :kind Supervisor
687 // (INSPIRATIONS §II.2). `Caixa::supervisor_view` only folds them
688 // into a validatable SupervisorSpec when the kind is Supervisor,
689 // and the wasm-operator's hierarchical reconciler only consumes
690 // them for one — so on any *other* kind a declared supervisor
691 // slot is the manifest field's documented "ignored otherwise":
692 // it silently passes verify and then vanishes (never validated,
693 // never reconciled), far from the source caixa.lisp. Reject it
694 // here — beside the mesh-slot gate, before the path-existence
695 // loops — naming the offending kind + slot(s). `declared_
696 // supervisor_slots` is the single typed source of the
697 // supervisor-slot set + its canonical diagnostic order.
698 if !caixa.kind().is_supervisor() {
699 let supervisor_slots = caixa.declared_supervisor_slots();
700 if !supervisor_slots.is_empty() {
701 return Err(LayoutError::SupervisorSlotsOnNonSupervisor {
702 caixa: caixa.nome().to_string(),
703 kind: caixa.kind(),
704 slots: supervisor_slots.join(" "),
705 });
706 }
707 }
708
709 // Kind ↔ slot coherence (mirror of the mesh-slot + supervisor-slot
710 // gates above on the M2 Servico-runtime slot set): the M2 slots
711 // (:limits, :behavior, :upgrade-from) configure the runtime of a
712 // long-running wasm component, i.e. a :kind Servico — :limits is
713 // Lunatic per-process sandboxing (INSPIRATIONS §III.1), :behavior
714 // the OTP gen_server callback set (§II.3), :upgrade-from the OTP
715 // appup hot-reload table (§II.4). The caixa-helm / caixa-flux
716 // renderers gate on `require_kind(_, Servico)` and only emit these
717 // slots for a Servico — so on any *other* kind a declared M2 slot
718 // is the manifest field's documented "ignored otherwise": its
719 // well-formedness is checked by the M2 invariant blocks below, but
720 // the value is never rendered into a chart / programs.yaml entry —
721 // it silently passes verify and then vanishes, far from the source
722 // caixa.lisp. Reject it here — beside the mesh- and supervisor-slot
723 // gates, before the M2 validate blocks (which would otherwise spend
724 // their diagnostics on a value the kind can never render) — naming
725 // the offending kind + slot(s). `declared_servico_slots` is the
726 // single typed source of the M2-slot set + its canonical
727 // diagnostic order.
728 if !caixa.kind().is_servico() {
729 let servico_slots = caixa.declared_servico_slots();
730 if !servico_slots.is_empty() {
731 return Err(LayoutError::ServicoSlotsOnNonServico {
732 caixa: caixa.nome().to_string(),
733 kind: caixa.kind(),
734 slots: servico_slots.join(" "),
735 });
736 }
737 }
738
739 // Kind ↔ slot coherence (mirror of the three gates above on the
740 // Acao `:ci` slot, CANTEIRO §7.1-C): `:ci` carries a typed CI
741 // run — a canteiro_types::CiRun — that only the caixa-actions
742 // renderer decomposes + validates, and only for a :kind Acao.
743 // On any *other* kind a declared `:ci` is the manifest field's
744 // documented "ignored otherwise": it silently passes verify and
745 // then vanishes (never decomposed, never rendered), far from the
746 // source caixa.lisp. Reject it here — beside the mesh-,
747 // supervisor-, and servico-slot gates — naming the offending
748 // kind. Unlike its three siblings this axis is a single
749 // `Option` field, not a Vec-of-named-slots, so the gate reads
750 // `caixa.ci().is_some()` directly rather than reaching for a
751 // `declared_*_slots()` helper.
752 if caixa.ci().is_some() && !caixa.kind().is_acao() {
753 return Err(LayoutError::CiOnNonAcao {
754 caixa: caixa.nome().to_string(),
755 kind: caixa.kind(),
756 });
757 }
758
759 // Kind ↔ slot coherence on the fourth and final axis — the
760 // code-surface slot set (the trio M2/Supervisor/Aplicacao gates
761 // above close on the M2 runtime, supervisor-tree, and M3 mesh
762 // axes; this gate closes the symmetric "kind owns this code
763 // shape" relation on `:exe` + `:servicos`). `:exe` is the nix-
764 // built executable surface owned only by Binario; `:servicos`
765 // is the wasm-component daemon surface owned only by Servico.
766 // The caixa-helm / caixa-flux / caixa-flake renderers gate on
767 // `require_kind(_, <owning-kind>)` and only emit the slot for
768 // its owning kind — so on any *other* code-running kind a
769 // declared `:exe` / `:servicos` is the manifest field's
770 // documented "ignored otherwise" (see the field docs on
771 // `Caixa::exe` + `Caixa::servicos`): the path is validated by
772 // the per-kind path-existence loops below, but the value is
773 // never rendered into a build target or programs.yaml entry —
774 // it silently passes `feira build` and then vanishes, far from
775 // the source caixa.lisp.
776 //
777 // Reject it here — beside the M2/Supervisor/Aplicacao slot
778 // gates, after the `SupervisorOwnsCode` / `AplicacaoOwnsCode`
779 // OwnCode gates which dominate on those two no-code kinds (a
780 // Supervisor / Aplicacao with any of `:bibliotecas` / `:exe` /
781 // `:servicos` surfaces the OwnCode diagnostic first), and
782 // before the path-existence loops which would otherwise spend
783 // a less-helpful `MissingEntry` diagnostic on the foreign
784 // slot's path. `declared_foreign_code_slots` is the single
785 // typed source of the foreign-code-slot set + its canonical
786 // diagnostic order (`:exe` → `:servicos`).
787 //
788 // Mirrors the 9d37f98 / 510c00a / 760a430 kind ↔ slot
789 // coherence trio's "declared-but-inert" footgun closure on the
790 // M2 / supervisor-tree / M3 axes, now extended onto the code-
791 // surface axis — every code-running kind's exclusive code
792 // surface is structurally fenced from every other code-running
793 // kind. `:bibliotecas` is deliberately excluded from the foreign
794 // set on Binario / Servico (a `lib/` helper bundled into the
795 // nix flake's build or the wasm-component's source tree is a
796 // legitimate cross-kind authoring shape); on Biblioteca it is
797 // the native slot, and on Supervisor / Aplicacao the OwnCode
798 // gates above already close it.
799 let foreign_code_slots = caixa.declared_foreign_code_slots();
800 if !foreign_code_slots.is_empty() {
801 return Err(LayoutError::ForeignCodeSlot {
802 caixa: caixa.nome().to_string(),
803 kind: caixa.kind(),
804 slots: foreign_code_slots.join(" "),
805 });
806 }
807
808 // Per-entry path-shape gate on the three Caixa-level code-surface
809 // path lists (`:bibliotecas`, `:exe`, `:servicos`): each entry must
810 // be non-empty, relative, and free of `..` components — the same
811 // [`crate::render::is_sandboxed_relative_path`] discipline the
812 // peer `:behavior :on-*` (b0c8389) and
813 // `:upgrade-from :state-change :script` (26da2c7) axes already
814 // route through. Runs *after* the kind-coherence gates above (so
815 // a `:exe` on a Servico surfaces ForeignCodeSlot rather than a
816 // per-entry shape diagnostic, and a Supervisor/Aplicacao with any
817 // code surface surfaces OwnCode first) and *before* the existence
818 // loops below (so an empty / absolute / parent-escaping entry
819 // surfaces its self-locating per-slot diagnostic rather than a
820 // downstream `MissingEntry` / `ExeOutsideDir` /
821 // `ServicoOutsideDir` against the resolved sandbox-escape path).
822 caixa
823 .validate_code_paths()
824 .map_err(|err| LayoutError::CodePathViolation {
825 caixa: caixa.nome().to_string(),
826 issue: err.to_string(),
827 })?;
828
829 if caixa.kind().requires_lib() && caixa.bibliotecas().is_empty() {
830 let expected = root
831 .join(crate::render::LAYOUT_DIR_LIB)
832 .join(format!("{}.lisp", caixa.nome()));
833 if !self.exists(&expected) {
834 return Err(LayoutError::MissingLib {
835 caixa: caixa.nome().to_string(),
836 expected,
837 });
838 }
839 }
840
841 if caixa.kind().requires_exe() && caixa.exe().is_empty() {
842 return Err(LayoutError::BinarioWithoutExe(caixa.nome().to_string()));
843 }
844
845 if caixa.kind().requires_servicos() && caixa.servicos().is_empty() {
846 return Err(LayoutError::ServicoWithoutServicos(
847 caixa.nome().to_string(),
848 ));
849 }
850
851 // Required-slot gate on the fifth [`CaixaKind`] arm — mirror of
852 // the `BinarioWithoutExe` / `ServicoWithoutServicos` pair above.
853 // An `Acao`'s sole payload is its `:ci` slot; an `Acao` caixa
854 // that doesn't declare one has no CI run to decompose or
855 // validate, so `feira build` refuses it here rather than
856 // letting a downstream `caixa-actions::validate` call fail with
857 // a less-helpful "no :ci" surprise far from the source
858 // caixa.lisp.
859 if caixa.kind().requires_ci() && caixa.ci().is_none() {
860 return Err(LayoutError::MissingCi(caixa.nome().to_string()));
861 }
862
863 for p in caixa.bibliotecas() {
864 let full = root.join(p);
865 if !self.exists(&full) {
866 return Err(LayoutError::MissingEntry {
867 kind: crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA,
868 path: full,
869 });
870 }
871 }
872
873 let exe_dir = root.join(crate::render::LAYOUT_DIR_EXE);
874 for p in caixa.exe() {
875 let full = root.join(p);
876 if !self.exists(&full) {
877 return Err(LayoutError::MissingEntry {
878 kind: crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE,
879 path: full,
880 });
881 }
882 if !full.starts_with(&exe_dir) {
883 return Err(LayoutError::ExeOutsideDir(full));
884 }
885 }
886
887 let servicos_dir = root.join(crate::render::LAYOUT_DIR_SERVICOS);
888 for p in caixa.servicos() {
889 let full = root.join(p);
890 if !self.exists(&full) {
891 return Err(LayoutError::MissingEntry {
892 kind: crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO,
893 path: full,
894 });
895 }
896 if !full.starts_with(&servicos_dir) {
897 return Err(LayoutError::ServicoOutsideDir(full));
898 }
899 }
900
901 // ── M2 typed-substrate invariants ────────────────────────────────
902
903 // Lunatic-style per-process limits: every declared axis must
904 // be meaningfully non-zero. A zero on any axis is the same
905 // authorial-intent footgun as a 0-failure circuit-breaker or
906 // a 0-port :entrada — wasmtime would consume the value as
907 // "trap immediately" rather than the author's "an unspecified
908 // bound". See `LimitsSpec::validate` for the full rationale.
909 if let Some(l) = caixa.limits() {
910 l.validate().map_err(|err| LayoutError::LimitsViolation {
911 caixa: caixa.nome().to_string(),
912 issue: err.to_string(),
913 })?;
914 }
915
916 // Behavior callbacks: every declared callback must (a) be
917 // value-shape valid (no empty / absolute / parent-escaping
918 // path values that would silently subvert the layout
919 // checker's `root.join(p)` sandbox), then (b) resolve on disk.
920 // The shape pass runs first so the diagnostic names *which
921 // :behavior slot* is malformed before the existence check
922 // would otherwise surface a less-helpful "missing entry".
923 if let Some(b) = caixa.behavior() {
924 b.validate().map_err(|err| LayoutError::BehaviorViolation {
925 caixa: caixa.nome().to_string(),
926 issue: err.to_string(),
927 })?;
928 for p in b.declared_paths() {
929 let full = root.join(p);
930 if !self.exists(&full) {
931 return Err(LayoutError::MissingEntry {
932 kind: crate::render::LAYOUT_MISSING_ENTRY_KIND_BEHAVIOR_CALLBACK,
933 path: full,
934 });
935 }
936 }
937 }
938
939 // Compound per-Caixa entry gate on `:upgrade-from`: the layout
940 // pipeline's three-dispatch M2 `:upgrade-from` cascade — the
941 // per-entry shape + cross-entry duplicate-`:from` gate
942 // ([`crate::upgrade::validate_upgrade_from`]), the cross-slot
943 // `:from < :versao` SemVer-2 precedence gate
944 // ([`crate::upgrade::validate_upgrade_from_against_versao`]), and
945 // the cross-slot `:state-change` ↔ `:on-state-change` composition
946 // gate ([`crate::upgrade::validate_upgrade_from_against_behavior`])
947 // — folded onto the [`crate::Caixa::validate_upgrade_from`]
948 // substrate primitive. The three dispatches run in the same
949 // canonical order at the primitive (per-entry → versao → behavior)
950 // so the fold is byte-for-byte equivalent to the pre-fold
951 // three-block cascade this call site formerly carried, pinned by
952 // the paired
953 // `validate_upgrade_from_folds_{per_entry,versao,behavior}_arm_matches_gate`
954 // equivalence pins and the
955 // `validate_upgrade_from_{per_entry_arm_fires_before_versao_arm,
956 // versao_arm_fires_before_behavior_arm}` ordering pins in the
957 // [`crate::Caixa::validate_upgrade_from`] pin family
958 // (`manifest.rs`).
959 //
960 // Runs BEFORE the existing per-instruction script-path existence
961 // pass below so a malformed typed slot surfaces its own
962 // self-locating diagnostic rather than the less-helpful "missing
963 // upgrade-script" (which doesn't fire for non-script axes at all).
964 // Same lift discipline the peer per-slot compound gates
965 // ([`crate::AplicacaoSpec::validate_contratos`] and its
966 // `:membros` / `:entrada` / `:placement` / `:politicas` peers,
967 // [`crate::MeshPolicy::validate`],
968 // [`crate::SupervisorSpec::validate_children`]) each carry — one
969 // named substrate-primitive gate per typed slot folds every
970 // structural axis on that slot onto one call, so every future
971 // consumer that wants to re-check `:upgrade-from` after a
972 // per-entry patch (the deferred `caixa.pleme.io/v1alpha1/Caixa`
973 // CR materializer's admission webhook, a future `feira validate
974 // --upgrade` per-caixa admission verb, a per-`:upgrade-from`
975 // overlay resolver) reaches the three-arm compound gate through
976 // one dispatch rather than re-inlining the three-dispatch
977 // cascade in lockstep with this wire-up.
978 //
979 // The per-instruction on-disk existence-probe walk below stays
980 // open-coded at the layout wire-up site — that arm needs the
981 // filesystem oracle on the [`LayoutInvariants`] trait, not on
982 // the pure per-Caixa typed-shape surface the compound gate
983 // folds. Same posture [`crate::Caixa::validate_code_paths`] takes
984 // on the sibling code-path axes: the typed-shape gate fires on
985 // the per-Caixa surface, the on-disk existence check fires on
986 // the [`StandardLayout`] surface.
987 caixa
988 .validate_upgrade_from()
989 .map_err(|err| LayoutError::UpgradeViolation {
990 caixa: caixa.nome().to_string(),
991 issue: err.to_string(),
992 })?;
993 for entry in caixa.upgrade_from() {
994 for instr in entry.instructions() {
995 if let Some(p) = instr.declared_path() {
996 let full = root.join(p);
997 if !self.exists(&full) {
998 return Err(LayoutError::MissingEntry {
999 kind: crate::render::LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT,
1000 path: full,
1001 });
1002 }
1003 }
1004 }
1005 }
1006
1007 // Supervisor invariants (typed shape — children, restart strategy).
1008 // The "supervisor doesn't own code" check is at the top of verify()
1009 // so it fires before the existence-check loops.
1010 if caixa.kind().is_supervisor() {
1011 // Raw `:restart-window` parse gate on the flat
1012 // `Caixa::restart_window: Option<String>` axis — the last
1013 // orphan-validator on the typed Caixa surface flagged by the
1014 // [`Self::validate_deps`] wire-up's closing comment (the
1015 // "Supervisor-axis specific" remainder) and the
1016 // [`Caixa::supervisor_view`] doc-comment's "the future
1017 // layout-side wire-up" pin. Until this gate landed
1018 // [`Caixa::validate_restart_window`] existed as `pub fn` on
1019 // [`Caixa`] with full per-arm unit coverage in `manifest::tests`
1020 // (`validate_restart_window_rejects_*` — fractional seconds,
1021 // decimal-shaped integer, half-unit minute, leading sign,
1022 // unknown unit, garbage, empty-after-trim; eight rejection
1023 // arms total), but no production code path called it —
1024 // `feira build` (the canonical author-time gate; routes
1025 // through [`StandardLayout::verify`]) silently accepted a
1026 // malformed `:restart-window` and [`Caixa::supervisor_view`]
1027 // soft-swallowed the parse failure as `restart_window: None`
1028 // (i.e. the canonical "omit the slot to express no reset"
1029 // sentinel), turning every malformed window into a never-reset
1030 // supervisor far from the source `caixa.lisp`, with no field
1031 // naming the offending `:restart-window`. The Erlang/OTP
1032 // `MaxIntensity / Period` invariant the typed [`SupervisorSpec`]
1033 // gate (`view.validate()` immediately below) enforces on the
1034 // `Option<Duration>` value never reached the gate at all on
1035 // these inputs: the parse error was already laundered to
1036 // `None`, and `None` is the canonical "never reset" shape
1037 // that always validates cleanly. Lifting the parse gate to
1038 // the layout-pipeline wire-up closes the laundering — every
1039 // value past this gate either parses through the shared
1040 // `crate::supervisor::duration_codec::parse` (and therefore
1041 // round-trips canonically) or fires the new
1042 // [`Self::RestartWindowViolation`] envelope at the source.
1043 //
1044 // Runs *inside* the `kind == Supervisor` branch (rather than
1045 // alongside the peer flat-Caixa gates `validate_nome` /
1046 // `validate_versao` / `validate_deps` / `validate_code_paths`
1047 // above the kind dispatch) because `:restart-window` is in
1048 // the Supervisor slot set per [`Caixa::declared_supervisor_slots`]
1049 // — every non-Supervisor caixa with `:restart-window` set
1050 // already errors upstream via the
1051 // [`Self::SupervisorSlotsOnNonSupervisor`] kind-coherence gate
1052 // (line 243-252), so reaching this gate on a non-Supervisor
1053 // kind would be a no-op (the field is `None` by construction).
1054 // Runs *before* `view.validate()` so the parse-side diagnostic
1055 // surfaces first on the raw-string axis — a `:restart-window
1056 // "1.5s"` lands on the more self-locating
1057 // `RestartWindowViolation` (which names the offending raw
1058 // string verbatim) rather than the laundered-to-`None`
1059 // soft-pass that the typed view would silently let through.
1060 //
1061 // Same per-axis `*Violation { caixa, issue }` envelope every
1062 // peer flat-Caixa wrap exposes ([`Self::NomeViolation`] /
1063 // [`Self::VersaoViolation`] 1f74a5f,
1064 // [`Self::DepsViolation`] aa77d0f, [`Self::CodePathViolation`]
1065 // b868442). Threads [`ManifestError::RestartWindowMalformed`]
1066 // Display through verbatim — the per-arm reason already names
1067 // the offending raw value (e.g. `":restart-window \"1.5s\" is
1068 // not a canonical duration: …"`), so the wrap's `issue`
1069 // carries a self-locating "which axis, which value, why"
1070 // without re-shaping the parser-side reason.
1071 caixa
1072 .validate_restart_window()
1073 .map_err(|err| LayoutError::RestartWindowViolation {
1074 caixa: caixa.nome().to_string(),
1075 issue: err.to_string(),
1076 })?;
1077 let view = caixa
1078 .supervisor_view()
1079 .expect("Supervisor kind must have a supervisor_view");
1080 view.validate()
1081 .map_err(|err| LayoutError::SupervisorViolation {
1082 caixa: caixa.nome().to_string(),
1083 issue: err.to_string(),
1084 })?;
1085 // Cross-slot coherence: `:children :caixa` must not name the
1086 // supervisor's own `:nome`. The typed `SupervisorSpec` view
1087 // carries the children but not the parent `:nome`, so this
1088 // self-parent gate reads one slot against another here, the
1089 // same wire-up shape `validate_upgrade_from_against_versao`
1090 // uses for the `:from`/`:versao` precedence gate. Runs after
1091 // `view.validate()` so the per-child shape + duplicate
1092 // diagnostics surface first; a self-referential child is
1093 // always a valid DNS-1123 label (it equals the already-valid
1094 // `:nome`), so this ordering never masks a narrower defect.
1095 //
1096 // Routes the `parent_nome` arg through the typed
1097 // [`Caixa::nome`] accessor (`caixa.nome()`) rather than
1098 // the raw `&caixa.nome` `&String`-borrow of the underlying
1099 // field — same one-typed-dispatch-per-`:nome`-consumer
1100 // discipline the sibling caixa-mesh (980c059) / caixa-helm
1101 // (22461ef) / caixa-flux (162e2e2) / caixa-crd (61d3429) /
1102 // caixa-feira (ef83332) `caixa.nome`-arg raw-borrow converges
1103 // established on the peer renderer / CR-materializer / CLI
1104 // crates, extended here onto the substrate's own
1105 // [`LayoutInvariants::verify`] cross-slot self-edge gate
1106 // wire-up on the supervisor-tree kind arm — the
1107 // [`crate::supervisor::validate_no_self_supervision`] helper
1108 // accepts `parent_nome: &str`, so the accessor's `&str`
1109 // return threads through without an intermediate deref-
1110 // coerce, closing the raw `&caixa.nome` arg-passing axis at
1111 // this call site.
1112 crate::supervisor::validate_no_self_supervision(caixa.children(), caixa.nome())
1113 .map_err(|err| LayoutError::SupervisorViolation {
1114 caixa: caixa.nome().to_string(),
1115 issue: err.to_string(),
1116 })?;
1117 }
1118
1119 // Aplicacao invariants — typed graph composition. Like
1120 // Supervisor, an Aplicacao runs no code itself.
1121 if caixa.kind().is_aplicacao() {
1122 let view = caixa
1123 .aplicacao_view()
1124 .expect("Aplicacao kind must have an aplicacao_view");
1125 view.validate()
1126 .map_err(|err| LayoutError::AplicacaoViolation {
1127 caixa: caixa.nome().to_string(),
1128 issue: err.to_string(),
1129 })?;
1130 // Cross-slot coherence: `:membros :caixa` must not name the
1131 // Aplicacao's own `:nome`. The typed `AplicacaoSpec` view
1132 // carries the membros but not the parent `:nome`, so this
1133 // self-edge gate reads one slot against another here, the
1134 // same wire-up shape `crate::supervisor::validate_no_self_supervision`
1135 // (ad4abf1) uses on the peer supervision-tree axis. Runs
1136 // after `view.validate()` so the per-member shape +
1137 // duplicate diagnostics surface first; a self-referential
1138 // member is always a valid DNS-1123 label (it equals the
1139 // already-valid `:nome`), so this ordering never masks a
1140 // narrower defect. Because `:contratos` `:de`/`:para` and
1141 // `:entrada :para` are already required to be members of
1142 // `:membros`, this single gate also transitively closes
1143 // those two axes — every validated `:contratos` edge and
1144 // every validated `:entrada :para` cannot name the
1145 // Aplicacao itself, without re-deriving the partition.
1146 //
1147 // Routes the `parent_nome` arg through the typed
1148 // [`Caixa::nome`] accessor (`caixa.nome()`) rather than
1149 // the raw `&caixa.nome` `&String`-borrow of the underlying
1150 // field — peer of the sibling supervision-tree-arm
1151 // [`crate::supervisor::validate_no_self_supervision`] arg
1152 // converge immediately above on the same
1153 // [`LayoutInvariants::verify`] cross-slot self-edge gate
1154 // wire-up, extended here onto the Aplicacao-kind arm. Both
1155 // per-kind arms now key off exactly one typed dispatch on
1156 // the substrate primitive for the parent-`:nome` self-edge
1157 // arg, closing the raw `&caixa.nome` arg-passing axis on
1158 // the cross-slot self-edge gate wire-up across both
1159 // typed-name-graph kinds ([`crate::supervisor::validate_no_self_supervision`]
1160 // on the supervision tree, this call on the Aplicacao
1161 // graph). The [`crate::aplicacao::validate_no_self_membership`]
1162 // helper accepts `parent_nome: &str`, so the accessor's
1163 // `&str` return threads through without an intermediate
1164 // deref-coerce.
1165 crate::aplicacao::validate_no_self_membership(caixa.membros(), caixa.nome()).map_err(
1166 |err| LayoutError::AplicacaoViolation {
1167 caixa: caixa.nome().to_string(),
1168 issue: err.to_string(),
1169 },
1170 )?;
1171 }
1172
1173 Ok(())
1174 }
1175}
1176
1177#[derive(Debug, Error, PartialEq, Eq)]
1178pub enum LayoutError {
1179 #[error("manifest missing: {}", .0.display())]
1180 MissingManifest(PathBuf),
1181 #[error("caixa '{caixa}' is a Biblioteca but has no lib entry — expected {}", expected.display())]
1182 MissingLib { caixa: String, expected: PathBuf },
1183 #[error("caixa '{0}' is a Binario but has no :exe entries")]
1184 BinarioWithoutExe(String),
1185 #[error("caixa '{0}' is a Servico but has no :servicos entries")]
1186 ServicoWithoutServicos(String),
1187 #[error("declared {kind} entry missing: {}", path.display())]
1188 MissingEntry { kind: &'static str, path: PathBuf },
1189 #[error("exe entry outside exe/ directory: {}", .0.display())]
1190 ExeOutsideDir(PathBuf),
1191 #[error("servico entry outside servicos/ directory: {}", .0.display())]
1192 ServicoOutsideDir(PathBuf),
1193 #[error("caixa '{caixa}' has invalid :nome: {issue}")]
1194 NomeViolation { caixa: String, issue: String },
1195 #[error("caixa '{caixa}' has invalid :versao: {issue}")]
1196 VersaoViolation { caixa: String, issue: String },
1197 #[error("caixa '{caixa}' has invalid :deps / :deps-dev entry: {issue}")]
1198 DepsViolation { caixa: String, issue: String },
1199 #[error("caixa '{caixa}' has invalid :etiquetas entry: {issue}")]
1200 EtiquetasViolation { caixa: String, issue: String },
1201 #[error("caixa '{caixa}' has invalid :autores entry: {issue}")]
1202 AutoresViolation { caixa: String, issue: String },
1203 #[error("caixa '{caixa}' has invalid :repositorio: {issue}")]
1204 RepositorioViolation { caixa: String, issue: String },
1205 #[error("caixa '{caixa}' has invalid :descricao: {issue}")]
1206 DescricaoViolation { caixa: String, issue: String },
1207 #[error("caixa '{caixa}' has invalid :licenca: {issue}")]
1208 LicencaViolation { caixa: String, issue: String },
1209 #[error("caixa '{caixa}' has invalid :edicao: {issue}")]
1210 EdicaoViolation { caixa: String, issue: String },
1211 #[error("caixa '{caixa}' has invalid code-path entry: {issue}")]
1212 CodePathViolation { caixa: String, issue: String },
1213 #[error("caixa '{caixa}' has invalid :limits: {issue}")]
1214 LimitsViolation { caixa: String, issue: String },
1215 #[error("caixa '{caixa}' has invalid :behavior callback: {issue}")]
1216 BehaviorViolation { caixa: String, issue: String },
1217 #[error("caixa '{caixa}' has invalid :upgrade-from entry: {issue}")]
1218 UpgradeViolation { caixa: String, issue: String },
1219 #[error("supervisor caixa '{caixa}' violates typed shape: {issue}")]
1220 SupervisorViolation { caixa: String, issue: String },
1221 #[error("supervisor caixa '{caixa}' has invalid :restart-window: {issue}")]
1222 RestartWindowViolation { caixa: String, issue: String },
1223 #[error(
1224 "supervisor caixa '{0}' must not declare :bibliotecas, :exe, or :servicos — supervisors don't run code, they orchestrate other caixas"
1225 )]
1226 SupervisorOwnsCode(String),
1227 #[error("aplicacao caixa '{caixa}' violates typed shape: {issue}")]
1228 AplicacaoViolation { caixa: String, issue: String },
1229 #[error(
1230 "aplicacao caixa '{0}' must not declare :bibliotecas, :exe, or :servicos — aplicacaos compose Servicos, they don't run code themselves"
1231 )]
1232 AplicacaoOwnsCode(String),
1233 #[error(
1234 "acao caixa '{0}' must not declare :bibliotecas, :exe, or :servicos — acaos carry a typed CI run (:ci), they don't run code themselves"
1235 )]
1236 AcaoOwnsCode(String),
1237 #[error(
1238 "caixa '{caixa}' is :kind {kind:?} but declares Aplicacao-only mesh slot(s): {slots} — \
1239 :membros / :contratos / :politicas / :placement / :entrada compose a :kind Aplicacao's \
1240 typed graph (MESH-COMPOSITION §III.1) and are silently ignored on every other kind \
1241 (never validated, never rendered); move them to a :kind Aplicacao caixa or remove them"
1242 )]
1243 MeshSlotsOnNonAplicacao {
1244 caixa: String,
1245 kind: CaixaKind,
1246 slots: String,
1247 },
1248 #[error(
1249 "caixa '{caixa}' is :kind {kind:?} but declares Supervisor-only slot(s): {slots} — \
1250 :estrategia / :max-restarts / :restart-window / :children compose a :kind Supervisor's \
1251 typed OTP supervisor (INSPIRATIONS §II.2) and are silently ignored on every other kind \
1252 (never validated, never reconciled); move them to a :kind Supervisor caixa or remove them"
1253 )]
1254 SupervisorSlotsOnNonSupervisor {
1255 caixa: String,
1256 kind: CaixaKind,
1257 slots: String,
1258 },
1259 #[error(
1260 "caixa '{caixa}' is :kind {kind:?} but declares Servico-only slot(s): {slots} — \
1261 :limits / :behavior / :upgrade-from configure the runtime of a long-running :kind Servico \
1262 wasm component (INSPIRATIONS §III.1 / §II.3 / §II.4) and are silently ignored on every \
1263 other kind (never rendered into a chart or programs.yaml entry); move them to a :kind \
1264 Servico caixa or remove them"
1265 )]
1266 ServicoSlotsOnNonServico {
1267 caixa: String,
1268 kind: CaixaKind,
1269 slots: String,
1270 },
1271 #[error(
1272 "caixa '{caixa}' is :kind {kind:?} but declares foreign code-surface slot(s): {slots} — \
1273 :exe is the nix-built executable surface owned only by :kind Binario, :servicos is the \
1274 wasm-component + ComputeUnit daemon surface owned only by :kind Servico; \
1275 caixa-helm / caixa-flux / caixa-flake gate emission on `require_kind(_, <owning-kind>)`, \
1276 so a declared :exe / :servicos on the wrong code-running kind is silently ignored — the \
1277 path is validated by the layout's path-existence loops but never rendered into a build \
1278 target or programs.yaml entry. Move the slot to its owning kind, change :kind to match \
1279 (Binario for :exe, Servico for :servicos), or drop the slot entirely"
1280 )]
1281 ForeignCodeSlot {
1282 caixa: String,
1283 kind: CaixaKind,
1284 slots: String,
1285 },
1286 #[error("caixa '{0}' is an Acao but has no :ci slot")]
1287 MissingCi(String),
1288 #[error(
1289 "caixa '{caixa}' is :kind {kind:?} but declares the Acao-only :ci slot — \
1290 :ci carries a typed CI run (canteiro_types::CiRun, CANTEIRO §7.1-C) that only the \
1291 caixa-actions renderer validates for :kind Acao, and is silently ignored on every \
1292 other kind (never decomposed, never rendered); move it to a :kind Acao caixa or \
1293 remove it"
1294 )]
1295 CiOnNonAcao { caixa: String, kind: CaixaKind },
1296}
1297
1298#[cfg(test)]
1299mod tests {
1300 use super::*;
1301 use crate::{Caixa, CaixaKind};
1302 use std::path::PathBuf;
1303
1304 fn caixa(kind: CaixaKind) -> Caixa {
1305 Caixa {
1306 nome: "demo".into(),
1307 versao: "0.1.0".into(),
1308 kind,
1309 edicao: None,
1310 descricao: None,
1311 repositorio: None,
1312 licenca: None,
1313 autores: vec![],
1314 etiquetas: vec![],
1315 deps: vec![],
1316 deps_dev: vec![],
1317 exe: vec![],
1318 bibliotecas: vec![],
1319 servicos: vec![],
1320 // M2 typed-substrate slots default to absent.
1321 limits: None,
1322 behavior: None,
1323 upgrade_from: vec![],
1324 estrategia: None,
1325 max_restarts: None,
1326 restart_window: None,
1327 children: vec![],
1328 // M3 Aplicacao slots default to absent.
1329 membros: vec![],
1330 contratos: vec![],
1331 politicas: None,
1332 placement: None,
1333 entrada: None,
1334 ci: None,
1335 }
1336 }
1337
1338 #[test]
1339 fn missing_manifest_errors() {
1340 let layout = StandardLayout::new().with_path_exists(|_| false);
1341 let err = layout
1342 .verify(&caixa(CaixaKind::Biblioteca), Path::new("/tmp/x"))
1343 .unwrap_err();
1344 assert!(matches!(err, LayoutError::MissingManifest(_)));
1345 }
1346
1347 #[test]
1348 fn biblioteca_needs_default_lib_path() {
1349 let root = PathBuf::from("/tmp/x");
1350 let expect_manifest = root.join("caixa.lisp");
1351 let layout = StandardLayout::new().with_path_exists(move |p| p == expect_manifest);
1352 let err = layout
1353 .verify(&caixa(CaixaKind::Biblioteca), &root)
1354 .unwrap_err();
1355 assert!(matches!(err, LayoutError::MissingLib { .. }));
1356 }
1357
1358 #[test]
1359 fn biblioteca_passes_when_default_lib_exists() {
1360 let root = PathBuf::from("/tmp/x");
1361 let manifest = root.join("caixa.lisp");
1362 let default_lib = root.join("lib").join("demo.lisp");
1363 let layout =
1364 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1365 layout
1366 .verify(&caixa(CaixaKind::Biblioteca), &root)
1367 .expect("should pass");
1368 }
1369
1370 #[test]
1371 fn binario_without_exe_errors() {
1372 let root = PathBuf::from("/tmp/x");
1373 let manifest = root.join("caixa.lisp");
1374 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
1375 let err = layout
1376 .verify(&caixa(CaixaKind::Binario), &root)
1377 .unwrap_err();
1378 assert!(matches!(err, LayoutError::BinarioWithoutExe(_)));
1379 }
1380
1381 #[test]
1382 fn exe_outside_dir_errors() {
1383 // A relative entry that lives under the caixa root but *not*
1384 // under `exe/` — the canonical case the `starts_with(exe_dir)`
1385 // fence catches. The prior parent-escape shape this test used
1386 // (`"../sibling/tool"`) is now caught at validate time by
1387 // [`Caixa::validate_code_paths`] with the narrower
1388 // [`crate::ManifestError::CodePathParentEscape`] diagnostic
1389 // (see the layout-level integration pin
1390 // `code_path_violation_on_parent_escape_fires_before_existence_check`),
1391 // so this fence pin uses a non-`..` non-absolute shape outside
1392 // `exe/` to preserve coverage of the ExeOutsideDir surface.
1393 let root = PathBuf::from("/tmp/x");
1394 let manifest = root.join("caixa.lisp");
1395 let outside = root.join("lib/tool");
1396 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == outside);
1397 let mut c = caixa(CaixaKind::Binario);
1398 c.exe = vec!["lib/tool".into()];
1399 let err = layout.verify(&c, &root).unwrap_err();
1400 assert!(matches!(err, LayoutError::ExeOutsideDir(_)));
1401 }
1402
1403 // ── code-path shape gate (lifted to layout-level verify) ─────────────
1404
1405 #[test]
1406 fn code_path_violation_on_empty_bibliotecas_entry() {
1407 let root = PathBuf::from("/tmp/x");
1408 let manifest = root.join("caixa.lisp");
1409 let default_lib = root.join("lib").join("demo.lisp");
1410 let layout =
1411 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1412 let mut c = caixa(CaixaKind::Biblioteca);
1413 c.bibliotecas = vec![String::new()];
1414 let err = layout.verify(&c, &root).unwrap_err();
1415 // The wire-up wraps `ManifestError` Display into the
1416 // CodePathViolation envelope (peer of LimitsViolation /
1417 // BehaviorViolation / UpgradeViolation), so the issue string
1418 // names the offending slot at the source.
1419 let LayoutError::CodePathViolation { caixa, issue } = err else {
1420 panic!("expected LayoutError::CodePathViolation, got {err:?}");
1421 };
1422 assert_eq!(caixa, "demo");
1423 assert!(
1424 issue.contains(":bibliotecas"),
1425 "issue must name the offending slot: {issue}",
1426 );
1427 }
1428
1429 #[test]
1430 fn code_path_violation_on_absolute_servicos_entry() {
1431 let root = PathBuf::from("/tmp/x");
1432 let manifest = root.join("caixa.lisp");
1433 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
1434 let mut c = caixa(CaixaKind::Servico);
1435 c.servicos = vec!["/etc/servicos/escape.yaml".into()];
1436 let err = layout.verify(&c, &root).unwrap_err();
1437 let LayoutError::CodePathViolation { caixa, issue } = err else {
1438 panic!("expected LayoutError::CodePathViolation, got {err:?}");
1439 };
1440 assert_eq!(caixa, "demo");
1441 assert!(
1442 issue.contains(":servicos"),
1443 "issue must name the offending slot: {issue}",
1444 );
1445 assert!(
1446 issue.contains("/etc/servicos/escape.yaml"),
1447 "issue must quote the offending path: {issue}",
1448 );
1449 }
1450
1451 #[test]
1452 fn code_path_violation_on_parent_escape_fires_before_existence_check() {
1453 // The new gate runs BEFORE the existence loops, so a
1454 // parent-escaping `:exe` entry surfaces CodePathViolation
1455 // (naming `:exe` at the source) rather than the downstream
1456 // ExeOutsideDir / MissingEntry against the resolved sandbox-
1457 // escape path. Even if the resolved escape target exists
1458 // on disk (which we simulate here by claiming it does), the
1459 // shape diagnostic wins.
1460 let root = PathBuf::from("/tmp/x");
1461 let manifest = root.join("caixa.lisp");
1462 let resolved_escape = root.join("exe/../../escape.lisp");
1463 let layout =
1464 StandardLayout::new().with_path_exists(move |p| p == manifest || p == resolved_escape);
1465 let mut c = caixa(CaixaKind::Binario);
1466 c.exe = vec!["exe/../../escape.lisp".into()];
1467 let err = layout.verify(&c, &root).unwrap_err();
1468 let LayoutError::CodePathViolation { caixa, issue } = err else {
1469 panic!("expected LayoutError::CodePathViolation, got {err:?}");
1470 };
1471 assert_eq!(caixa, "demo");
1472 assert!(
1473 issue.contains(":exe"),
1474 "issue must name the offending slot: {issue}",
1475 );
1476 }
1477
1478 // ── etiquetas universal-axis gate wired into verify ─────────────────
1479 //
1480 // Pins the layout-pipeline wire-up of [`Caixa::validate_etiquetas`]:
1481 // the fourth universal-axis Caixa-level value-shape gate (peer of
1482 // `validate_nome` / `validate_versao` / `validate_deps` /
1483 // `validate_code_paths`), wired before the kind-coherence gates so
1484 // a structurally-invalid `:etiquetas` entry on any kind surfaces
1485 // the per-axis `EtiquetasViolation { caixa, issue }` envelope at
1486 // the source rather than silently rendering as `keywords: [""]`
1487 // in `Chart.yaml` (Servico kind, via caixa-helm's `BTreeSet`
1488 // collect) or silently dedup'ing at chart render (every kind).
1489 // Until this wire-up landed `:etiquetas` had no shape gate at any
1490 // layer — the registry-search-tag axis was the largest universal
1491 // authoring surface on the typed Caixa surface with no validate
1492 // discipline.
1493 //
1494 // Same per-axis `*Violation { caixa, issue }` envelope every peer
1495 // per-axis wrap exposes; the wire-up runs after `validate_deps`
1496 // (universal axis ordering: `:nome` → `:versao` → `:deps` →
1497 // `:etiquetas`) and before every kind-coherence gate
1498 // (`:etiquetas` is universal so its shape diagnostic is more
1499 // fundamental than the partition-on-kind diagnostics).
1500
1501 #[test]
1502 fn etiquetas_violation_on_empty_entry() {
1503 // Canonical paste-from-blank-doc footgun on every kind. The
1504 // wrap envelope wraps [`ManifestError::EtiquetaEmpty`]'s
1505 // Display through verbatim, so the issue string names the
1506 // offending `:etiquetas` axis at the source — the author can
1507 // grep their caixa.lisp for `:etiquetas` and fix the empty
1508 // entry in one edit. Mirrors the peer
1509 // `code_path_violation_on_empty_bibliotecas_entry` shape
1510 // (b868442) on the `:bibliotecas` axis.
1511 let root = PathBuf::from("/tmp/x");
1512 let manifest = root.join("caixa.lisp");
1513 let default_lib = root.join("lib").join("demo.lisp");
1514 let layout =
1515 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1516 let mut c = caixa(CaixaKind::Biblioteca);
1517 c.etiquetas = vec![String::new()];
1518 let err = layout.verify(&c, &root).unwrap_err();
1519 let LayoutError::EtiquetasViolation { caixa, issue } = err else {
1520 panic!("expected LayoutError::EtiquetasViolation, got {err:?}");
1521 };
1522 assert_eq!(caixa, "demo");
1523 assert!(
1524 issue.contains(":etiquetas"),
1525 "issue must name the offending slot: {issue}",
1526 );
1527 }
1528
1529 #[test]
1530 fn etiquetas_violation_on_duplicate_entry() {
1531 // Canonical copy-paste-the-wrong-tag footgun. Without the wire-
1532 // up the duplicate was silently dedup'd by caixa-helm's
1533 // `BTreeSet` collect at chart render — a "second wins / one
1534 // silently disappears" shape. The wrap envelope names the
1535 // offending tag verbatim through the inner
1536 // [`ManifestError::EtiquetaDuplicate`]'s Display.
1537 let root = PathBuf::from("/tmp/x");
1538 let manifest = root.join("caixa.lisp");
1539 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
1540 let mut c = caixa(CaixaKind::Servico);
1541 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
1542 c.etiquetas = vec!["demo".into(), "demo".into()];
1543 // The servicos path doesn't exist in this fixture, but the
1544 // `:etiquetas` gate fires before the existence loop (universal
1545 // axis dominates kind-specific existence checks). Wire is
1546 // intact iff the wrap envelope surfaces first.
1547 let err = layout.verify(&c, &root).unwrap_err();
1548 let LayoutError::EtiquetasViolation { caixa, issue } = err else {
1549 panic!("expected LayoutError::EtiquetasViolation, got {err:?}");
1550 };
1551 assert_eq!(caixa, "demo");
1552 assert!(
1553 issue.contains("demo"),
1554 "issue must quote the offending tag: {issue}",
1555 );
1556 }
1557
1558 #[test]
1559 fn etiquetas_violation_fires_before_kind_coherence_mesh_slot() {
1560 // Cross-axis precedence pin: a Biblioteca with malformed
1561 // `:etiquetas` *and* declared mesh slots (`:membros`) surfaces
1562 // the universal `:etiquetas` diagnostic first, not the
1563 // kind-coherence `MeshSlotsOnNonAplicacao` diagnostic.
1564 // `:etiquetas` is universal (every kind owns the slot), so its
1565 // shape diagnostic is more fundamental than the partition-on-
1566 // kind diagnostic. Mirrors the peer
1567 // `deps_violation_fires_before_*` precedence pins (aa77d0f) on
1568 // the universal `:deps` axis vs the same kind-coherence gates.
1569 let root = PathBuf::from("/tmp/x");
1570 let manifest = root.join("caixa.lisp");
1571 let default_lib = root.join("lib").join("demo.lisp");
1572 let layout =
1573 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1574 let mut c = caixa(CaixaKind::Biblioteca);
1575 c.etiquetas = vec![String::new()];
1576 c.membros = vec![crate::aplicacao::Membro {
1577 caixa: "x".into(),
1578 versao: "^0.1".into(),
1579 }];
1580 let err = layout.verify(&c, &root).unwrap_err();
1581 assert!(
1582 matches!(err, LayoutError::EtiquetasViolation { .. }),
1583 "got {err:?}",
1584 );
1585 }
1586
1587 #[test]
1588 fn etiquetas_violation_fires_after_deps_violation() {
1589 // Cross-axis precedence pin (inside the universal-axis trio):
1590 // a caixa with both a malformed `:deps` entry *and* a malformed
1591 // `:etiquetas` entry surfaces `DepsViolation` first — `:deps`
1592 // is the third universal axis in declaration order
1593 // (`:nome` → `:versao` → `:deps` → `:etiquetas`) and runs first
1594 // in `verify`. Mirrors the peer
1595 // `nome_violation_fires_before_versao_violation` shape on the
1596 // identity-axis pair.
1597 let root = PathBuf::from("/tmp/x");
1598 let manifest = root.join("caixa.lisp");
1599 let default_lib = root.join("lib").join("demo.lisp");
1600 let layout =
1601 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1602 let mut c = caixa(CaixaKind::Biblioteca);
1603 c.deps = vec![crate::Dep::simple("Caixa-Teia", "^0.1")]; // uppercase :nome
1604 c.etiquetas = vec![String::new()];
1605 let err = layout.verify(&c, &root).unwrap_err();
1606 assert!(
1607 matches!(err, LayoutError::DepsViolation { .. }),
1608 "got {err:?}",
1609 );
1610 }
1611
1612 #[test]
1613 fn etiquetas_violation_accepts_canonical_template() {
1614 // Positive control sanity pin: the canonical `Caixa::template`
1615 // shape (`:etiquetas ()` — empty list) passes the gate
1616 // trivially. Mirrors the peer
1617 // `validate_code_paths_accepts_canonical_template` pin.
1618 let root = PathBuf::from("/tmp/x");
1619 let manifest = root.join("caixa.lisp");
1620 let default_lib = root.join("lib").join("demo.lisp");
1621 let layout =
1622 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1623 let c = caixa(CaixaKind::Biblioteca);
1624 layout.verify(&c, &root).expect("template must pass");
1625 }
1626
1627 #[test]
1628 fn etiquetas_violation_on_non_chart_keyword_shape() {
1629 // Canonical CSV-list-separator-confusion footgun: the author
1630 // confused the CSV-style separator with the `:etiquetas` list
1631 // grammar. The shape gate fires past the empty + duplicate
1632 // arms via [`Caixa::validate_etiquetas`]'s new
1633 // `is_chart_keyword_shape` cascade, and the layout envelope
1634 // wraps [`ManifestError::EtiquetaInvalid`]'s Display through
1635 // verbatim — the issue string names both the offending slot
1636 // and the offending value (debug-escaped). Peer with the
1637 // `autores_violation_on_non_chart_maintainer_shape` pin on
1638 // the sibling universal-axis `Vec<String>` surface — the
1639 // second layout pin on the Vec<String> per-entry shape
1640 // cascade.
1641 let root = PathBuf::from("/tmp/x");
1642 let manifest = root.join("caixa.lisp");
1643 let default_lib = root.join("lib").join("demo.lisp");
1644 let layout =
1645 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1646 let mut c = caixa(CaixaKind::Biblioteca);
1647 c.etiquetas = vec!["mesh,http,grpc".into()];
1648 let err = layout.verify(&c, &root).unwrap_err();
1649 let LayoutError::EtiquetasViolation { caixa, issue } = err else {
1650 panic!("expected LayoutError::EtiquetasViolation, got {err:?}");
1651 };
1652 assert_eq!(caixa, "demo");
1653 assert!(
1654 issue.contains(":etiquetas"),
1655 "issue must name the offending slot: {issue}",
1656 );
1657 assert!(
1658 issue.contains("mesh,http,grpc"),
1659 "issue must quote the offending value: {issue}",
1660 );
1661 }
1662
1663 // ── autores universal-axis gate wired into verify ───────────────────
1664 //
1665 // Pins the layout-pipeline wire-up of [`Caixa::validate_autores`]:
1666 // the fifth universal-axis Caixa-level value-shape gate (peer of
1667 // `validate_nome` / `validate_versao` / `validate_deps` /
1668 // `validate_etiquetas` / `validate_code_paths`), wired immediately
1669 // after `validate_etiquetas` so the two Vec-shaped universal
1670 // metadata axes sit adjacent in the cascade. Until this wire-up
1671 // landed `:autores` had no shape gate at any layer — the
1672 // maintainer-axis was the second largest universal authoring
1673 // surface on the typed Caixa surface with no validate discipline,
1674 // and unlike `:etiquetas` (caixa-helm dedups the rendered
1675 // `keywords:` array via `BTreeSet` collect at chart render),
1676 // `maintainers:` has *no* renderer-side dedup, so duplicate
1677 // `:autores` entries render verbatim as two identical
1678 // `Maintainer { name, email: None }` records — a strictly worse
1679 // footgun than the peer `:etiquetas` shape.
1680
1681 #[test]
1682 fn autores_violation_on_empty_entry() {
1683 // Canonical paste-from-blank-doc footgun on every kind. The
1684 // wrap envelope wraps [`ManifestError::AutorEmpty`]'s Display
1685 // through verbatim, so the issue string names the offending
1686 // `:autores` axis at the source — the author can grep their
1687 // caixa.lisp for `:autores` and fix the empty entry in one
1688 // edit. Mirrors the peer `etiquetas_violation_on_empty_entry`
1689 // shape (360a499) on the `:etiquetas` axis.
1690 let root = PathBuf::from("/tmp/x");
1691 let manifest = root.join("caixa.lisp");
1692 let default_lib = root.join("lib").join("demo.lisp");
1693 let layout =
1694 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1695 let mut c = caixa(CaixaKind::Biblioteca);
1696 c.autores = vec![String::new()];
1697 let err = layout.verify(&c, &root).unwrap_err();
1698 let LayoutError::AutoresViolation { caixa, issue } = err else {
1699 panic!("expected LayoutError::AutoresViolation, got {err:?}");
1700 };
1701 assert_eq!(caixa, "demo");
1702 assert!(
1703 issue.contains(":autores"),
1704 "issue must name the offending slot: {issue}",
1705 );
1706 }
1707
1708 #[test]
1709 fn autores_violation_on_duplicate_entry() {
1710 // Canonical copy-paste-the-wrong-author footgun. Unlike the
1711 // peer `:etiquetas` axis (silently dedup'd by caixa-helm's
1712 // `BTreeSet` collect at chart render), `:autores` duplicates
1713 // stack verbatim in the rendered `maintainers:` — the gate
1714 // closes the footgun at validate time before any renderer
1715 // sees it. The wrap envelope names the offending author
1716 // verbatim through the inner [`ManifestError::AutorDuplicate`]'s
1717 // Display.
1718 let root = PathBuf::from("/tmp/x");
1719 let manifest = root.join("caixa.lisp");
1720 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
1721 let mut c = caixa(CaixaKind::Servico);
1722 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
1723 c.autores = vec!["pleme-io".into(), "pleme-io".into()];
1724 // The servicos path doesn't exist in this fixture, but the
1725 // `:autores` gate fires before the existence loop (universal
1726 // axis dominates kind-specific existence checks). Wire is
1727 // intact iff the wrap envelope surfaces first.
1728 let err = layout.verify(&c, &root).unwrap_err();
1729 let LayoutError::AutoresViolation { caixa, issue } = err else {
1730 panic!("expected LayoutError::AutoresViolation, got {err:?}");
1731 };
1732 assert_eq!(caixa, "demo");
1733 assert!(
1734 issue.contains("pleme-io"),
1735 "issue must quote the offending author: {issue}",
1736 );
1737 }
1738
1739 #[test]
1740 fn autores_violation_fires_before_kind_coherence_mesh_slot() {
1741 // Cross-axis precedence pin: a Biblioteca with malformed
1742 // `:autores` *and* declared mesh slots (`:membros`) surfaces
1743 // the universal `:autores` diagnostic first, not the
1744 // kind-coherence `MeshSlotsOnNonAplicacao` diagnostic.
1745 // `:autores` is universal (every kind owns the slot), so its
1746 // shape diagnostic is more fundamental than the partition-on-
1747 // kind diagnostic. Mirrors the peer
1748 // `etiquetas_violation_fires_before_kind_coherence_mesh_slot`
1749 // pin (360a499) on the `:etiquetas` axis vs the same kind-
1750 // coherence gates.
1751 let root = PathBuf::from("/tmp/x");
1752 let manifest = root.join("caixa.lisp");
1753 let default_lib = root.join("lib").join("demo.lisp");
1754 let layout =
1755 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1756 let mut c = caixa(CaixaKind::Biblioteca);
1757 c.autores = vec![String::new()];
1758 c.membros = vec![crate::aplicacao::Membro {
1759 caixa: "x".into(),
1760 versao: "^0.1".into(),
1761 }];
1762 let err = layout.verify(&c, &root).unwrap_err();
1763 assert!(
1764 matches!(err, LayoutError::AutoresViolation { .. }),
1765 "got {err:?}",
1766 );
1767 }
1768
1769 #[test]
1770 fn autores_violation_fires_after_etiquetas_violation() {
1771 // Cross-axis precedence pin (inside the Vec-shaped universal
1772 // metadata pair): a caixa with both a malformed `:etiquetas`
1773 // entry *and* a malformed `:autores` entry surfaces
1774 // `EtiquetasViolation` first — `:etiquetas` is the fourth
1775 // universal axis in the cascade and runs before `:autores`,
1776 // peer with the canonical identity-axis-first cascade the
1777 // peer gates establish. Mirrors the peer
1778 // `etiquetas_violation_fires_after_deps_violation` precedence
1779 // pin (360a499) on the dep-axis-before-tag-axis pair.
1780 let root = PathBuf::from("/tmp/x");
1781 let manifest = root.join("caixa.lisp");
1782 let default_lib = root.join("lib").join("demo.lisp");
1783 let layout =
1784 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1785 let mut c = caixa(CaixaKind::Biblioteca);
1786 c.etiquetas = vec![String::new()];
1787 c.autores = vec![String::new()];
1788 let err = layout.verify(&c, &root).unwrap_err();
1789 assert!(
1790 matches!(err, LayoutError::EtiquetasViolation { .. }),
1791 "got {err:?}",
1792 );
1793 }
1794
1795 #[test]
1796 fn autores_violation_accepts_canonical_template() {
1797 // Positive control sanity pin: the canonical `Caixa::template`
1798 // shape (`:autores ()` — empty list) passes the gate trivially.
1799 // Mirrors the peer `etiquetas_violation_accepts_canonical_template`
1800 // pin (360a499).
1801 let root = PathBuf::from("/tmp/x");
1802 let manifest = root.join("caixa.lisp");
1803 let default_lib = root.join("lib").join("demo.lisp");
1804 let layout =
1805 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1806 let c = caixa(CaixaKind::Biblioteca);
1807 layout.verify(&c, &root).expect("template must pass");
1808 }
1809
1810 #[test]
1811 fn autores_violation_on_non_chart_maintainer_shape() {
1812 // Canonical paste-from-multiline-doc footgun: the author
1813 // pasted a multi-line block of author records into one
1814 // `:autores` entry instead of splitting into one entry per
1815 // author. The shape gate fires past the empty + duplicate arms
1816 // via [`Caixa::validate_autores`]'s new
1817 // `is_chart_maintainer_name_shape` cascade, and the layout
1818 // envelope wraps [`ManifestError::AutorInvalid`]'s Display
1819 // through verbatim — the issue string names both the offending
1820 // slot and the offending value (debug-escaped). Peer with the
1821 // `descricao_violation_on_non_chart_shape` pin on the sibling
1822 // universal-axis `Option<String>` surface and the
1823 // `licenca_violation_on_non_spdx_shape` /
1824 // `edicao_violation_on_non_year_shape` peers — and the first
1825 // layout pin on the Vec<String> per-entry shape cascade.
1826 let root = PathBuf::from("/tmp/x");
1827 let manifest = root.join("caixa.lisp");
1828 let default_lib = root.join("lib").join("demo.lisp");
1829 let layout =
1830 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1831 let mut c = caixa(CaixaKind::Biblioteca);
1832 c.autores = vec!["alice\nbob".into()];
1833 let err = layout.verify(&c, &root).unwrap_err();
1834 let LayoutError::AutoresViolation { caixa, issue } = err else {
1835 panic!("expected LayoutError::AutoresViolation, got {err:?}");
1836 };
1837 assert_eq!(caixa, "demo");
1838 assert!(
1839 issue.contains(":autores"),
1840 "issue must name the offending slot: {issue}",
1841 );
1842 assert!(
1843 issue.contains("alice\\nbob"),
1844 "issue must quote the offending value (debug-escaped): {issue}",
1845 );
1846 }
1847
1848 // ── repositorio universal-axis gate wired into verify ────────────────
1849 //
1850 // Pins the layout-pipeline wire-up of [`Caixa::validate_repositorio`]:
1851 // the sixth universal-axis Caixa-level value-shape gate (peer of
1852 // `validate_nome` / `validate_versao` / `validate_deps` /
1853 // `validate_etiquetas` / `validate_autores` / `validate_code_paths`),
1854 // wired immediately after `validate_autores` so the universal
1855 // git-URL axis sits adjacent to the two Vec-shaped universal
1856 // metadata axes (`:etiquetas`, `:autores`) in the cascade. Until
1857 // this wire-up landed `:repositorio` had no shape gate at any
1858 // layer — the universal git-shaped homepage axis was the third
1859 // largest universal authoring surface on the typed Caixa with no
1860 // validate discipline, routing the same string through two
1861 // load-bearing substrate consumers (`caixa-helm`'s `Chart.yaml
1862 // home:` field and `caixa-flux`'s FluxCD `GitRepository.spec.url`)
1863 // via `Option::unwrap_or_else` fallbacks that only fire on `None` —
1864 // a `Some("")` silently passed every fallback and rendered as an
1865 // empty URL in both consumers, breaking at `helm template` /
1866 // FluxCD reconcile time far from the source `caixa.lisp`. The
1867 // gate closes the divergence and makes the two `git URL`-shaped
1868 // surfaces on the typed Caixa (`:repositorio` here, `:deps :fonte
1869 // :repo` peer routed through the same shared
1870 // `crate::render::is_git_repo_url` predicate) structurally
1871 // equivalent by construction.
1872
1873 #[test]
1874 fn repositorio_violation_on_empty_some() {
1875 // Canonical paste-from-blank-doc footgun on every kind. The
1876 // wrap envelope wraps [`ManifestError::RepositorioEmpty`]'s
1877 // Display through verbatim, so the issue string names the
1878 // offending `:repositorio` axis at the source — the author
1879 // can grep their caixa.lisp for `:repositorio ""` and fix the
1880 // empty value in one edit. Mirrors the peer
1881 // `autores_violation_on_empty_entry` shape (86c769b) on the
1882 // `:autores` axis.
1883 let root = PathBuf::from("/tmp/x");
1884 let manifest = root.join("caixa.lisp");
1885 let default_lib = root.join("lib").join("demo.lisp");
1886 let layout =
1887 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1888 let mut c = caixa(CaixaKind::Biblioteca);
1889 c.repositorio = Some(String::new());
1890 let err = layout.verify(&c, &root).unwrap_err();
1891 let LayoutError::RepositorioViolation { caixa, issue } = err else {
1892 panic!("expected LayoutError::RepositorioViolation, got {err:?}");
1893 };
1894 assert_eq!(caixa, "demo");
1895 assert!(
1896 issue.contains(":repositorio"),
1897 "issue must name the offending slot: {issue}",
1898 );
1899 }
1900
1901 #[test]
1902 fn repositorio_violation_on_malformed_shape() {
1903 // Canonical CLI-argument-injection footgun: a leading `-`
1904 // value (`-upload-pack=evil`) escapes the `git clone <repo>`
1905 // subprocess argument boundary at clone time. The shared
1906 // `is_git_repo_url` predicate — the same parser the peer
1907 // `:deps :fonte :repo` axis routes through via
1908 // `DepSource::validate` — refuses every leading-`-` shape at
1909 // validate time. The wrap envelope names the offending value
1910 // verbatim through the inner [`ManifestError::RepositorioInvalid`]'s
1911 // Display.
1912 let root = PathBuf::from("/tmp/x");
1913 let manifest = root.join("caixa.lisp");
1914 let default_lib = root.join("lib").join("demo.lisp");
1915 let layout =
1916 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1917 let mut c = caixa(CaixaKind::Biblioteca);
1918 c.repositorio = Some("-upload-pack=evil".into());
1919 let err = layout.verify(&c, &root).unwrap_err();
1920 let LayoutError::RepositorioViolation { caixa, issue } = err else {
1921 panic!("expected LayoutError::RepositorioViolation, got {err:?}");
1922 };
1923 assert_eq!(caixa, "demo");
1924 assert!(
1925 issue.contains("-upload-pack=evil"),
1926 "issue must quote the offending value: {issue}",
1927 );
1928 }
1929
1930 #[test]
1931 fn repositorio_violation_fires_before_kind_coherence_mesh_slot() {
1932 // Cross-axis precedence pin: a Biblioteca with malformed
1933 // `:repositorio` *and* declared mesh slots (`:membros`)
1934 // surfaces the universal `:repositorio` diagnostic first, not
1935 // the kind-coherence `MeshSlotsOnNonAplicacao` diagnostic.
1936 // `:repositorio` is universal (every kind owns the slot), so
1937 // its shape diagnostic is more fundamental than the
1938 // partition-on-kind diagnostic. Mirrors the peer
1939 // `autores_violation_fires_before_kind_coherence_mesh_slot`
1940 // pin (86c769b) on the `:autores` axis vs the same
1941 // kind-coherence gates.
1942 let root = PathBuf::from("/tmp/x");
1943 let manifest = root.join("caixa.lisp");
1944 let default_lib = root.join("lib").join("demo.lisp");
1945 let layout =
1946 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1947 let mut c = caixa(CaixaKind::Biblioteca);
1948 c.repositorio = Some(String::new());
1949 c.membros = vec![crate::aplicacao::Membro {
1950 caixa: "x".into(),
1951 versao: "^0.1".into(),
1952 }];
1953 let err = layout.verify(&c, &root).unwrap_err();
1954 assert!(
1955 matches!(err, LayoutError::RepositorioViolation { .. }),
1956 "got {err:?}",
1957 );
1958 }
1959
1960 #[test]
1961 fn repositorio_violation_fires_after_autores_violation() {
1962 // Cross-axis precedence pin (inside the universal metadata
1963 // trio): a caixa with both a malformed `:autores` entry *and*
1964 // a malformed `:repositorio` value surfaces `AutoresViolation`
1965 // first — `:autores` is the fifth universal axis in the
1966 // cascade and runs before `:repositorio`, peer with the
1967 // canonical identity-axis-first cascade the peer gates
1968 // establish. Mirrors the peer
1969 // `autores_violation_fires_after_etiquetas_violation`
1970 // precedence pin (86c769b) on the tag-axis-before-author-axis
1971 // pair.
1972 let root = PathBuf::from("/tmp/x");
1973 let manifest = root.join("caixa.lisp");
1974 let default_lib = root.join("lib").join("demo.lisp");
1975 let layout =
1976 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1977 let mut c = caixa(CaixaKind::Biblioteca);
1978 c.autores = vec![String::new()];
1979 c.repositorio = Some(String::new());
1980 let err = layout.verify(&c, &root).unwrap_err();
1981 assert!(
1982 matches!(err, LayoutError::AutoresViolation { .. }),
1983 "got {err:?}",
1984 );
1985 }
1986
1987 #[test]
1988 fn repositorio_violation_accepts_canonical_template() {
1989 // Positive control sanity pin: the canonical `Caixa::template`
1990 // shape (omits `:repositorio` entirely → `None` on the typed
1991 // surface) passes the gate trivially — the gate is a no-op
1992 // when the author didn't author a value. Mirrors the peer
1993 // `autores_violation_accepts_canonical_template` pin (86c769b).
1994 let root = PathBuf::from("/tmp/x");
1995 let manifest = root.join("caixa.lisp");
1996 let default_lib = root.join("lib").join("demo.lisp");
1997 let layout =
1998 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
1999 let c = caixa(CaixaKind::Biblioteca);
2000 layout.verify(&c, &root).expect("template must pass");
2001 }
2002
2003 #[test]
2004 fn repositorio_violation_accepts_canonical_github_shorthand() {
2005 // Positive control pin on the canonical pleme-io `:repositorio`
2006 // shape: the `github:org/repo` shorthand the README quickstart
2007 // and the `caixa-helm` / `caixa-mesh` / `caixa-flux` fixtures
2008 // all use passes the gate end-to-end. Closes the structural
2009 // equivalence between this surface and the peer `:deps :fonte
2010 // :repo` axis — both consume `crate::render::is_git_repo_url`
2011 // and both must agree on the same accepted shape set.
2012 let root = PathBuf::from("/tmp/x");
2013 let manifest = root.join("caixa.lisp");
2014 let default_lib = root.join("lib").join("demo.lisp");
2015 let layout =
2016 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2017 let mut c = caixa(CaixaKind::Biblioteca);
2018 c.repositorio = Some("github:pleme-io/hello-rio".into());
2019 layout.verify(&c, &root).expect("canonical shape must pass");
2020 }
2021
2022 // ── descricao universal-axis gate wired into verify ──────────────────
2023 //
2024 // Pins the layout-pipeline wire-up of [`Caixa::validate_descricao`]:
2025 // the seventh universal-axis Caixa-level value-shape gate (peer of
2026 // `validate_nome` / `validate_versao` / `validate_deps` /
2027 // `validate_etiquetas` / `validate_autores` / `validate_repositorio` /
2028 // `validate_code_paths`), wired immediately after `validate_repositorio`
2029 // so the universal free-form-prose axis sits adjacent to the
2030 // universal git-URL axis in the cascade. Until this wire-up landed
2031 // `:descricao` had no shape gate at any layer — the empty
2032 // `Some("")` silently passed both `caixa-helm` consumers'
2033 // `Option::unwrap_or_else(|| <fallback>)` (which only fire on
2034 // `None`) and rendered as `Chart.yaml description: ""` plus a
2035 // blank `README.md` header, breaking at `helm lint` time
2036 // (`WARNING [chart.metadata.description]: description is required`
2037 // on `apiVersion: v2` charts) far from the source `caixa.lisp`.
2038 // Closes the same `Some("")` skips-`unwrap_or_else` footgun the
2039 // peer `:repositorio` gate (577b0a9) closed, on the universal
2040 // free-form-prose summary axis.
2041
2042 #[test]
2043 fn descricao_violation_on_empty_some() {
2044 // Canonical paste-from-blank-doc footgun on every kind. The
2045 // wrap envelope wraps [`ManifestError::DescricaoEmpty`]'s
2046 // Display through verbatim, so the issue string names the
2047 // offending `:descricao` axis at the source — the author can
2048 // grep their caixa.lisp for `:descricao ""` and fix the empty
2049 // value in one edit. Mirrors the peer
2050 // `repositorio_violation_on_empty_some` shape (577b0a9) on
2051 // the sibling `Option<String>` `:repositorio` axis.
2052 let root = PathBuf::from("/tmp/x");
2053 let manifest = root.join("caixa.lisp");
2054 let default_lib = root.join("lib").join("demo.lisp");
2055 let layout =
2056 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2057 let mut c = caixa(CaixaKind::Biblioteca);
2058 c.descricao = Some(String::new());
2059 let err = layout.verify(&c, &root).unwrap_err();
2060 let LayoutError::DescricaoViolation { caixa, issue } = err else {
2061 panic!("expected LayoutError::DescricaoViolation, got {err:?}");
2062 };
2063 assert_eq!(caixa, "demo");
2064 assert!(
2065 issue.contains(":descricao"),
2066 "issue must name the offending slot: {issue}",
2067 );
2068 }
2069
2070 #[test]
2071 fn descricao_violation_fires_before_kind_coherence_mesh_slot() {
2072 // Cross-axis precedence pin: a Biblioteca with empty
2073 // `:descricao` *and* declared mesh slots (`:membros`)
2074 // surfaces the universal `:descricao` diagnostic first, not
2075 // the kind-coherence `MeshSlotsOnNonAplicacao` diagnostic.
2076 // `:descricao` is universal (every kind owns the slot), so
2077 // its shape diagnostic is more fundamental than the
2078 // partition-on-kind diagnostic. Mirrors the peer
2079 // `repositorio_violation_fires_before_kind_coherence_mesh_slot`
2080 // pin (577b0a9) on the `:repositorio` axis vs the same
2081 // kind-coherence gates.
2082 let root = PathBuf::from("/tmp/x");
2083 let manifest = root.join("caixa.lisp");
2084 let default_lib = root.join("lib").join("demo.lisp");
2085 let layout =
2086 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2087 let mut c = caixa(CaixaKind::Biblioteca);
2088 c.descricao = Some(String::new());
2089 c.membros = vec![crate::aplicacao::Membro {
2090 caixa: "x".into(),
2091 versao: "^0.1".into(),
2092 }];
2093 let err = layout.verify(&c, &root).unwrap_err();
2094 assert!(
2095 matches!(err, LayoutError::DescricaoViolation { .. }),
2096 "got {err:?}",
2097 );
2098 }
2099
2100 #[test]
2101 fn descricao_violation_fires_after_repositorio_violation() {
2102 // Cross-axis precedence pin (inside the universal metadata
2103 // cascade): a caixa with both a malformed `:repositorio` *and*
2104 // an empty `:descricao` surfaces `RepositorioViolation`
2105 // first — `:repositorio` is the sixth universal axis in the
2106 // cascade and runs before `:descricao`, peer with the
2107 // canonical identity-axis-first cascade the peer gates
2108 // establish. Mirrors the peer
2109 // `repositorio_violation_fires_after_autores_violation`
2110 // precedence pin (577b0a9) on the autores-axis-before-
2111 // repositorio-axis pair.
2112 let root = PathBuf::from("/tmp/x");
2113 let manifest = root.join("caixa.lisp");
2114 let default_lib = root.join("lib").join("demo.lisp");
2115 let layout =
2116 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2117 let mut c = caixa(CaixaKind::Biblioteca);
2118 c.repositorio = Some(String::new());
2119 c.descricao = Some(String::new());
2120 let err = layout.verify(&c, &root).unwrap_err();
2121 assert!(
2122 matches!(err, LayoutError::RepositorioViolation { .. }),
2123 "got {err:?}",
2124 );
2125 }
2126
2127 #[test]
2128 fn descricao_violation_accepts_none() {
2129 // Positive control sanity pin: a caixa that omits
2130 // `:descricao` entirely (the canonical `Caixa::template` shape
2131 // carries `Some("FIXME — describe this caixa")`, but the
2132 // layout-test fixture defaults to `None`) passes the gate
2133 // trivially — the gate is a no-op when the author didn't
2134 // author a value. Mirrors the peer
2135 // `repositorio_violation_accepts_canonical_template` pin
2136 // (577b0a9).
2137 let root = PathBuf::from("/tmp/x");
2138 let manifest = root.join("caixa.lisp");
2139 let default_lib = root.join("lib").join("demo.lisp");
2140 let layout =
2141 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2142 let c = caixa(CaixaKind::Biblioteca);
2143 layout.verify(&c, &root).expect("None must pass");
2144 }
2145
2146 #[test]
2147 fn descricao_violation_accepts_canonical_summary() {
2148 // Positive control pin on the canonical pleme-io `:descricao`
2149 // shape: a short free-form prose summary the `caixa-helm` /
2150 // `caixa-flux` / `caixa-mesh` fixtures all carry passes the
2151 // gate end-to-end.
2152 let root = PathBuf::from("/tmp/x");
2153 let manifest = root.join("caixa.lisp");
2154 let default_lib = root.join("lib").join("demo.lisp");
2155 let layout =
2156 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2157 let mut c = caixa(CaixaKind::Biblioteca);
2158 c.descricao = Some("Canonical Rust→wasm32-wasip2 caixa Servico.".into());
2159 layout
2160 .verify(&c, &root)
2161 .expect("canonical summary must pass");
2162 }
2163
2164 #[test]
2165 fn descricao_violation_on_non_chart_shape() {
2166 // Shape-predicate wire-up pin: a malformed `:descricao` value
2167 // that's a non-empty `Some(s)` but carries a paste-from-
2168 // multiline-doc embedded newline surfaces the
2169 // `DescricaoViolation` envelope via the manifest-layer
2170 // `ManifestError::DescricaoInvalid` arm. Mirrors the peer
2171 // `descricao_violation_on_empty_some` shape on the empty arm
2172 // of the same axis and the peer
2173 // `licenca_violation_on_non_spdx_shape` shape on the sibling
2174 // `:licenca` axis. Until this gate landed a value like
2175 // `"Checkout\nflow."` (an embedded newline) or `"Checkout
2176 // flow. "` (a trailing whitespace) silently passed
2177 // `StandardLayout::verify` and landed in the rendered
2178 // Chart.yaml `description:` field as a YAML-illegal
2179 // multi-line scalar or a silently-trimmed whitespace
2180 // round-trip far from the source caixa.lisp.
2181 let root = PathBuf::from("/tmp/x");
2182 let manifest = root.join("caixa.lisp");
2183 let default_lib = root.join("lib").join("demo.lisp");
2184 let layout =
2185 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2186 let mut c = caixa(CaixaKind::Biblioteca);
2187 c.descricao = Some("Checkout\nflow.".into());
2188 let err = layout.verify(&c, &root).unwrap_err();
2189 let LayoutError::DescricaoViolation { caixa, issue } = err else {
2190 panic!("expected LayoutError::DescricaoViolation, got {err:?}");
2191 };
2192 assert_eq!(caixa, "demo");
2193 assert!(
2194 issue.contains(":descricao"),
2195 "issue must name the offending slot: {issue}",
2196 );
2197 // The wrapped `ManifestError::DescricaoInvalid` Display uses
2198 // `{descricao:?}` (Debug) so the embedded newline surfaces
2199 // debug-escaped as `\n` in the issue string.
2200 assert!(
2201 issue.contains("Checkout\\nflow."),
2202 "issue must quote the offending value (debug-escaped): {issue}",
2203 );
2204 }
2205
2206 // ── :licenca empty-Some shape wired into verify (universal axis) ──
2207 //
2208 // Until this wire-up landed `Caixa::validate_licenca` did not
2209 // exist — the universal SPDX-shaped license-expression axis had
2210 // no shape gate at any layer, so an empty `Some("")` silently
2211 // passed `Caixa::from_lisp` and `StandardLayout::verify` and
2212 // landed as a bare trailing period in the rendered
2213 // `lareira-<nome>` chart's `README.md` `## License` section via
2214 // the `caixa-helm` consumer's `caixa.licenca.clone().unwrap_or_else(||
2215 // "MIT".into())` (which only fires on `None`) at
2216 // `caixa-helm/src/lib.rs:361`. Closes the same `Some("")`
2217 // skips-`unwrap_or_else` footgun the peer `:repositorio`
2218 // (577b0a9) and `:descricao` (4e6db38) gates closed, on the
2219 // universal license-expression axis.
2220
2221 #[test]
2222 fn licenca_violation_on_empty_some() {
2223 // Canonical paste-from-blank-doc footgun on every kind. The
2224 // wrap envelope wraps [`ManifestError::LicencaEmpty`]'s
2225 // Display through verbatim, so the issue string names the
2226 // offending `:licenca` axis at the source — the author can
2227 // grep their caixa.lisp for `:licenca ""` and fix the empty
2228 // value in one edit. Mirrors the peer
2229 // `descricao_violation_on_empty_some` shape (4e6db38) on
2230 // the sibling `Option<String>` `:licenca` axis.
2231 let root = PathBuf::from("/tmp/x");
2232 let manifest = root.join("caixa.lisp");
2233 let default_lib = root.join("lib").join("demo.lisp");
2234 let layout =
2235 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2236 let mut c = caixa(CaixaKind::Biblioteca);
2237 c.licenca = Some(String::new());
2238 let err = layout.verify(&c, &root).unwrap_err();
2239 let LayoutError::LicencaViolation { caixa, issue } = err else {
2240 panic!("expected LayoutError::LicencaViolation, got {err:?}");
2241 };
2242 assert_eq!(caixa, "demo");
2243 assert!(
2244 issue.contains(":licenca"),
2245 "issue must name the offending slot: {issue}",
2246 );
2247 }
2248
2249 #[test]
2250 fn licenca_violation_fires_before_kind_coherence_mesh_slot() {
2251 // Cross-axis precedence pin: a Biblioteca with empty
2252 // `:licenca` *and* declared mesh slots (`:membros`)
2253 // surfaces the universal `:licenca` diagnostic first, not
2254 // the kind-coherence `MeshSlotsOnNonAplicacao` diagnostic.
2255 // `:licenca` is universal (every kind owns the slot), so
2256 // its shape diagnostic is more fundamental than the
2257 // partition-on-kind diagnostic. Mirrors the peer
2258 // `descricao_violation_fires_before_kind_coherence_mesh_slot`
2259 // pin (4e6db38) on the `:descricao` axis vs the same
2260 // kind-coherence gates.
2261 let root = PathBuf::from("/tmp/x");
2262 let manifest = root.join("caixa.lisp");
2263 let default_lib = root.join("lib").join("demo.lisp");
2264 let layout =
2265 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2266 let mut c = caixa(CaixaKind::Biblioteca);
2267 c.licenca = Some(String::new());
2268 c.membros = vec![crate::aplicacao::Membro {
2269 caixa: "x".into(),
2270 versao: "^0.1".into(),
2271 }];
2272 let err = layout.verify(&c, &root).unwrap_err();
2273 assert!(
2274 matches!(err, LayoutError::LicencaViolation { .. }),
2275 "got {err:?}",
2276 );
2277 }
2278
2279 #[test]
2280 fn licenca_violation_fires_after_descricao_violation() {
2281 // Cross-axis precedence pin (inside the universal metadata
2282 // cascade): a caixa with both an empty `:descricao` *and*
2283 // an empty `:licenca` surfaces `DescricaoViolation`
2284 // first — `:descricao` is the seventh universal axis in the
2285 // cascade and runs before `:licenca`, peer with the
2286 // canonical identity-axis-first cascade the peer gates
2287 // establish. Mirrors the peer
2288 // `descricao_violation_fires_after_repositorio_violation`
2289 // precedence pin (4e6db38) on the repositorio-axis-before-
2290 // descricao-axis pair.
2291 let root = PathBuf::from("/tmp/x");
2292 let manifest = root.join("caixa.lisp");
2293 let default_lib = root.join("lib").join("demo.lisp");
2294 let layout =
2295 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2296 let mut c = caixa(CaixaKind::Biblioteca);
2297 c.descricao = Some(String::new());
2298 c.licenca = Some(String::new());
2299 let err = layout.verify(&c, &root).unwrap_err();
2300 assert!(
2301 matches!(err, LayoutError::DescricaoViolation { .. }),
2302 "got {err:?}",
2303 );
2304 }
2305
2306 #[test]
2307 fn licenca_violation_accepts_none() {
2308 // Positive control sanity pin: a caixa that omits `:licenca`
2309 // entirely (the layout-test fixture defaults to `None`)
2310 // passes the gate trivially — the gate is a no-op when the
2311 // author didn't author a value. Mirrors the peer
2312 // `descricao_violation_accepts_none` pin (4e6db38).
2313 let root = PathBuf::from("/tmp/x");
2314 let manifest = root.join("caixa.lisp");
2315 let default_lib = root.join("lib").join("demo.lisp");
2316 let layout =
2317 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2318 let c = caixa(CaixaKind::Biblioteca);
2319 layout.verify(&c, &root).expect("None must pass");
2320 }
2321
2322 #[test]
2323 fn licenca_violation_accepts_canonical_expression() {
2324 // Positive control pin on the canonical pleme-io `:licenca`
2325 // shape: a non-empty SPDX expression the `caixa-helm` /
2326 // `caixa-flux` / `caixa-mesh` fixtures all carry (`"MIT"`)
2327 // passes the gate end-to-end.
2328 let root = PathBuf::from("/tmp/x");
2329 let manifest = root.join("caixa.lisp");
2330 let default_lib = root.join("lib").join("demo.lisp");
2331 let layout =
2332 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2333 let mut c = caixa(CaixaKind::Biblioteca);
2334 c.licenca = Some("Apache-2.0 OR MIT".into());
2335 layout
2336 .verify(&c, &root)
2337 .expect("canonical SPDX expression must pass");
2338 }
2339
2340 #[test]
2341 fn licenca_violation_on_non_spdx_shape() {
2342 // Shape-predicate wire-up pin: a malformed `:licenca` value
2343 // that's a non-empty `Some(s)` but falls outside the SPDX
2344 // expression alphabet floor surfaces the `LicencaViolation`
2345 // envelope via the manifest-layer `ManifestError::LicencaInvalid`
2346 // arm. Mirrors the peer `licenca_violation_on_empty_some`
2347 // shape on the empty arm of the same axis and the peer
2348 // `edicao_violation_on_non_year_shape` shape on the sibling
2349 // `:edicao` axis. Until this gate landed a value like
2350 // `"Apache_2.0"` (an underscore-instead-of-hyphen typo) or
2351 // `"MIT, Apache-2.0"` (a comma-instead-of-`OR`-keyword
2352 // colloquial idiom) silently passed `StandardLayout::verify`
2353 // and landed in the rendered chart `README.md` `## License`
2354 // section + a future SPDX-aware Chart.yaml `license:`
2355 // emitter would refuse the value at `helm lint` time far
2356 // from the source caixa.lisp.
2357 let root = PathBuf::from("/tmp/x");
2358 let manifest = root.join("caixa.lisp");
2359 let default_lib = root.join("lib").join("demo.lisp");
2360 let layout =
2361 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2362 let mut c = caixa(CaixaKind::Biblioteca);
2363 c.licenca = Some("Apache_2.0".into());
2364 let err = layout.verify(&c, &root).unwrap_err();
2365 let LayoutError::LicencaViolation { caixa, issue } = err else {
2366 panic!("expected LayoutError::LicencaViolation, got {err:?}");
2367 };
2368 assert_eq!(caixa, "demo");
2369 assert!(
2370 issue.contains(":licenca"),
2371 "issue must name the offending slot: {issue}",
2372 );
2373 assert!(
2374 issue.contains("Apache_2.0"),
2375 "issue must quote the offending value: {issue}",
2376 );
2377 }
2378
2379 // ── :edicao empty-Some shape wired into verify (universal axis) ──
2380 //
2381 // Until this wire-up landed `Caixa::validate_edicao` did not
2382 // exist — the universal language-edition axis had no shape gate
2383 // at any layer, so an empty `Some("")` silently passed
2384 // `Caixa::from_lisp` and `StandardLayout::verify` and landed as a
2385 // bare `(:edicao "")` line in the rendered caixa.lisp, ready for
2386 // a future renderer-side consumer's `Option::unwrap_or_else`
2387 // (which only fires on `None`) to skip its fallback. Closes the
2388 // same `Some("")`-skips-`unwrap_or_else` footgun the peer
2389 // `:repositorio` (577b0a9), `:descricao` (4e6db38), and
2390 // `:licenca` (3d1e535) gates closed, on the universal language-
2391 // edition axis — the last un-gated universal-axis
2392 // `Option<String>` Caixa-level value-shape surface.
2393
2394 #[test]
2395 fn edicao_violation_on_empty_some() {
2396 // Canonical paste-from-blank-doc footgun on every kind. The
2397 // wrap envelope wraps [`ManifestError::EdicaoEmpty`]'s
2398 // Display through verbatim, so the issue string names the
2399 // offending `:edicao` axis at the source — the author can
2400 // grep their caixa.lisp for `:edicao ""` and fix the empty
2401 // value in one edit. Mirrors the peer
2402 // `licenca_violation_on_empty_some` shape (3d1e535) on the
2403 // sibling `Option<String>` `:edicao` axis.
2404 let root = PathBuf::from("/tmp/x");
2405 let manifest = root.join("caixa.lisp");
2406 let default_lib = root.join("lib").join("demo.lisp");
2407 let layout =
2408 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2409 let mut c = caixa(CaixaKind::Biblioteca);
2410 c.edicao = Some(String::new());
2411 let err = layout.verify(&c, &root).unwrap_err();
2412 let LayoutError::EdicaoViolation { caixa, issue } = err else {
2413 panic!("expected LayoutError::EdicaoViolation, got {err:?}");
2414 };
2415 assert_eq!(caixa, "demo");
2416 assert!(
2417 issue.contains(":edicao"),
2418 "issue must name the offending slot: {issue}",
2419 );
2420 }
2421
2422 #[test]
2423 fn edicao_violation_fires_before_kind_coherence_mesh_slot() {
2424 // Cross-axis precedence pin: a Biblioteca with empty
2425 // `:edicao` *and* declared mesh slots (`:membros`) surfaces
2426 // the universal `:edicao` diagnostic first, not the
2427 // kind-coherence `MeshSlotsOnNonAplicacao` diagnostic.
2428 // `:edicao` is universal (every kind owns the slot), so
2429 // its shape diagnostic is more fundamental than the
2430 // partition-on-kind diagnostic. Mirrors the peer
2431 // `licenca_violation_fires_before_kind_coherence_mesh_slot`
2432 // pin (3d1e535) on the `:licenca` axis vs the same
2433 // kind-coherence gates.
2434 let root = PathBuf::from("/tmp/x");
2435 let manifest = root.join("caixa.lisp");
2436 let default_lib = root.join("lib").join("demo.lisp");
2437 let layout =
2438 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2439 let mut c = caixa(CaixaKind::Biblioteca);
2440 c.edicao = Some(String::new());
2441 c.membros = vec![crate::aplicacao::Membro {
2442 caixa: "x".into(),
2443 versao: "^0.1".into(),
2444 }];
2445 let err = layout.verify(&c, &root).unwrap_err();
2446 assert!(
2447 matches!(err, LayoutError::EdicaoViolation { .. }),
2448 "got {err:?}",
2449 );
2450 }
2451
2452 #[test]
2453 fn edicao_violation_fires_after_licenca_violation() {
2454 // Cross-axis precedence pin (inside the universal metadata
2455 // cascade): a caixa with both an empty `:licenca` *and* an
2456 // empty `:edicao` surfaces `LicencaViolation` first —
2457 // `:licenca` is the eighth universal axis in the cascade
2458 // and runs before `:edicao`, peer with the canonical
2459 // identity-axis-first cascade the peer gates establish.
2460 // Mirrors the peer
2461 // `licenca_violation_fires_after_descricao_violation`
2462 // precedence pin (3d1e535) on the descricao-axis-before-
2463 // licenca-axis pair.
2464 let root = PathBuf::from("/tmp/x");
2465 let manifest = root.join("caixa.lisp");
2466 let default_lib = root.join("lib").join("demo.lisp");
2467 let layout =
2468 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2469 let mut c = caixa(CaixaKind::Biblioteca);
2470 c.licenca = Some(String::new());
2471 c.edicao = Some(String::new());
2472 let err = layout.verify(&c, &root).unwrap_err();
2473 assert!(
2474 matches!(err, LayoutError::LicencaViolation { .. }),
2475 "got {err:?}",
2476 );
2477 }
2478
2479 #[test]
2480 fn edicao_violation_accepts_none() {
2481 // Positive control sanity pin: a caixa that omits `:edicao`
2482 // entirely (the layout-test fixture defaults to `None`)
2483 // passes the gate trivially — the gate is a no-op when the
2484 // author didn't author a value. Mirrors the peer
2485 // `licenca_violation_accepts_none` pin (3d1e535).
2486 let root = PathBuf::from("/tmp/x");
2487 let manifest = root.join("caixa.lisp");
2488 let default_lib = root.join("lib").join("demo.lisp");
2489 let layout =
2490 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2491 let c = caixa(CaixaKind::Biblioteca);
2492 layout.verify(&c, &root).expect("None must pass");
2493 }
2494
2495 #[test]
2496 fn edicao_violation_accepts_canonical_value() {
2497 // Positive control pin on the canonical pleme-io `:edicao`
2498 // shape: the `"2026"` edition every `caixa-helm` /
2499 // `caixa-flux` / `caixa-mesh` / `caixa-core/src/render.rs`
2500 // fixture carries by construction passes the gate end-to-end.
2501 let root = PathBuf::from("/tmp/x");
2502 let manifest = root.join("caixa.lisp");
2503 let default_lib = root.join("lib").join("demo.lisp");
2504 let layout =
2505 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2506 let mut c = caixa(CaixaKind::Biblioteca);
2507 c.edicao = Some("2026".into());
2508 layout
2509 .verify(&c, &root)
2510 .expect("canonical edition must pass");
2511 }
2512
2513 #[test]
2514 fn edicao_violation_on_non_year_shape() {
2515 // Shape-predicate wire-up pin: a malformed `:edicao` value
2516 // that's a non-empty `Some(s)` but not a 4-digit ASCII
2517 // decimal year surfaces the `EdicaoViolation` envelope via
2518 // the manifest-layer `ManifestError::EdicaoInvalid` arm.
2519 // Mirrors the peer `edicao_violation_on_empty_some` shape
2520 // on the empty arm of the same axis. Until this gate landed
2521 // a value like `"v2026"` (a familiar git-tag idiom that
2522 // doesn't apply to the year-shaped edition axis) silently
2523 // passed `StandardLayout::verify` and broke at the
2524 // substrate's build-time edition selector far from the
2525 // source caixa.lisp.
2526 let root = PathBuf::from("/tmp/x");
2527 let manifest = root.join("caixa.lisp");
2528 let default_lib = root.join("lib").join("demo.lisp");
2529 let layout =
2530 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
2531 let mut c = caixa(CaixaKind::Biblioteca);
2532 c.edicao = Some("v2026".into());
2533 let err = layout.verify(&c, &root).unwrap_err();
2534 let LayoutError::EdicaoViolation { caixa, issue } = err else {
2535 panic!("expected LayoutError::EdicaoViolation, got {err:?}");
2536 };
2537 assert_eq!(caixa, "demo");
2538 assert!(
2539 issue.contains(":edicao"),
2540 "issue must name the offending slot: {issue}",
2541 );
2542 assert!(
2543 issue.contains("v2026"),
2544 "issue must quote the offending value: {issue}",
2545 );
2546 }
2547
2548 // ── Caixa-identity gates (`:nome`, `:versao`) wired into verify ────
2549 //
2550 // Until this wire-up landed `Caixa::validate_nome` and
2551 // `Caixa::validate_versao` lived as `pub fn` on `Caixa` with full
2552 // per-arm unit coverage in `manifest::tests`, but no production
2553 // path called them — `feira build` silently accepted malformed
2554 // `:nome` / `:versao` and the failure surfaced at `helm install` /
2555 // `kubectl apply` / `feira publish` / lacre-resolve / `:upgrade-from
2556 // :from` matching time, far from the source `caixa.lisp`. The
2557 // following pins fence the layout-pipeline wire-up: every layout
2558 // verify on a structurally-invalid Caixa identity axis surfaces
2559 // the per-axis `*Violation { caixa, issue }` envelope before any
2560 // kind-coherence, code-path, or downstream gate sees it.
2561
2562 #[test]
2563 fn nome_violation_on_uppercase() {
2564 let root = PathBuf::from("/tmp/x");
2565 let manifest = root.join("caixa.lisp");
2566 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2567 let mut c = caixa(CaixaKind::Biblioteca);
2568 c.nome = "MyApp".into();
2569 let err = layout.verify(&c, &root).unwrap_err();
2570 let LayoutError::NomeViolation { caixa, issue } = err else {
2571 panic!("expected LayoutError::NomeViolation, got {err:?}");
2572 };
2573 assert_eq!(caixa, "MyApp");
2574 assert!(
2575 issue.contains("MyApp"),
2576 "issue must quote the offending nome: {issue}",
2577 );
2578 }
2579
2580 #[test]
2581 fn nome_violation_on_underscore() {
2582 let root = PathBuf::from("/tmp/x");
2583 let manifest = root.join("caixa.lisp");
2584 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2585 let mut c = caixa(CaixaKind::Biblioteca);
2586 c.nome = "my_app".into();
2587 let err = layout.verify(&c, &root).unwrap_err();
2588 assert!(
2589 matches!(err, LayoutError::NomeViolation { ref caixa, .. } if caixa == "my_app"),
2590 "got {err:?}",
2591 );
2592 }
2593
2594 #[test]
2595 fn nome_violation_on_empty() {
2596 // Empty `:nome` surfaces NomeViolation wrapping the narrower
2597 // `ManifestError::NomeEmpty` arm — the empty-first cascade the
2598 // peer per-axis name gates already use.
2599 let root = PathBuf::from("/tmp/x");
2600 let manifest = root.join("caixa.lisp");
2601 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2602 let mut c = caixa(CaixaKind::Biblioteca);
2603 c.nome = String::new();
2604 let err = layout.verify(&c, &root).unwrap_err();
2605 let LayoutError::NomeViolation { caixa, issue } = err else {
2606 panic!("expected LayoutError::NomeViolation, got {err:?}");
2607 };
2608 assert!(caixa.is_empty());
2609 assert!(
2610 issue.contains(":nome is empty"),
2611 "issue must surface the empty-arm diagnostic: {issue}",
2612 );
2613 }
2614
2615 #[test]
2616 fn versao_violation_on_missing_patch() {
2617 // `"0.1"` — the canonical "I shortened it" footgun. Helm /
2618 // OCI / lacre-resolve / `:upgrade-from :from` all strict-parse
2619 // through `semver::Version::parse`, which refuses a two-part
2620 // shape.
2621 let root = PathBuf::from("/tmp/x");
2622 let manifest = root.join("caixa.lisp");
2623 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2624 let mut c = caixa(CaixaKind::Biblioteca);
2625 c.versao = "0.1".into();
2626 let err = layout.verify(&c, &root).unwrap_err();
2627 let LayoutError::VersaoViolation { caixa, issue } = err else {
2628 panic!("expected LayoutError::VersaoViolation, got {err:?}");
2629 };
2630 assert_eq!(caixa, "demo");
2631 assert!(
2632 issue.contains("0.1"),
2633 "issue must quote the offending versao: {issue}",
2634 );
2635 }
2636
2637 #[test]
2638 fn versao_violation_on_git_tag_shape() {
2639 // `"v0.1.0"` — the git-tag-shape-leaking-into-versao typo.
2640 let root = PathBuf::from("/tmp/x");
2641 let manifest = root.join("caixa.lisp");
2642 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2643 let mut c = caixa(CaixaKind::Biblioteca);
2644 c.versao = "v0.1.0".into();
2645 let err = layout.verify(&c, &root).unwrap_err();
2646 assert!(
2647 matches!(err, LayoutError::VersaoViolation { ref issue, .. }
2648 if issue.contains("v0.1.0")),
2649 "got {err:?}",
2650 );
2651 }
2652
2653 #[test]
2654 fn versao_violation_on_empty() {
2655 let root = PathBuf::from("/tmp/x");
2656 let manifest = root.join("caixa.lisp");
2657 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2658 let mut c = caixa(CaixaKind::Biblioteca);
2659 c.versao = String::new();
2660 let err = layout.verify(&c, &root).unwrap_err();
2661 let LayoutError::VersaoViolation { caixa, issue } = err else {
2662 panic!("expected LayoutError::VersaoViolation, got {err:?}");
2663 };
2664 assert_eq!(caixa, "demo");
2665 assert!(
2666 issue.contains(":versao is empty"),
2667 "issue must surface the empty-arm diagnostic: {issue}",
2668 );
2669 }
2670
2671 #[test]
2672 fn nome_violation_fires_before_versao_violation() {
2673 // Precedence pin: when both `:nome` and `:versao` are malformed,
2674 // `:nome` surfaces first — the canonical declaration-order
2675 // precedence the `ManifestError` family establishes, the same
2676 // grep-order the author follows when fixing in `caixa.lisp`.
2677 let root = PathBuf::from("/tmp/x");
2678 let manifest = root.join("caixa.lisp");
2679 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2680 let mut c = caixa(CaixaKind::Biblioteca);
2681 c.nome = "MyApp".into();
2682 c.versao = "0.1".into();
2683 let err = layout.verify(&c, &root).unwrap_err();
2684 assert!(
2685 matches!(err, LayoutError::NomeViolation { .. }),
2686 "got {err:?} — nome must fire before versao",
2687 );
2688 }
2689
2690 #[test]
2691 fn nome_violation_fires_before_kind_coherence() {
2692 // Precedence pin: a Biblioteca caixa with a malformed `:nome`
2693 // AND a declared mesh slot surfaces NomeViolation, not
2694 // MeshSlotsOnNonAplicacao — the identity-axis gate is more
2695 // fundamental than the kind-coherence gate (which carries
2696 // `caixa.nome` verbatim in its diagnostic, and so depends on the
2697 // name being structurally valid to render a useful message).
2698 let root = PathBuf::from("/tmp/x");
2699 let manifest = root.join("caixa.lisp");
2700 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2701 let mut c = caixa(CaixaKind::Biblioteca);
2702 c.nome = "MyApp".into();
2703 c.membros = vec![crate::aplicacao::Membro {
2704 caixa: "x".into(),
2705 versao: "^0.1".into(),
2706 }];
2707 let err = layout.verify(&c, &root).unwrap_err();
2708 assert!(
2709 matches!(err, LayoutError::NomeViolation { .. }),
2710 "got {err:?} — nome must fire before MeshSlotsOnNonAplicacao",
2711 );
2712 }
2713
2714 #[test]
2715 fn nome_violation_fires_before_owncode() {
2716 // Precedence pin: a Supervisor with a malformed `:nome` AND
2717 // declared `:bibliotecas` surfaces NomeViolation, not
2718 // SupervisorOwnsCode — same rationale as above.
2719 let root = PathBuf::from("/tmp/x");
2720 let manifest = root.join("caixa.lisp");
2721 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2722 let mut c = caixa(CaixaKind::Supervisor);
2723 c.nome = "MyApp".into();
2724 c.bibliotecas = vec!["lib/x.lisp".into()];
2725 let err = layout.verify(&c, &root).unwrap_err();
2726 assert!(
2727 matches!(err, LayoutError::NomeViolation { .. }),
2728 "got {err:?} — nome must fire before SupervisorOwnsCode",
2729 );
2730 }
2731
2732 #[test]
2733 fn versao_violation_fires_before_kind_coherence() {
2734 // Precedence pin: a Biblioteca with a valid `:nome` but a
2735 // malformed `:versao` AND a declared servico slot surfaces
2736 // VersaoViolation before ServicoSlotsOnNonServico.
2737 let root = PathBuf::from("/tmp/x");
2738 let manifest = root.join("caixa.lisp");
2739 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2740 let mut c = caixa(CaixaKind::Biblioteca);
2741 c.versao = "v0.1.0".into();
2742 c.limits = Some(crate::LimitsSpec {
2743 memory: Some(64 * 1024 * 1024),
2744 ..Default::default()
2745 });
2746 let err = layout.verify(&c, &root).unwrap_err();
2747 assert!(
2748 matches!(err, LayoutError::VersaoViolation { .. }),
2749 "got {err:?} — versao must fire before ServicoSlotsOnNonServico",
2750 );
2751 }
2752
2753 #[test]
2754 fn nome_violation_fires_before_missing_lib() {
2755 // Precedence pin: a Biblioteca with a malformed `:nome` and no
2756 // lib entry surfaces NomeViolation, not MissingLib — the
2757 // identity-axis gate is more fundamental than the layout's
2758 // `lib/<nome>.lisp` default-path check (which derives the
2759 // expected path from `:nome` itself, so would surface a
2760 // misleading "expected lib/MyApp.lisp" diagnostic against an
2761 // unrecoverable name).
2762 let root = PathBuf::from("/tmp/x");
2763 let manifest = root.join("caixa.lisp");
2764 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2765 let mut c = caixa(CaixaKind::Biblioteca);
2766 c.nome = "MyApp".into();
2767 let err = layout.verify(&c, &root).unwrap_err();
2768 assert!(
2769 matches!(err, LayoutError::NomeViolation { .. }),
2770 "got {err:?} — nome must fire before MissingLib",
2771 );
2772 }
2773
2774 #[test]
2775 fn nome_versao_violations_fire_after_missing_manifest() {
2776 // Precedence pin: `MissingManifest` still dominates — there's
2777 // no caixa to identity-check when the manifest is missing.
2778 let root = PathBuf::from("/tmp/x");
2779 let layout = StandardLayout::new().with_path_exists(|_| false);
2780 let mut c = caixa(CaixaKind::Biblioteca);
2781 c.nome = "MyApp".into();
2782 c.versao = "0.1".into();
2783 let err = layout.verify(&c, &root).unwrap_err();
2784 assert!(
2785 matches!(err, LayoutError::MissingManifest(_)),
2786 "got {err:?} — MissingManifest must dominate identity gates",
2787 );
2788 }
2789
2790 #[test]
2791 fn valid_nome_versao_passes_to_downstream_gates() {
2792 // Sanity pin: the canonical "demo" / "0.1.0" identity passes
2793 // both axes; downstream gates (MissingLib here) take over.
2794 let root = PathBuf::from("/tmp/x");
2795 let manifest = root.join("caixa.lisp");
2796 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2797 let err = layout
2798 .verify(&caixa(CaixaKind::Biblioteca), &root)
2799 .unwrap_err();
2800 assert!(
2801 matches!(err, LayoutError::MissingLib { .. }),
2802 "got {err:?} — valid identity must pass to MissingLib",
2803 );
2804 }
2805
2806 // ── :deps / :deps-dev shape gate (lifted to layout-level verify) ─────
2807 //
2808 // Until this wire-up landed `Caixa::validate_deps` lived as `pub fn`
2809 // on `Caixa` with full per-arm unit coverage in `manifest::tests` +
2810 // `dep::tests` but no production path called it — `feira build`
2811 // silently accepted a malformed `:deps` / `:deps-dev` entry and the
2812 // failure surfaced at lacre-resolve / `git clone` / `cargo metadata`
2813 // / `helm install` time on the *first* downstream consumer to
2814 // strict-parse the value, far from the source `caixa.lisp` and
2815 // without any field naming the offending `:deps` axis. The following
2816 // pins fence the layout-pipeline wire-up: every layout verify on a
2817 // structurally-invalid `:deps` value-shape surfaces the per-axis
2818 // `DepsViolation { caixa, issue }` envelope (peer of
2819 // `NomeViolation` / `VersaoViolation` / `CodePathViolation` /
2820 // `LimitsViolation` / `BehaviorViolation` / `UpgradeViolation` /
2821 // `SupervisorViolation` / `AplicacaoViolation`) before any kind-
2822 // coherence, code-path, or downstream gate sees it.
2823
2824 #[test]
2825 fn deps_violation_on_empty_dep_nome() {
2826 // Empty `:nome` on a `:deps` entry surfaces the narrower
2827 // `DepError::NomeEmpty` arm through the wrap envelope.
2828 use crate::Dep;
2829 let root = PathBuf::from("/tmp/x");
2830 let manifest = root.join("caixa.lisp");
2831 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2832 let mut c = caixa(CaixaKind::Biblioteca);
2833 c.deps = vec![Dep::simple("", "^0.1")];
2834 let err = layout.verify(&c, &root).unwrap_err();
2835 let LayoutError::DepsViolation { caixa, issue } = err else {
2836 panic!("expected LayoutError::DepsViolation, got {err:?}");
2837 };
2838 assert_eq!(caixa, "demo");
2839 assert!(
2840 issue.contains(":deps") && issue.contains(":nome"),
2841 "issue must name the offending slot + axis: {issue}",
2842 );
2843 }
2844
2845 #[test]
2846 fn deps_violation_on_uppercase_dep_nome() {
2847 // Uppercase `:nome` on a `:deps` entry surfaces
2848 // `DepError::NomeInvalid` (DNS-1123 violation) through the wrap.
2849 use crate::Dep;
2850 let root = PathBuf::from("/tmp/x");
2851 let manifest = root.join("caixa.lisp");
2852 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2853 let mut c = caixa(CaixaKind::Biblioteca);
2854 c.deps = vec![Dep::simple("Caixa-Teia", "^0.1")];
2855 let err = layout.verify(&c, &root).unwrap_err();
2856 let LayoutError::DepsViolation { caixa, issue } = err else {
2857 panic!("expected LayoutError::DepsViolation, got {err:?}");
2858 };
2859 assert_eq!(caixa, "demo");
2860 assert!(
2861 issue.contains("Caixa-Teia"),
2862 "issue must quote the offending dep nome verbatim: {issue}",
2863 );
2864 }
2865
2866 #[test]
2867 fn deps_violation_on_unparseable_dep_versao() {
2868 // Unparseable `:versao` requirement on a `:deps` entry surfaces
2869 // `DepError::VersaoInvalid` through the wrap — the canonical
2870 // "the semver::Error reached the resolver, far from the source"
2871 // footgun closed at author time.
2872 use crate::Dep;
2873 let root = PathBuf::from("/tmp/x");
2874 let manifest = root.join("caixa.lisp");
2875 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2876 let mut c = caixa(CaixaKind::Biblioteca);
2877 c.deps = vec![Dep::simple("caixa-teia", "not-a-req")];
2878 let err = layout.verify(&c, &root).unwrap_err();
2879 let LayoutError::DepsViolation { caixa, issue } = err else {
2880 panic!("expected LayoutError::DepsViolation, got {err:?}");
2881 };
2882 assert_eq!(caixa, "demo");
2883 assert!(
2884 issue.contains("caixa-teia") && issue.contains("not-a-req"),
2885 "issue must quote the dep nome + offending versao: {issue}",
2886 );
2887 }
2888
2889 #[test]
2890 fn deps_violation_on_duplicate_nome_in_deps() {
2891 // Within-list `:deps :nome` duplicate surfaces
2892 // `DepError::DuplicateNome { list: ":deps" }` through the wrap.
2893 use crate::Dep;
2894 let root = PathBuf::from("/tmp/x");
2895 let manifest = root.join("caixa.lisp");
2896 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2897 let mut c = caixa(CaixaKind::Biblioteca);
2898 c.deps = vec![
2899 Dep::simple("caixa-teia", "^0.1"),
2900 Dep::simple("caixa-teia", "^0.2"),
2901 ];
2902 let err = layout.verify(&c, &root).unwrap_err();
2903 let LayoutError::DepsViolation { caixa, issue } = err else {
2904 panic!("expected LayoutError::DepsViolation, got {err:?}");
2905 };
2906 assert_eq!(caixa, "demo");
2907 assert!(
2908 issue.contains("caixa-teia") && issue.contains(":deps"),
2909 "issue must quote the duplicated nome + list: {issue}",
2910 );
2911 }
2912
2913 #[test]
2914 fn deps_violation_on_duplicate_nome_in_deps_dev() {
2915 // Within-list `:deps-dev :nome` duplicate surfaces the same
2916 // diagnostic on the dev-only axis — neither list is a
2917 // second-class citizen of the typed surface.
2918 use crate::Dep;
2919 let root = PathBuf::from("/tmp/x");
2920 let manifest = root.join("caixa.lisp");
2921 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2922 let mut c = caixa(CaixaKind::Biblioteca);
2923 c.deps_dev = vec![
2924 Dep::simple("caixa-teia", "^0.1"),
2925 Dep::simple("caixa-teia", "^0.2"),
2926 ];
2927 let err = layout.verify(&c, &root).unwrap_err();
2928 let LayoutError::DepsViolation { caixa, issue } = err else {
2929 panic!("expected LayoutError::DepsViolation, got {err:?}");
2930 };
2931 assert_eq!(caixa, "demo");
2932 assert!(
2933 issue.contains(":deps-dev"),
2934 "issue must name the offending list: {issue}",
2935 );
2936 }
2937
2938 #[test]
2939 fn deps_violation_in_deps_fires_before_deps_dev() {
2940 // Precedence pin: when *both* `:deps` and `:deps-dev` carry a
2941 // malformed entry, the `:deps` walk fires first — the canonical
2942 // declaration-order precedence `Caixa::validate_deps` establishes
2943 // (the same author-grep ordering the typed-graph peers use on
2944 // every other Vec-shaped surface).
2945 use crate::Dep;
2946 let root = PathBuf::from("/tmp/x");
2947 let manifest = root.join("caixa.lisp");
2948 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2949 let mut c = caixa(CaixaKind::Biblioteca);
2950 c.deps = vec![Dep::simple("Bad-In-Deps", "^0.1")];
2951 c.deps_dev = vec![Dep::simple("Bad-In-Deps-Dev", "^0.1")];
2952 let err = layout.verify(&c, &root).unwrap_err();
2953 let LayoutError::DepsViolation { caixa: _, issue } = err else {
2954 panic!("expected LayoutError::DepsViolation, got {err:?}");
2955 };
2956 assert!(
2957 issue.contains("Bad-In-Deps") && !issue.contains("Bad-In-Deps-Dev"),
2958 "issue must name the :deps offender, not :deps-dev: {issue}",
2959 );
2960 }
2961
2962 #[test]
2963 fn deps_violation_fires_after_versao_violation() {
2964 // Precedence pin: when both the top-level `:versao` and a `:deps`
2965 // entry are malformed, the Caixa-identity gate fires first — the
2966 // canonical declaration order on `Caixa` (`:nome` → `:versao` →
2967 // ... → `:deps`) and the same identity-axis-dominates-content-
2968 // axis discipline the peer `validate_nome` / `validate_versao`
2969 // wire-up established (1f74a5f). A malformed `:versao` would
2970 // otherwise quote `caixa.nome` against a downstream-shaped
2971 // diagnostic.
2972 use crate::Dep;
2973 let root = PathBuf::from("/tmp/x");
2974 let manifest = root.join("caixa.lisp");
2975 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
2976 let mut c = caixa(CaixaKind::Biblioteca);
2977 c.versao = "v0.1.0".into();
2978 c.deps = vec![Dep::simple("Bad-Dep", "^0.1")];
2979 let err = layout.verify(&c, &root).unwrap_err();
2980 assert!(
2981 matches!(err, LayoutError::VersaoViolation { .. }),
2982 "got {err:?} — versao must fire before DepsViolation",
2983 );
2984 }
2985
2986 #[test]
2987 fn deps_violation_fires_before_kind_coherence() {
2988 // Precedence pin: a Supervisor with a malformed `:deps` entry
2989 // AND declared `:bibliotecas` (the canonical SupervisorOwnsCode
2990 // shape) surfaces DepsViolation, not SupervisorOwnsCode — the
2991 // dep surface is universal across all kinds and its shape gate
2992 // is more fundamental than the kind-coherence partitions on
2993 // `:bibliotecas` / `:exe` / `:servicos`. The author can fix the
2994 // dep typo without first being told to move their `:bibliotecas`
2995 // off a Supervisor.
2996 use crate::Dep;
2997 let root = PathBuf::from("/tmp/x");
2998 let manifest = root.join("caixa.lisp");
2999 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
3000 let mut c = caixa(CaixaKind::Supervisor);
3001 c.deps = vec![Dep::simple("Bad-Dep", "^0.1")];
3002 c.bibliotecas = vec!["lib/x.lisp".into()];
3003 let err = layout.verify(&c, &root).unwrap_err();
3004 assert!(
3005 matches!(err, LayoutError::DepsViolation { .. }),
3006 "got {err:?} — DepsViolation must fire before SupervisorOwnsCode",
3007 );
3008 }
3009
3010 #[test]
3011 fn deps_violation_fires_after_missing_manifest() {
3012 // Precedence pin: `MissingManifest` still dominates — there's no
3013 // caixa to deps-check when the manifest is missing.
3014 use crate::Dep;
3015 let root = PathBuf::from("/tmp/x");
3016 let layout = StandardLayout::new().with_path_exists(|_| false);
3017 let mut c = caixa(CaixaKind::Biblioteca);
3018 c.deps = vec![Dep::simple("Bad-Dep", "^0.1")];
3019 let err = layout.verify(&c, &root).unwrap_err();
3020 assert!(
3021 matches!(err, LayoutError::MissingManifest(_)),
3022 "got {err:?} — MissingManifest must dominate the deps gate",
3023 );
3024 }
3025
3026 #[test]
3027 fn deps_violation_on_self_dep_in_deps() {
3028 // Cross-slot self-edge: a caixa whose `:deps` lists its own
3029 // `:nome` is rejected at the layout wire-up, the diagnostic
3030 // surfaces through the `DepsViolation` envelope with both the
3031 // offending list tag (`":deps"`) and the parent's `:nome`
3032 // verbatim. Until this wire-up landed the self-dep silently
3033 // passed `feira build` and the resolver's lacre-pipeline
3034 // closure walk either rejected mid-traversal (infinite
3035 // recursion detected far from the source caixa.lisp) or, on
3036 // the unbounded path, recursed until it exhausted its stack.
3037 // Mirrors the supervision-tree
3038 // [`supervisor_violation_on_self_supervision`] and the
3039 // Aplicacao-membership self-edge wire-up tests on the peer
3040 // typed-name-graph axes.
3041 use crate::Dep;
3042 let root = PathBuf::from("/tmp/x");
3043 let manifest = root.join("caixa.lisp");
3044 let default_lib = root.join("lib").join("demo.lisp");
3045 let layout =
3046 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
3047 let mut c = caixa(CaixaKind::Biblioteca);
3048 c.deps = vec![Dep::simple("demo", "^0.1")];
3049 let err = layout.verify(&c, &root).unwrap_err();
3050 let LayoutError::DepsViolation { caixa, issue } = err else {
3051 panic!("expected LayoutError::DepsViolation, got {err:?}");
3052 };
3053 assert_eq!(caixa, "demo");
3054 assert!(
3055 issue.contains(":deps") && issue.contains("demo"),
3056 "issue must name the offending list + parent :nome: {issue}",
3057 );
3058 }
3059
3060 #[test]
3061 fn deps_violation_on_self_dep_in_deps_dev() {
3062 // Same cross-slot self-edge gate on the `:deps-dev` axis —
3063 // neither dep list is a second-class citizen of the typed
3064 // surface. The diagnostic names `:deps-dev` so the author can
3065 // grep their caixa.lisp for the offending block directly.
3066 use crate::Dep;
3067 let root = PathBuf::from("/tmp/x");
3068 let manifest = root.join("caixa.lisp");
3069 let default_lib = root.join("lib").join("demo.lisp");
3070 let layout =
3071 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
3072 let mut c = caixa(CaixaKind::Biblioteca);
3073 c.deps_dev = vec![Dep::simple("demo", "^0.1")];
3074 let err = layout.verify(&c, &root).unwrap_err();
3075 let LayoutError::DepsViolation { caixa, issue } = err else {
3076 panic!("expected LayoutError::DepsViolation, got {err:?}");
3077 };
3078 assert_eq!(caixa, "demo");
3079 assert!(
3080 issue.contains(":deps-dev"),
3081 "issue must name the offending list: {issue}",
3082 );
3083 }
3084
3085 #[test]
3086 fn self_dep_fires_after_per_entry_dep_shape() {
3087 // Precedence pin: the per-entry shape gates of
3088 // [`Caixa::validate_deps`] (DNS-1123 / SemVer / fonte / etc.)
3089 // fire first on a self-dep entry whose `:nome` is malformed.
3090 // Same ordering posture every peer cross-slot gate uses
3091 // (`validate_no_self_supervision` after `SupervisorSpec::validate`,
3092 // `validate_no_self_membership` after `AplicacaoSpec::validate`).
3093 // A malformed self-dep `:nome` surfaces the narrower
3094 // per-entry diagnostic (which already names the parser-side
3095 // reason) before the self-edge gate sees the entry.
3096 use crate::Dep;
3097 let root = PathBuf::from("/tmp/x");
3098 let manifest = root.join("caixa.lisp");
3099 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
3100 let mut c = caixa(CaixaKind::Biblioteca);
3101 // The parent is "demo" (DNS-1123 valid); the dep is "DEMO"
3102 // (DNS-1123 invalid). The per-entry shape gate fires on the
3103 // upper-case nome, masking the self-edge gate (and that's the
3104 // canonical precedence — fix the dep shape first, then the
3105 // structural self-edge becomes the next live diagnostic).
3106 c.deps = vec![Dep::simple("DEMO", "^0.1")];
3107 let err = layout.verify(&c, &root).unwrap_err();
3108 let LayoutError::DepsViolation { caixa: _, issue } = err else {
3109 panic!("expected LayoutError::DepsViolation, got {err:?}");
3110 };
3111 assert!(
3112 issue.contains("DNS-1123"),
3113 "issue must be the per-entry shape diagnostic, not the self-edge gate: {issue}",
3114 );
3115 }
3116
3117 #[test]
3118 fn valid_deps_pass_to_downstream_gates() {
3119 // Positive control pin: the canonical authoring shape (one
3120 // `:deps` entry naming a DNS-1123 nome + Cargo-shaped requirement,
3121 // one `:deps-dev` entry on a distinct nome) passes the dep gate;
3122 // downstream gates (MissingLib here) take over. Drift here =
3123 // a future tighten that rejects any canonical shape surfaces as
3124 // a regression at this layout-level pin.
3125 use crate::Dep;
3126 let root = PathBuf::from("/tmp/x");
3127 let manifest = root.join("caixa.lisp");
3128 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
3129 let mut c = caixa(CaixaKind::Biblioteca);
3130 c.deps = vec![Dep::simple("caixa-teia", "^0.1")];
3131 c.deps_dev = vec![Dep::simple("caixa-lint", "^0.2")];
3132 let err = layout.verify(&c, &root).unwrap_err();
3133 assert!(
3134 matches!(err, LayoutError::MissingLib { .. }),
3135 "got {err:?} — valid deps must pass to MissingLib",
3136 );
3137 }
3138
3139 #[test]
3140 fn code_path_gate_runs_after_foreign_code_slot_gate() {
3141 // Precedence pin: a Servico that declares `:exe` (foreign code
3142 // surface) surfaces ForeignCodeSlot, *not* a per-entry path
3143 // shape diagnostic, even when the `:exe` entry is itself
3144 // malformed. The kind-coherence gate is the load-bearing
3145 // diagnostic at this site — once the slot is moved off the
3146 // wrong kind, the per-entry shape gate becomes the next live
3147 // diagnostic.
3148 let root = PathBuf::from("/tmp/x");
3149 let manifest = root.join("caixa.lisp");
3150 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
3151 let mut c = caixa(CaixaKind::Servico);
3152 c.servicos = vec!["servicos/ok.yaml".into()];
3153 c.exe = vec!["/etc/foreign".into()];
3154 let err = layout.verify(&c, &root).unwrap_err();
3155 assert!(
3156 matches!(err, LayoutError::ForeignCodeSlot { .. }),
3157 "expected ForeignCodeSlot (kind-coherence wins over per-entry shape), got {err:?}",
3158 );
3159 }
3160
3161 // ── M2 typed-substrate invariants ────────────────────────────────────
3162
3163 #[test]
3164 fn behavior_callback_path_must_exist() {
3165 use crate::BehaviorSpec;
3166 use std::path::PathBuf;
3167 let root = PathBuf::from("/tmp/x");
3168 let manifest = root.join("caixa.lisp");
3169 let mut c = caixa(CaixaKind::Servico);
3170 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3171 let svc = root.join("servicos/demo.computeunit.yaml");
3172 c.behavior = Some(BehaviorSpec {
3173 on_init: Some(PathBuf::from("lib/init.lisp")),
3174 ..Default::default()
3175 });
3176 let manifest_clone = manifest.clone();
3177 let svc_clone = svc.clone();
3178 let layout =
3179 StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == svc_clone);
3180 let err = layout.verify(&c, &root).unwrap_err();
3181 assert!(matches!(
3182 err,
3183 LayoutError::MissingEntry { kind, .. }
3184 if kind == crate::render::LAYOUT_MISSING_ENTRY_KIND_BEHAVIOR_CALLBACK
3185 ));
3186
3187 // Now declare the path exists — passes.
3188 let init = root.join("lib/init.lisp");
3189 let layout =
3190 StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc || p == init);
3191 layout.verify(&c, &root).unwrap();
3192 }
3193
3194 #[test]
3195 fn behavior_absolute_callback_is_violation_not_missing() {
3196 // An absolute path silently subverts `root.join(p)` (Path::join
3197 // replaces the base when the right side is absolute). Before
3198 // BehaviorSpec::validate ran, an `:on-init "/etc/passwd"` would
3199 // surface as a confusing "missing behavior-callback /etc/passwd"
3200 // — or, worse, pass when /etc/passwd happens to exist. Now it's
3201 // a value-shape error naming the slot.
3202 use crate::BehaviorSpec;
3203 let root = PathBuf::from("/tmp/x");
3204 let manifest = root.join("caixa.lisp");
3205 let svc = root.join("servicos/demo.computeunit.yaml");
3206 let mut c = caixa(CaixaKind::Servico);
3207 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3208 c.behavior = Some(BehaviorSpec {
3209 on_init: Some(PathBuf::from("/etc/passwd")),
3210 ..Default::default()
3211 });
3212 // Path exists check would *succeed* on /etc/passwd (proving the
3213 // sandbox bypass) — value-shape pass must fire first.
3214 let layout = StandardLayout::new()
3215 .with_path_exists(move |p| p == manifest || p == svc || p == Path::new("/etc/passwd"));
3216 let err = layout.verify(&c, &root).unwrap_err();
3217 assert!(
3218 matches!(err, LayoutError::BehaviorViolation { ref caixa, .. } if caixa == "demo"),
3219 "got {err:?}",
3220 );
3221 }
3222
3223 #[test]
3224 fn behavior_empty_callback_is_violation() {
3225 use crate::BehaviorSpec;
3226 let root = PathBuf::from("/tmp/x");
3227 let manifest = root.join("caixa.lisp");
3228 let svc = root.join("servicos/demo.computeunit.yaml");
3229 let mut c = caixa(CaixaKind::Servico);
3230 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3231 c.behavior = Some(BehaviorSpec {
3232 on_call: Some(PathBuf::new()),
3233 ..Default::default()
3234 });
3235 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc);
3236 let err = layout.verify(&c, &root).unwrap_err();
3237 assert!(matches!(err, LayoutError::BehaviorViolation { .. }));
3238 }
3239
3240 #[test]
3241 fn upgrade_from_duplicate_surfaces_as_upgrade_violation() {
3242 // Wiring pin: the cross-entry duplicate-`:from` gate in
3243 // `validate_upgrade_from` lands on the same
3244 // `LayoutError::UpgradeViolation` axis the per-entry
3245 // `UpgradeFromEntry::validate` already does (26da2c7), so a
3246 // caixa.lisp with two `(:from "0.1.0" …)` blocks surfaces at
3247 // `feira build` time naming the offending caixa rather than
3248 // silently passing into the wasm-operator's non-deterministic
3249 // dispatch. Mirrors `behavior_empty_callback_is_violation` on
3250 // the peer M2 typed slot.
3251 use crate::{UpgradeFromEntry, UpgradeInstruction};
3252 let root = PathBuf::from("/tmp/x");
3253 let manifest = root.join("caixa.lisp");
3254 let svc = root.join("servicos/demo.computeunit.yaml");
3255 let mut c = caixa(CaixaKind::Servico);
3256 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3257 c.upgrade_from = vec![
3258 UpgradeFromEntry {
3259 from: "0.1.0".into(),
3260 instructions: vec![UpgradeInstruction::Restart],
3261 },
3262 UpgradeFromEntry {
3263 from: "0.1.0".into(),
3264 instructions: vec![UpgradeInstruction::Restart],
3265 },
3266 ];
3267 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc);
3268 let err = layout.verify(&c, &root).unwrap_err();
3269 let LayoutError::UpgradeViolation { caixa, issue } = err else {
3270 panic!("expected LayoutError::UpgradeViolation for duplicate `:from`, got {err:?}");
3271 };
3272 assert_eq!(caixa, "demo");
3273 assert!(
3274 issue.contains("0.1.0"),
3275 "UpgradeViolation issue must name the offending `:from` verbatim, got {issue:?}"
3276 );
3277 }
3278
3279 #[test]
3280 fn upgrade_from_downgrade_surfaces_as_upgrade_violation() {
3281 // Wiring pin: the cross-slot precedence gate in
3282 // `validate_upgrade_from_against_versao` lands on the same
3283 // `LayoutError::UpgradeViolation` axis the per-entry and
3284 // cross-entry gates already do (26da2c7, 7c6aef2), so a
3285 // caixa.lisp whose `:upgrade-from :from` is greater than the
3286 // caixa's own `:versao` surfaces at `feira build` time
3287 // naming the offending caixa rather than silently passing
3288 // into the wasm-operator's `:from`-match dispatch where the
3289 // entry would sit dormant forever. Mirrors
3290 // `upgrade_from_duplicate_surfaces_as_upgrade_violation` on
3291 // the peer cross-entry gate.
3292 use crate::{UpgradeFromEntry, UpgradeInstruction};
3293 let root = PathBuf::from("/tmp/x");
3294 let manifest = root.join("caixa.lisp");
3295 let svc = root.join("servicos/demo.computeunit.yaml");
3296 let mut c = caixa(CaixaKind::Servico);
3297 c.versao = "0.1.5".into();
3298 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3299 c.upgrade_from = vec![UpgradeFromEntry {
3300 from: "0.2.0".into(),
3301 instructions: vec![UpgradeInstruction::Restart],
3302 }];
3303 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc);
3304 let err = layout.verify(&c, &root).unwrap_err();
3305 let LayoutError::UpgradeViolation { caixa, issue } = err else {
3306 panic!(
3307 "expected LayoutError::UpgradeViolation for downgrade-shaped `:from`, got {err:?}"
3308 );
3309 };
3310 assert_eq!(caixa, "demo");
3311 assert!(
3312 issue.contains("0.2.0") && issue.contains("0.1.5"),
3313 "UpgradeViolation issue must name both `:from` and `:versao` verbatim, got {issue:?}"
3314 );
3315 }
3316
3317 #[test]
3318 fn upgrade_from_equal_to_versao_surfaces_as_upgrade_violation() {
3319 // Self-upgrade no-op arm: `:from "0.1.0"` while
3320 // `:versao "0.1.0"` declares "upgrade from myself to
3321 // myself", which the operator's dispatch either skips
3322 // silently or trivially "succeeds" with no observable
3323 // transition. Surfaces at validate time naming both values
3324 // so the author can fix in one edit.
3325 use crate::{UpgradeFromEntry, UpgradeInstruction};
3326 let root = PathBuf::from("/tmp/x");
3327 let manifest = root.join("caixa.lisp");
3328 let svc = root.join("servicos/demo.computeunit.yaml");
3329 let mut c = caixa(CaixaKind::Servico);
3330 c.versao = "0.1.0".into();
3331 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3332 c.upgrade_from = vec![UpgradeFromEntry {
3333 from: "0.1.0".into(),
3334 instructions: vec![UpgradeInstruction::Restart],
3335 }];
3336 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc);
3337 let err = layout.verify(&c, &root).unwrap_err();
3338 let LayoutError::UpgradeViolation { caixa, issue } = err else {
3339 panic!(
3340 "expected LayoutError::UpgradeViolation for self-upgrade `:from == :versao`, got \
3341 {err:?}"
3342 );
3343 };
3344 assert_eq!(caixa, "demo");
3345 assert!(
3346 issue.contains("0.1.0"),
3347 "UpgradeViolation issue must name the equal `:from`/`:versao` verbatim, got {issue:?}"
3348 );
3349 }
3350
3351 #[test]
3352 fn upgrade_from_strict_upgrade_passes_layout() {
3353 // Positive control for the precedence gate at the
3354 // LayoutInvariants level: a valid `:from < :versao` chain
3355 // (`0.1.0 → 0.2.0`) must not regress into a false-positive
3356 // `UpgradeViolation`. Mirrors `behavior_callback_path_must_exist`'s
3357 // positive-control arm.
3358 use crate::{UpgradeFromEntry, UpgradeInstruction};
3359 let root = PathBuf::from("/tmp/x");
3360 let manifest = root.join("caixa.lisp");
3361 let svc = root.join("servicos/demo.computeunit.yaml");
3362 let mut c = caixa(CaixaKind::Servico);
3363 c.versao = "0.2.0".into();
3364 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3365 c.upgrade_from = vec![UpgradeFromEntry {
3366 from: "0.1.0".into(),
3367 instructions: vec![UpgradeInstruction::Restart],
3368 }];
3369 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc);
3370 layout.verify(&c, &root).unwrap();
3371 }
3372
3373 #[test]
3374 fn upgrade_script_path_must_exist() {
3375 use crate::{BehaviorSpec, UpgradeFromEntry, UpgradeInstruction};
3376 use std::path::PathBuf;
3377 let root = PathBuf::from("/tmp/x");
3378 let manifest = root.join("caixa.lisp");
3379 let svc = root.join("servicos/demo.computeunit.yaml");
3380 let on_state_change = root.join("lib/migrations.lisp");
3381 let mut c = caixa(CaixaKind::Servico);
3382 // `:versao` past the entry's `:from` so the cross-slot
3383 // precedence gate (`FromNotBeforeVersao`) lets this case
3384 // through to the path-existence pass under test.
3385 c.versao = "0.2.0".into();
3386 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3387 // `:on-state-change` declared so the cross-slot composition
3388 // gate (`validate_upgrade_from_against_behavior`) lets the
3389 // `:state-change` entry through to the path-existence pass
3390 // under test. Without the callback the missing-callback gate
3391 // would surface first and the path-existence pass wouldn't be
3392 // exercised.
3393 c.behavior = Some(BehaviorSpec {
3394 on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
3395 ..Default::default()
3396 });
3397 // A `:load-module` precedes the `:state-change` so the entry
3398 // satisfies the within-entry state-change-ordering gate
3399 // (`StateChangeWithoutPriorLoad`) and the path-existence pass
3400 // under test is the gate actually exercised. `:load-module`
3401 // carries no on-disk path, so it adds no existence requirement.
3402 c.upgrade_from = vec![UpgradeFromEntry {
3403 from: "0.1.0".into(),
3404 instructions: vec![
3405 UpgradeInstruction::LoadModule {
3406 module: "demo".into(),
3407 },
3408 UpgradeInstruction::StateChange {
3409 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3410 },
3411 ],
3412 }];
3413 let manifest_clone = manifest.clone();
3414 let svc_clone = svc.clone();
3415 let on_state_change_clone = on_state_change.clone();
3416 let layout = StandardLayout::new().with_path_exists(move |p| {
3417 p == manifest_clone || p == svc_clone || p == on_state_change_clone
3418 });
3419 let err = layout.verify(&c, &root).unwrap_err();
3420 assert!(matches!(
3421 err,
3422 LayoutError::MissingEntry { kind, .. }
3423 if kind == crate::render::LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT
3424 ));
3425 }
3426
3427 #[test]
3428 fn layout_missing_entry_kind_m2_consts_pin_canonical_kebab_case_labels() {
3429 // Byte-identity pin: the two per-M2-slot leaf-kind labels the
3430 // [`LayoutError::MissingEntry`] `kind: &'static str`
3431 // discriminator surfaces under (the M2 `:behavior` per-callback
3432 // on-disk-leaf axis, the M2 `:upgrade-from :instructions`
3433 // per-`:state-change` script-path on-disk-leaf axis) route
3434 // through the lifted [`crate::render::LAYOUT_MISSING_ENTRY_KIND_BEHAVIOR_CALLBACK`]
3435 // / [`crate::render::LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT`]
3436 // consts, so a future rebrand that reaches the const but not
3437 // the production emit / test probe (or vice versa) surfaces
3438 // here at build time rather than at runtime as a downstream
3439 // [`LayoutError::MissingEntry`] `kind: <stale-label>`
3440 // diagnostic mismatch far from the rename's commit. Mirror of
3441 // the peer
3442 // [`crate::aplicacao::tests::contrato_author_key_consts_pin_canonical_kebab_case_labels`]
3443 // (f50c875) and
3444 // [`crate::upgrade::tests::upgrade_instruction_kind_consts_pin_canonical_kebab_case_tags`]
3445 // (56120ef) byte-identity pins on the sibling M3 `:contratos`
3446 // per-entry endpoint-label + M2 `:upgrade-from :instructions`
3447 // per-variant kind-tag axes.
3448 assert_eq!(
3449 crate::render::LAYOUT_MISSING_ENTRY_KIND_BEHAVIOR_CALLBACK,
3450 "behavior-callback"
3451 );
3452 assert_eq!(
3453 crate::render::LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT,
3454 "upgrade-script"
3455 );
3456 }
3457
3458 #[test]
3459 fn layout_missing_entry_kind_m0_consts_pin_canonical_code_slot_labels() {
3460 // Byte-identity pin: the three per-M0-code-slot leaf-kind
3461 // labels the [`LayoutError::MissingEntry`] `kind: &'static
3462 // str` discriminator surfaces under (the `:bibliotecas`
3463 // per-entry axis, the `:exe` per-entry axis, the `:servicos`
3464 // per-entry axis) route through the lifted
3465 // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA`] /
3466 // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE`] /
3467 // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO`] consts,
3468 // so a future rebrand that reaches the const but not the
3469 // production emit (or vice versa) surfaces here at build time
3470 // rather than at runtime as a downstream
3471 // [`LayoutError::MissingEntry`] `kind: <stale-label>`
3472 // diagnostic mismatch far from the rename's commit. Mirror of
3473 // the peer M2-tier pin
3474 // [`layout_missing_entry_kind_m2_consts_pin_canonical_kebab_case_labels`]
3475 // (95c9c4c) on the sibling `:behavior` / `:upgrade-from`
3476 // per-slot leaf-kind axes.
3477 assert_eq!(
3478 crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA,
3479 "biblioteca"
3480 );
3481 assert_eq!(crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE, "exe");
3482 assert_eq!(crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO, "servico");
3483 }
3484
3485 #[test]
3486 fn layout_missing_entry_kind_m0_consts_align_with_caixa_kind_as_str() {
3487 // Cross-axis byte-identity pin: the two `:kind`-namesake M0
3488 // leaf-kind labels ([`crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA`]
3489 // = `"biblioteca"`,
3490 // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO`] =
3491 // `"servico"`) must equal [`crate::CaixaKind::Biblioteca`] /
3492 // [`crate::CaixaKind::Servico`]'s
3493 // [`crate::CaixaKind::as_str`] outputs verbatim — the
3494 // substrate's canonical human-readable-kind axis and the
3495 // layout diagnostic's per-slot leaf-kind axis share one
3496 // vocabulary for these two arms by design (both label the
3497 // caixa's code-producing shape by its Portuguese-native
3498 // idiom), so drift between the two lands as a build-time
3499 // pattern-arm miss here rather than as a runtime diagnostic
3500 // that reads inconsistently across `feira build`'s
3501 // per-invocation output.
3502 //
3503 // The third M0 arm ([`crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE`]
3504 // = `"exe"`) is deliberately *distinct* from
3505 // [`crate::CaixaKind::Binario`]'s [`crate::CaixaKind::as_str`]
3506 // output (`"binario"`) — the `:exe` code slot names the
3507 // per-directory leaf-kind at the `exe/` subtree, whereas
3508 // [`crate::CaixaKind::Binario`] names the caixa's own runtime
3509 // kind. Two axes, two labels — the inequality assertion here
3510 // pins the split so a future accidental collapse of the two
3511 // onto one scalar (a rebrand that reroutes either axis to
3512 // match the other) trips at build time.
3513 assert_eq!(
3514 crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA,
3515 CaixaKind::Biblioteca.as_str()
3516 );
3517 assert_eq!(
3518 crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO,
3519 CaixaKind::Servico.as_str()
3520 );
3521 assert_ne!(
3522 crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE,
3523 CaixaKind::Binario.as_str(),
3524 "`exe` leaf-kind label names the per-directory code-slot \
3525 axis; `binario` names the caixa-kind axis — the two must \
3526 not silently collapse onto one scalar"
3527 );
3528 }
3529
3530 #[test]
3531 fn layout_missing_entry_kind_consts_are_pairwise_distinct() {
3532 // Distinctness pin: the five [`LayoutError::MissingEntry`]
3533 // `kind: &'static str` accept-set members
3534 // ([`crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA`] /
3535 // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE`] /
3536 // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO`] on the
3537 // M0 code-slot arms plus
3538 // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_BEHAVIOR_CALLBACK`]
3539 // / [`crate::render::LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT`]
3540 // on the M2 slot arms) must be pairwise distinct — an
3541 // accidental copy-paste flip that reroutes one label's byte-
3542 // string to also match another silently collapses two
3543 // per-slot diagnostics onto one, so an operator running
3544 // `feira build` reads `kind: "biblioteca"` for what should
3545 // have surfaced as a `:behavior :on-init` script-not-found
3546 // diagnostic (or vice versa). This pin catches any such
3547 // flip at build time. Mirror of the peer
3548 // [`crate::render::tests::m2_limits_key_consts_are_pairwise_distinct`]
3549 // / peer distinctness pins on other closed-set typed axes.
3550 let entries: &[(&str, &str)] = &[
3551 (
3552 "LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA",
3553 crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA,
3554 ),
3555 (
3556 "LAYOUT_MISSING_ENTRY_KIND_EXE",
3557 crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE,
3558 ),
3559 (
3560 "LAYOUT_MISSING_ENTRY_KIND_SERVICO",
3561 crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO,
3562 ),
3563 (
3564 "LAYOUT_MISSING_ENTRY_KIND_BEHAVIOR_CALLBACK",
3565 crate::render::LAYOUT_MISSING_ENTRY_KIND_BEHAVIOR_CALLBACK,
3566 ),
3567 (
3568 "LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT",
3569 crate::render::LAYOUT_MISSING_ENTRY_KIND_UPGRADE_SCRIPT,
3570 ),
3571 ];
3572 for (i, (name_a, value_a)) in entries.iter().enumerate() {
3573 for (name_b, value_b) in entries.iter().skip(i + 1) {
3574 assert_ne!(
3575 value_a, value_b,
3576 "LAYOUT_MISSING_ENTRY_KIND_* consts must be \
3577 pairwise-distinct byte-strings — {name_a} and \
3578 {name_b} both resolve to {value_a:?}"
3579 );
3580 }
3581 }
3582 }
3583
3584 #[test]
3585 fn layout_dir_consts_pin_canonical_directory_names() {
3586 // Scalar-value pin for the three [`crate::render::LAYOUT_DIR_*`]
3587 // consts naming the CSE-invariant per-[`CaixaKind`]
3588 // on-disk-directory-name axes the substrate's layout invariants
3589 // pin (`lib/` for [`CaixaKind::Biblioteca`], `exe/` for
3590 // [`CaixaKind::Binario`], `servicos/` for [`CaixaKind::Servico`]).
3591 // A future rebrand of any of the three on-disk directory landing
3592 // conventions must reach this pin — the const-edit lands on one
3593 // arm, the assertion here re-pins the new byte-string, and every
3594 // downstream consumer (the caixa-feira `init` / `fmt` / `lint` /
3595 // `tofu` scaffolders, the [`crate::LayoutInvariants::verify`]
3596 // sandbox reconstruction, the future
3597 // `feira app deploy`-cluster scaffolder) picks up the new
3598 // directory name at build time. Mirror of the peer
3599 // [`layout_missing_entry_kind_m0_consts_pin_canonical_code_slot_labels`]
3600 // (fe2a898) on the sibling
3601 // [`crate::LayoutError::MissingEntry`] `kind:` discriminator
3602 // axis this on-disk-directory axis composes with.
3603 assert_eq!(crate::render::LAYOUT_DIR_LIB, "lib");
3604 assert_eq!(crate::render::LAYOUT_DIR_EXE, "exe");
3605 assert_eq!(crate::render::LAYOUT_DIR_SERVICOS, "servicos");
3606 }
3607
3608 #[test]
3609 fn layout_dir_consts_are_pairwise_distinct() {
3610 // Distinctness pin: the three per-[`CaixaKind`]
3611 // on-disk-directory-name arms must resolve to pairwise-distinct
3612 // byte-strings — a future accidental copy-paste flip that
3613 // reroutes any one of the three onto another's value silently
3614 // collapses two per-kind on-disk sandboxes onto one, so
3615 // [`crate::LayoutInvariants::verify`] would gate a
3616 // [`CaixaKind::Binario`] caixa's `:exe` entries against the
3617 // wrong sub-tree (or a `:kind Servico` caixa's `:servicos`
3618 // entries against `lib/` and pass every entry `feira build`
3619 // should have rejected as [`crate::LayoutError::ServicoOutsideDir`]).
3620 // Mirror of the peer
3621 // [`layout_missing_entry_kind_consts_are_pairwise_distinct`]
3622 // (fe2a898) on the sibling leaf-kind label accept-set.
3623 let entries: &[(&str, &str)] = &[
3624 ("LAYOUT_DIR_LIB", crate::render::LAYOUT_DIR_LIB),
3625 ("LAYOUT_DIR_EXE", crate::render::LAYOUT_DIR_EXE),
3626 ("LAYOUT_DIR_SERVICOS", crate::render::LAYOUT_DIR_SERVICOS),
3627 ];
3628 for (i, (name_a, value_a)) in entries.iter().enumerate() {
3629 for (name_b, value_b) in entries.iter().skip(i + 1) {
3630 assert_ne!(
3631 value_a, value_b,
3632 "LAYOUT_DIR_* consts must be pairwise-distinct \
3633 byte-strings — {name_a} and {name_b} both resolve \
3634 to {value_a:?}"
3635 );
3636 }
3637 }
3638 }
3639
3640 #[test]
3641 fn layout_dir_exe_matches_layout_missing_entry_kind_exe() {
3642 // Cross-axis byte-identity pin: [`crate::render::LAYOUT_DIR_EXE`]
3643 // (the on-disk-directory-name arm) equals
3644 // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE`] (the
3645 // [`LayoutError::MissingEntry`] `kind:` leaf-kind categorization
3646 // arm) verbatim — the M0 `:kind Binario` on-disk-directory axis
3647 // and the [`crate::LayoutError::MissingEntry`] `kind:` leaf-kind
3648 // discriminator name the same three-byte sub-tree (`exe/`), a
3649 // coincidence [`crate::LayoutInvariants::verify`] itself relies
3650 // on: it joins `root` with [`crate::render::LAYOUT_DIR_EXE`] to
3651 // reconstruct `exe_dir` and emits [`crate::LayoutError::MissingEntry
3652 // { kind: LAYOUT_MISSING_ENTRY_KIND_EXE, path: <under exe_dir> }`]
3653 // for every non-resolving entry. Making the coincidence
3654 // load-bearing means a future rebrand touching either axis
3655 // without the other (a per-consumer disambiguation collapsing
3656 // the leaf-kind label onto `"binary"` while the directory stays
3657 // `"exe"`, or vice versa) trips at caixa-core build time rather
3658 // than surfacing at runtime as a mismatched
3659 // [`crate::LayoutInvariants::verify`] diagnostic whose `kind:`
3660 // reads one label while the `path:` sits under a differently-named
3661 // sub-tree.
3662 assert_eq!(
3663 crate::render::LAYOUT_DIR_EXE,
3664 crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE,
3665 "LAYOUT_DIR_EXE must equal LAYOUT_MISSING_ENTRY_KIND_EXE — \
3666 both name the M0 `:kind Binario` sub-tree by the same \
3667 three-byte scalar"
3668 );
3669 }
3670
3671 #[test]
3672 fn layout_dir_bib_is_distinct_from_layout_missing_entry_kind_bib() {
3673 // Cross-axis distinctness pin: [`crate::render::LAYOUT_DIR_LIB`]
3674 // (`"lib"`, the Cargo-style abbreviated on-disk directory name)
3675 // is *deliberately* distinct from
3676 // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA`]
3677 // (`"biblioteca"`, the full-form Portuguese-native leaf-kind
3678 // label) — the substrate splits the on-disk convention terse
3679 // (`lib/`) from the diagnostic vocabulary full (`biblioteca`),
3680 // matching Cargo's `src/lib.rs` abbreviation of the `library`
3681 // crate-type discriminator. A future accidental collapse of the
3682 // two axes onto one scalar (a rebrand aligning either arm with
3683 // the other for schema-clarity, an English-uniformity pass that
3684 // renames `LAYOUT_DIR_LIB` to `LAYOUT_DIR_BIBLIOTECA` or the
3685 // diagnostic label to `"lib"`) would silently reroute either
3686 // consumer onto the other's byte-string. This pin catches the
3687 // collapse at build time. Peer of the sibling
3688 // [`layout_missing_entry_kind_m0_consts_align_with_caixa_kind_as_str`]
3689 // (fe2a898) that pins the analogous *equality* between the M0
3690 // `:kind Biblioteca` diagnostic-label arm and
3691 // [`crate::CaixaKind::Biblioteca`]'s [`crate::CaixaKind::as_str`]
3692 // output — the two pins jointly encode the "which of the three
3693 // Biblioteca-related scalars are load-bearing-equal, which are
3694 // load-bearing-distinct" invariant across the substrate's
3695 // per-kind vocabulary.
3696 assert_ne!(
3697 crate::render::LAYOUT_DIR_LIB,
3698 crate::render::LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA,
3699 "LAYOUT_DIR_LIB (`\"lib\"`) and LAYOUT_MISSING_ENTRY_KIND_BIBLIOTECA \
3700 (`\"biblioteca\"`) name two distinct axes — the on-disk \
3701 directory convention (Cargo-style abbreviated) and the \
3702 layout-diagnostic leaf-kind label (full-form Portuguese) — \
3703 and must not silently collapse onto one scalar"
3704 );
3705 }
3706
3707 #[test]
3708 fn layout_dir_servicos_is_distinct_from_layout_missing_entry_kind_servico() {
3709 // Cross-axis distinctness pin: [`crate::render::LAYOUT_DIR_SERVICOS`]
3710 // (`"servicos"`, the Portuguese-*plural* on-disk directory
3711 // name) is *deliberately* distinct from
3712 // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO`]
3713 // (`"servico"`, the singular leaf-kind label) — the on-disk
3714 // sub-tree houses one-or-more ComputeUnit YAML descriptors per
3715 // caixa (hence the plural), the diagnostic label names the
3716 // caixa's own kind (singular). A future accidental collapse
3717 // onto one scalar (a per-consumer disambiguation aligning the
3718 // two, a hypothetical English-uniformity pass renaming
3719 // `"servicos"` → `"services"` while retaining `"servico"` on
3720 // the diagnostic arm — or vice versa) would silently reroute
3721 // either consumer onto the other's byte-string. Peer of the
3722 // sibling [`layout_dir_bib_is_distinct_from_layout_missing_entry_kind_bib`]
3723 // pin on the M0 `:kind Biblioteca` split axis; two of the three
3724 // per-kind on-disk / leaf-kind splits carry a distinctness
3725 // pin here, the third ([`crate::render::LAYOUT_DIR_EXE`] vs
3726 // [`crate::render::LAYOUT_MISSING_ENTRY_KIND_EXE`]) carries an
3727 // equality pin under
3728 // [`layout_dir_exe_matches_layout_missing_entry_kind_exe`].
3729 assert_ne!(
3730 crate::render::LAYOUT_DIR_SERVICOS,
3731 crate::render::LAYOUT_MISSING_ENTRY_KIND_SERVICO,
3732 "LAYOUT_DIR_SERVICOS (`\"servicos\"`, plural on-disk sub-tree) \
3733 and LAYOUT_MISSING_ENTRY_KIND_SERVICO (`\"servico\"`, singular \
3734 leaf-kind label) name two distinct axes and must not silently \
3735 collapse onto one scalar"
3736 );
3737 }
3738
3739 #[test]
3740 fn layout_invariants_reconstruct_sandbox_roots_through_lifted_layout_dir_consts() {
3741 // Production-through-const pin: [`LayoutInvariants::verify`]
3742 // routes its three per-kind sandbox-root joins
3743 // (`root.join(LAYOUT_DIR_LIB)` for the `:kind Biblioteca`
3744 // default `lib/<nome>.lisp` reconstruction, `root.join(LAYOUT_DIR_EXE)`
3745 // for the [`LayoutError::ExeOutsideDir`] gate,
3746 // `root.join(LAYOUT_DIR_SERVICOS)` for the
3747 // [`LayoutError::ServicoOutsideDir`] gate) through the three
3748 // lifted consts, not through inline `"lib"` / `"exe"` /
3749 // `"servicos"` `&str` literals. This test drives the
3750 // [`LayoutError::ExeOutsideDir`] arm through a `:kind Binario`
3751 // caixa whose declared `:exe` entry deliberately escapes
3752 // `root.join(LAYOUT_DIR_EXE)` (a sibling `bin/tool` path) —
3753 // if the production emit reads the wrong const (or reverts to
3754 // an inline literal that drifts from the const) the diagnostic
3755 // arm surfaces the wrong variant, catching the drift at build
3756 // time rather than as a per-invocation runtime mismatch.
3757 //
3758 // Mirror of the peer production-through-const pin
3759 // [`crate::dep::tests::validate_no_self_dep_deps_field_routes_through_dep_author_key`]
3760 // (4da6fba) on the sibling M0 `:deps` `list:` diagnostic axis.
3761 use std::path::PathBuf;
3762 let root = PathBuf::from("/tmp/x");
3763 let manifest = root.join("caixa.lisp");
3764 let bin_entry_outside = root.join("bin/tool");
3765 let mut c = caixa(CaixaKind::Binario);
3766 c.exe = vec!["bin/tool".into()];
3767 let manifest_clone = manifest.clone();
3768 let outside_clone = bin_entry_outside.clone();
3769 let layout = StandardLayout::new()
3770 .with_path_exists(move |p| p == manifest_clone || p == outside_clone);
3771 let err = layout.verify(&c, &root).unwrap_err();
3772 match err {
3773 LayoutError::ExeOutsideDir(path) => {
3774 assert_eq!(
3775 path, bin_entry_outside,
3776 "ExeOutsideDir must carry the resolved `:exe` entry that \
3777 escapes `root.join(LAYOUT_DIR_EXE)`"
3778 );
3779 // Byte-identity check: the escape must be against the
3780 // lifted `LAYOUT_DIR_EXE` sub-tree, not a stale inline
3781 // literal — a future const-edit that drifts from `"exe"`
3782 // reroutes `exe_dir` off the sandbox `bin/tool` escapes
3783 // from, and this pattern-arm miss re-surfaces here.
3784 assert!(
3785 !path.starts_with(root.join(crate::render::LAYOUT_DIR_EXE)),
3786 "resolved `:exe` entry {path:?} must escape the \
3787 `root.join(LAYOUT_DIR_EXE)` sub-tree the production \
3788 emit uses to gate the [`LayoutError::ExeOutsideDir`] arm"
3789 );
3790 }
3791 other => panic!("expected ExeOutsideDir, got {other:?}"),
3792 }
3793 }
3794
3795 #[test]
3796 fn upgrade_state_change_without_behavior_callback_surfaces_as_upgrade_violation() {
3797 // Wiring pin for the cross-slot composition gate
3798 // (`validate_upgrade_from_against_behavior`): a caixa whose
3799 // `:upgrade-from` declares a `(:state-change "lib/m.lisp")`
3800 // instruction but does not declare `:behavior :on-state-change`
3801 // surfaces at `feira build` time as a `LayoutError::UpgradeViolation`
3802 // naming the offending caixa + the entry's `:from` + the
3803 // offending script — not at hot-upgrade dispatch when the
3804 // operator reaches for the missing callback. Mirrors
3805 // `upgrade_from_downgrade_surfaces_as_upgrade_violation` on the
3806 // peer `:from` ↔ `:versao` cross-slot precedence gate.
3807 use crate::{UpgradeFromEntry, UpgradeInstruction};
3808 use std::path::PathBuf;
3809 let root = PathBuf::from("/tmp/x");
3810 let manifest = root.join("caixa.lisp");
3811 let svc = root.join("servicos/demo.computeunit.yaml");
3812 let mut c = caixa(CaixaKind::Servico);
3813 c.versao = "0.2.0".into();
3814 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
3815 // `:behavior` is None (the canonical "I added the upgrade path
3816 // but never declared :behavior" footgun the gate closes); a
3817 // peer arm covers the BehaviorSpec-Some-but-on-state-change-
3818 // None shape in `upgrade::tests::behavior_gate_rejects_state_
3819 // change_when_on_state_change_is_none`.
3820 c.upgrade_from = vec![UpgradeFromEntry {
3821 from: "0.1.0".into(),
3822 instructions: vec![
3823 UpgradeInstruction::LoadModule {
3824 module: "demo".into(),
3825 },
3826 UpgradeInstruction::StateChange {
3827 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
3828 },
3829 ],
3830 }];
3831 let manifest_clone = manifest.clone();
3832 let svc_clone = svc.clone();
3833 let layout =
3834 StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == svc_clone);
3835 let err = layout.verify(&c, &root).unwrap_err();
3836 match err {
3837 LayoutError::UpgradeViolation { caixa, issue } => {
3838 assert_eq!(caixa, "demo", "diagnostic must name the offending caixa");
3839 assert!(
3840 issue.contains(crate::render::M2_BEHAVIOR_AUTHOR_KEY_ON_STATE_CHANGE),
3841 "diagnostic must name the missing callback slot for self-locating fix, \
3842 got {issue:?}"
3843 );
3844 assert!(
3845 issue.contains("0.1.0"),
3846 "diagnostic must name the offending entry's :from, got {issue:?}"
3847 );
3848 assert!(
3849 issue.contains("v01-to-v02.lisp"),
3850 "diagnostic must name the offending :script for self-locating fix, \
3851 got {issue:?}"
3852 );
3853 }
3854 other => panic!("expected UpgradeViolation, got {other:?}"),
3855 }
3856 }
3857
3858 #[test]
3859 fn supervisor_must_have_children() {
3860 use crate::RestartStrategy;
3861 let root = PathBuf::from("/tmp/x");
3862 let manifest = root.join("caixa.lisp");
3863 let manifest_clone = manifest.clone();
3864 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
3865 let mut c = caixa(CaixaKind::Supervisor);
3866 c.estrategia = Some(RestartStrategy::OneForOne);
3867 c.max_restarts = Some(5);
3868 // No children → should fail
3869 let err = layout.verify(&c, &root).unwrap_err();
3870 assert!(matches!(err, LayoutError::SupervisorViolation { .. }));
3871 }
3872
3873 #[test]
3874 fn supervisor_self_referential_child_is_violation() {
3875 // A Supervisor whose `:children` names its own `:nome` is a
3876 // one-node supervision cycle. The cross-slot gate fires at
3877 // verify time, surfacing as a SupervisorViolation that names the
3878 // offending supervisor — not at the cluster apply far from
3879 // source. The `caixa()` helper's `:nome` is "demo".
3880 use crate::{ChildSpec, RestartPolicy, RestartStrategy};
3881 let root = PathBuf::from("/tmp/x");
3882 let manifest = root.join("caixa.lisp");
3883 let manifest_clone = manifest.clone();
3884 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
3885 let mut c = caixa(CaixaKind::Supervisor);
3886 c.estrategia = Some(RestartStrategy::OneForOne);
3887 c.max_restarts = Some(5);
3888 c.children = vec![
3889 ChildSpec {
3890 caixa: "worker".into(),
3891 versao: "^0.1".into(),
3892 restart: RestartPolicy::Permanent,
3893 },
3894 ChildSpec {
3895 caixa: "demo".into(),
3896 versao: "^0.1".into(),
3897 restart: RestartPolicy::Permanent,
3898 },
3899 ];
3900 let err = layout.verify(&c, &root).unwrap_err();
3901 let LayoutError::SupervisorViolation { caixa, issue } = err else {
3902 panic!("expected SupervisorViolation for self-referential child, got {err:?}");
3903 };
3904 assert_eq!(caixa, "demo");
3905 assert!(
3906 issue.contains("demo") && issue.contains("itself"),
3907 "issue must name the self-supervising caixa, got {issue:?}"
3908 );
3909 }
3910
3911 #[test]
3912 fn supervisor_distinct_children_pass_self_supervision_gate() {
3913 // Positive control: a Supervisor whose children are all distinct
3914 // from its own `:nome` verifies cleanly.
3915 use crate::{ChildSpec, RestartPolicy, RestartStrategy};
3916 let root = PathBuf::from("/tmp/x");
3917 let manifest = root.join("caixa.lisp");
3918 let manifest_clone = manifest.clone();
3919 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
3920 let mut c = caixa(CaixaKind::Supervisor);
3921 c.estrategia = Some(RestartStrategy::OneForOne);
3922 c.max_restarts = Some(5);
3923 c.children = vec![ChildSpec {
3924 caixa: "worker".into(),
3925 versao: "^0.1".into(),
3926 restart: RestartPolicy::Permanent,
3927 }];
3928 layout.verify(&c, &root).unwrap();
3929 }
3930
3931 #[test]
3932 fn cross_slot_self_edge_gates_route_parent_nome_through_lifted_accessor() {
3933 // Composition pin: every cross-slot self-edge gate fired from
3934 // `LayoutInvariants::verify` — the supervision-tree arm's
3935 // `crate::supervisor::validate_no_self_supervision` call, the
3936 // Aplicacao arm's `crate::aplicacao::validate_no_self_membership`
3937 // call, and the dep-graph arm's
3938 // `crate::dep::validate_no_self_dep` call — must key its
3939 // `parent_nome` arg off the typed [`Caixa::nome`] accessor, not
3940 // the raw `&caixa.nome` `&String`-borrow of the underlying
3941 // field.
3942 //
3943 // Structurally: a rename of the storage field or a hypothetical
3944 // accessor rebrand (a per-cluster alias table pinned through a
3945 // future `:placement`-scoped slot, the M4 CR materializer's
3946 // per-CR namespace-qualified rewrite, a `:nome-suffix` overlay
3947 // the MESH-COMPOSITION §III.2 roadmap acknowledges) would land
3948 // through the accessor by construction; a raw-borrow bypass
3949 // would silently disagree with every peer consumer that already
3950 // routes through `caixa.nome()` (the caixa-mesh 980c059,
3951 // caixa-helm 22461ef, caixa-flux 162e2e2, caixa-crd 61d3429,
3952 // caixa-feira ef83332 raw-borrow converges), reintroducing the
3953 // drift surface the sibling converges closed. Each arm fires
3954 // its per-kind `LayoutError` variant (`SupervisorViolation` /
3955 // `AplicacaoViolation` / `DepsViolation`) whose `caixa` field
3956 // carries the offending parent name verbatim through
3957 // `caixa.nome().clone()`; asserting the field equals
3958 // `caixa.nome()` on the mutated fixture pins the accessor-
3959 // routed parent-nome projection at every call site — a future
3960 // silent detour that had the gate observe a stale / aliased
3961 // name at the arg boundary would surface here as a
3962 // `caixa != "demo"` inequality.
3963 //
3964 // Peer of the sibling per-caixa-crate `nome`-arg raw-borrow
3965 // convergence pin discipline (54bf2f3 / 22461ef / 162e2e2 on the
3966 // renderer crates; ef83332 on the CLI) — extends the "one typed
3967 // dispatch per `:nome` consumer" discipline onto the substrate's
3968 // own [`LayoutInvariants::verify`] cross-slot self-edge gate
3969 // wire-up on all three typed-name-graph kinds.
3970 use crate::{
3971 ChildSpec, Dep, Membro, Placement, PlacementStrategy, RestartPolicy, RestartStrategy,
3972 };
3973 let root = PathBuf::from("/tmp/x");
3974 let manifest = root.join("caixa.lisp");
3975 let manifest_clone = manifest.clone();
3976 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
3977
3978 // Supervisor arm — the `caixa()` helper's `:nome` is "demo",
3979 // and the accessor's return `caixa.nome()` must equal the
3980 // parent-nome that the self-supervision gate observes.
3981 let mut sup = caixa(CaixaKind::Supervisor);
3982 sup.estrategia = Some(RestartStrategy::OneForOne);
3983 sup.max_restarts = Some(5);
3984 sup.children = vec![ChildSpec {
3985 caixa: "demo".into(),
3986 versao: "^0.1".into(),
3987 restart: RestartPolicy::Permanent,
3988 }];
3989 let parent_nome_via_accessor = sup.nome();
3990 assert_eq!(
3991 parent_nome_via_accessor, "demo",
3992 "the caixa() fixture helper's `:nome` must be \"demo\" — \
3993 the accessor's return is the pin's ground truth for the \
3994 cross-slot gate's parent-nome arg",
3995 );
3996 let err = layout.verify(&sup, &root).unwrap_err();
3997 let LayoutError::SupervisorViolation { caixa: c_nome, .. } = err else {
3998 panic!("expected SupervisorViolation for self-referential child, got {err:?}");
3999 };
4000 assert_eq!(
4001 c_nome, parent_nome_via_accessor,
4002 "the SupervisorViolation's `caixa` field must equal \
4003 `sup.nome()` — the cross-slot self-supervision gate's \
4004 `parent_nome` arg must route through the lifted \
4005 [`Caixa::nome`] accessor, not the raw `&caixa.nome` \
4006 `&String`-borrow of the underlying field",
4007 );
4008
4009 // Aplicacao arm — same discipline on the peer typed-name-graph
4010 // kind. Constructed alongside the supervisor arm so any future
4011 // accessor drift lands on both arms in the same pin.
4012 let mut app = caixa(CaixaKind::Aplicacao);
4013 app.placement = Some(Placement {
4014 estrategia: PlacementStrategy::Replicated,
4015 clusters: vec!["rio".into()],
4016 affinity: None,
4017 shard_key: None,
4018 });
4019 app.membros = vec![Membro {
4020 caixa: "demo".into(),
4021 versao: "^0.1".into(),
4022 }];
4023 let parent_nome_via_accessor = app.nome();
4024 assert_eq!(
4025 parent_nome_via_accessor, "demo",
4026 "the caixa() fixture helper's `:nome` must be \"demo\" on \
4027 the Aplicacao arm too — same accessor-ground-truth as the \
4028 sibling supervisor arm above",
4029 );
4030 let err = layout.verify(&app, &root).unwrap_err();
4031 let LayoutError::AplicacaoViolation { caixa: c_nome, .. } = err else {
4032 panic!("expected AplicacaoViolation for self-referential membro, got {err:?}");
4033 };
4034 assert_eq!(
4035 c_nome, parent_nome_via_accessor,
4036 "the AplicacaoViolation's `caixa` field must equal \
4037 `app.nome()` — the cross-slot self-membership gate's \
4038 `parent_nome` arg must route through the lifted \
4039 [`Caixa::nome`] accessor, not the raw `&caixa.nome` \
4040 `&String`-borrow of the underlying field",
4041 );
4042
4043 // Dep-graph arm — third typed-name-graph kind on the
4044 // `parent_nome` arg boundary. Same discipline as the peer
4045 // supervision-tree and Aplicacao-membership arms above.
4046 // Constructed alongside so any future accessor drift lands on
4047 // all three arms in the same pin. Needs a distinct layout
4048 // fixture from the supervisor / aplicacao arms above because
4049 // the `Biblioteca` kind's code-path existence gate demands the
4050 // canonical `lib/<nome>.lisp` path also `exists`, so the shim
4051 // covers both `caixa.lisp` and `lib/demo.lisp`.
4052 let default_lib = root.join("lib").join("demo.lisp");
4053 let manifest_dep = manifest.clone();
4054 let default_lib_clone = default_lib.clone();
4055 let layout_dep = StandardLayout::new()
4056 .with_path_exists(move |p| p == manifest_dep || p == default_lib_clone);
4057 let mut lib = caixa(CaixaKind::Biblioteca);
4058 lib.deps = vec![Dep::simple("demo", "^0.1")];
4059 let parent_nome_via_accessor = lib.nome();
4060 assert_eq!(
4061 parent_nome_via_accessor, "demo",
4062 "the caixa() fixture helper's `:nome` must be \"demo\" on \
4063 the Biblioteca arm too — same accessor-ground-truth as the \
4064 sibling supervisor + Aplicacao arms above",
4065 );
4066 let err = layout_dep.verify(&lib, &root).unwrap_err();
4067 let LayoutError::DepsViolation { caixa: c_nome, .. } = err else {
4068 panic!("expected DepsViolation for self-referential :deps entry, got {err:?}");
4069 };
4070 assert_eq!(
4071 c_nome, parent_nome_via_accessor,
4072 "the DepsViolation's `caixa` field must equal \
4073 `lib.nome()` — the cross-slot self-dep gate's `parent_nome` \
4074 arg must route through the lifted [`Caixa::nome`] accessor, \
4075 not the raw `&caixa.nome` `&String`-borrow of the underlying \
4076 field",
4077 );
4078 }
4079
4080 #[test]
4081 fn upgrade_against_versao_gate_routes_current_versao_through_lifted_accessor() {
4082 // Composition pin: the cross-slot `:upgrade-from :from` ↔
4083 // `:versao` precedence gate fired from
4084 // `LayoutInvariants::verify` — the
4085 // `crate::upgrade::validate_upgrade_from_against_versao` call —
4086 // must key its `versao` arg off the typed [`Caixa::versao`]
4087 // accessor, not the raw `&caixa.versao` `&String`-borrow of
4088 // the underlying field.
4089 //
4090 // Same "arg-boundary reads through the lifted accessor"
4091 // discipline as the sibling
4092 // [`cross_slot_self_edge_gates_route_parent_nome_through_lifted_accessor`]
4093 // pin above on the `:nome`-arg axis of the three typed-name-
4094 // graph self-edge gates — extended here onto the `:versao`-arg
4095 // axis of the substrate's remaining `LayoutInvariants::verify`
4096 // cross-slot arg-carrying call site. Structurally byte-equal
4097 // today (the accessor is `pub fn versao(&self) -> &str { &self.versao }`,
4098 // so both paths coerce to the same `&str`); the pin catches a
4099 // future silent detour (an accessor rebrand that no longer
4100 // shipped the raw slot verbatim — a per-`:edicao` overlay,
4101 // a promotion of `:versao` to a `CaixaVersion` newtype with a
4102 // canonicalizing accessor, an M4 CR-materializer-side pinning
4103 // through a resolver-annotated `:versao-resolved` slot) that
4104 // would silently split the substrate's own precedence gate
4105 // from every peer consumer already routing `:versao` reads
4106 // through the lifted accessor.
4107 //
4108 // The gate fires an `UpgradeViolation { caixa, issue }` when a
4109 // `:upgrade-from` entry's `:from` is not strictly less than
4110 // the top-level `:versao` — the `issue` string names both the
4111 // offending prior version and the current version verbatim,
4112 // so asserting the substring `caixa.versao()` appears in the
4113 // fired diagnostic pins the accessor-routed current-versao
4114 // projection at the arg boundary. A raw-borrow bypass would
4115 // still surface the same bytes today, but the presence of
4116 // this pin makes any future divergence between the accessor's
4117 // return and the raw slot's contents a build-time failure at
4118 // this call site.
4119 use crate::{UpgradeFromEntry, UpgradeInstruction};
4120 let root = PathBuf::from("/tmp/x");
4121 let manifest = root.join("caixa.lisp");
4122 let servico_path = root.join("servicos").join("demo.computeunit.yaml");
4123 let manifest_clone = manifest.clone();
4124 let servico_clone = servico_path.clone();
4125 let layout = StandardLayout::new()
4126 .with_path_exists(move |p| p == manifest_clone || p == servico_clone);
4127 let mut svc = caixa(CaixaKind::Servico);
4128 svc.versao = "0.1.0".into();
4129 svc.servicos = vec!["servicos/demo.computeunit.yaml".into()];
4130 // `:from` >= current `:versao` — trips the precedence gate the
4131 // `validate_upgrade_from_against_versao` cross-slot call
4132 // enforces. `:load-module` carries no on-disk path so the
4133 // path-existence gate downstream stays inert; the precedence
4134 // gate is what fires. No `:on-state-change` needed because the
4135 // instruction list carries no `:state-change` entry, so the
4136 // sibling `validate_upgrade_from_against_behavior` gate is
4137 // inert too.
4138 svc.upgrade_from = vec![UpgradeFromEntry {
4139 from: "0.2.0".into(),
4140 instructions: vec![UpgradeInstruction::LoadModule {
4141 module: "demo".into(),
4142 }],
4143 }];
4144 let current_versao_via_accessor = svc.versao().to_string();
4145 assert_eq!(
4146 current_versao_via_accessor, "0.1.0",
4147 "the mutated fixture's `:versao` must be observable through \
4148 the accessor before layout verification fires — a drift on \
4149 `Caixa::versao` would surface here as a `!= \"0.1.0\"` \
4150 inequality",
4151 );
4152 let err = layout.verify(&svc, &root).unwrap_err();
4153 let LayoutError::UpgradeViolation {
4154 caixa: c_nome,
4155 issue,
4156 } = err
4157 else {
4158 panic!("expected UpgradeViolation for :from >= :versao, got {err:?}");
4159 };
4160 assert_eq!(c_nome, svc.nome(), "wrap envelope names the caixa");
4161 assert!(
4162 issue.contains(¤t_versao_via_accessor),
4163 "the UpgradeViolation's `issue` must quote the current \
4164 `:versao` byte-string verbatim — the cross-slot precedence \
4165 gate's `versao` arg must route through the lifted \
4166 [`Caixa::versao`] accessor, not the raw `&caixa.versao` \
4167 `&String`-borrow of the underlying field. issue: {issue}",
4168 );
4169 }
4170
4171 #[test]
4172 fn layout_violation_envelopes_carry_caixa_nome_through_lifted_accessor() {
4173 // Wrap-envelope drift-detection pin: every per-axis
4174 // `LayoutError::*Violation { caixa, issue }` envelope fired
4175 // from `LayoutInvariants::verify` must key its offending-caixa
4176 // field off the typed [`Caixa::nome`] accessor's
4177 // `.to_string()` extension, not the raw
4178 // `caixa.nome.clone()` `String::clone()` of the underlying
4179 // field. Structurally byte-equal today (each accessor is
4180 // `pub fn nome(&self) -> &str { &self.nome }`, so
4181 // `caixa.nome().to_string()` and `caixa.nome.clone()` produce
4182 // the same bytes); the pin catches a future silent detour
4183 // (an accessor rebrand that no longer shipped the raw slot
4184 // verbatim — a per-cluster alias table pinned through a
4185 // future `:placement`-scoped slot, the M4 CR materializer's
4186 // per-CR namespace-qualified rewrite, a `:nome-suffix`
4187 // overlay the MESH-COMPOSITION §III.2 roadmap acknowledges)
4188 // that would silently split the substrate's own layout
4189 // invariant verifier's diagnostic surface from every peer
4190 // caixa-crate consumer that already routes `:nome` reads
4191 // through the lifted accessor (the caixa-mesh 980c059,
4192 // caixa-helm 22461ef, caixa-flux 162e2e2, caixa-crd 61d3429,
4193 // caixa-feira ef83332 raw-borrow converges).
4194 //
4195 // Exercises a representative variant on each of the three
4196 // wrap-envelope arm shapes the substrate's per-axis fan-out
4197 // carries: (1) `LayoutError::NomeViolation` (the leading arm
4198 // in the `verify` order — the `:nome` axis's DNS-1123 shape
4199 // gate fires immediately after the manifest-existence gate),
4200 // (2) `LayoutError::BinarioWithoutExe` (a tuple-variant on
4201 // the kind-coherence family — different envelope shape than
4202 // the struct-variant `*Violation { caixa, issue }` family
4203 // but the same converge target on the `caixa.nome().to_string()`
4204 // arg), and (3) `LayoutError::ServicoWithoutServicos` (the
4205 // sibling tuple-variant on the same kind-coherence family).
4206 // Together they cover the two `LayoutError` envelope shapes
4207 // (struct-variant + tuple-variant) the layout invariants file
4208 // emits on `:nome`-carrying arms.
4209 //
4210 // Peer of the sibling
4211 // [`cross_slot_self_edge_gates_route_parent_nome_through_lifted_accessor`]
4212 // pin above — extends the "wrap-envelope `caixa:` field
4213 // reads through the lifted accessor" discipline from the
4214 // cross-slot self-edge gates' `parent_nome` arg boundary
4215 // onto the per-axis `LayoutError::*Violation` envelope's
4216 // `caixa:` field boundary.
4217
4218 let root = PathBuf::from("/tmp/x");
4219 let manifest = root.join("caixa.lisp");
4220 let manifest_clone = manifest.clone();
4221 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4222
4223 // (1) `NomeViolation` on the struct-variant envelope: force a
4224 // DNS-1123-invalid `:nome` (uppercase byte — `is_dns_1123_label`
4225 // rejects) and assert the fired envelope's `caixa:` field
4226 // byte-equals `c.nome().to_string()`.
4227 let mut c = caixa(CaixaKind::Biblioteca);
4228 c.nome = "BAD_NAME".into();
4229 c.bibliotecas = vec!["lib/demo.lisp".into()];
4230 let expected_nome_via_accessor = c.nome().to_string();
4231 assert_eq!(
4232 expected_nome_via_accessor, "BAD_NAME",
4233 "the mutated fixture's `:nome` must be observable through \
4234 the accessor before layout verification fires — a drift \
4235 on `Caixa::nome` would surface here as a `!= \"BAD_NAME\"` \
4236 inequality",
4237 );
4238 let err = layout.verify(&c, &root).unwrap_err();
4239 let LayoutError::NomeViolation { caixa: c_nome, .. } = err else {
4240 panic!("expected NomeViolation for DNS-1123-invalid :nome, got {err:?}");
4241 };
4242 assert_eq!(
4243 c_nome, expected_nome_via_accessor,
4244 "the NomeViolation's `caixa` field must equal \
4245 `c.nome().to_string()` — the wrap envelope's per-axis \
4246 projection must route through the lifted [`Caixa::nome`] \
4247 accessor's `.to_string()` extension, not the raw \
4248 `caixa.nome.clone()` `String::clone()` of the underlying \
4249 field",
4250 );
4251
4252 // (2) `BinarioWithoutExe` on the tuple-variant envelope: a
4253 // Binario-kind caixa with an empty `:exe` list fires the
4254 // kind-coherence gate whose payload is a bare `String`, so the
4255 // pattern is `LayoutError::BinarioWithoutExe(String)` rather
4256 // than the struct-variant `{ caixa, issue }` family. The
4257 // converge target is the same — `caixa.nome().to_string()` — but
4258 // the envelope shape is different, so the pin exercises both.
4259 let mut c = caixa(CaixaKind::Binario);
4260 // `:exe` empty is the trigger — the fixture helper defaults
4261 // it to `vec![]`, so no mutation is needed.
4262 c.nome = "binario-demo".into();
4263 let expected_nome_via_accessor = c.nome().to_string();
4264 assert_eq!(
4265 expected_nome_via_accessor, "binario-demo",
4266 "the mutated fixture's `:nome` must be observable through \
4267 the accessor before layout verification fires",
4268 );
4269 let err = layout.verify(&c, &root).unwrap_err();
4270 let LayoutError::BinarioWithoutExe(c_nome) = err else {
4271 panic!("expected BinarioWithoutExe for empty :exe list on Binario kind, got {err:?}");
4272 };
4273 assert_eq!(
4274 c_nome, expected_nome_via_accessor,
4275 "the BinarioWithoutExe's payload must equal \
4276 `c.nome().to_string()` — the tuple-variant envelope's \
4277 per-axis projection must route through the lifted \
4278 [`Caixa::nome`] accessor's `.to_string()` extension, not \
4279 the raw `caixa.nome.clone()` `String::clone()` of the \
4280 underlying field",
4281 );
4282
4283 // (3) `ServicoWithoutServicos` on the sibling tuple-variant
4284 // envelope: same discipline on the peer kind-coherence
4285 // partition arm. Constructed alongside the Binario arm so any
4286 // future accessor drift lands on both arms in the same pin.
4287 let mut c = caixa(CaixaKind::Servico);
4288 // `:servicos` empty is the trigger — the fixture helper
4289 // defaults it to `vec![]`, so no mutation is needed.
4290 c.nome = "servico-demo".into();
4291 let expected_nome_via_accessor = c.nome().to_string();
4292 assert_eq!(
4293 expected_nome_via_accessor, "servico-demo",
4294 "the mutated fixture's `:nome` must be observable through \
4295 the accessor before layout verification fires",
4296 );
4297 let err = layout.verify(&c, &root).unwrap_err();
4298 let LayoutError::ServicoWithoutServicos(c_nome) = err else {
4299 panic!(
4300 "expected ServicoWithoutServicos for empty :servicos list on Servico kind, \
4301 got {err:?}"
4302 );
4303 };
4304 assert_eq!(
4305 c_nome, expected_nome_via_accessor,
4306 "the ServicoWithoutServicos's payload must equal \
4307 `c.nome().to_string()` — same converge discipline as the \
4308 sibling `BinarioWithoutExe` tuple-variant arm above",
4309 );
4310 }
4311
4312 #[test]
4313 fn supervisor_must_not_have_bibliotecas() {
4314 use crate::{ChildSpec, RestartPolicy, RestartStrategy};
4315 let root = PathBuf::from("/tmp/x");
4316 let manifest = root.join("caixa.lisp");
4317 let manifest_clone = manifest.clone();
4318 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4319 let mut c = caixa(CaixaKind::Supervisor);
4320 c.estrategia = Some(RestartStrategy::OneForOne);
4321 c.max_restarts = Some(5);
4322 c.bibliotecas = vec!["lib/code.lisp".into()];
4323 c.children = vec![ChildSpec {
4324 caixa: "worker".into(),
4325 versao: "^0.1".into(),
4326 restart: RestartPolicy::Permanent,
4327 }];
4328 let err = layout.verify(&c, &root).unwrap_err();
4329 assert!(matches!(err, LayoutError::SupervisorOwnsCode(_)));
4330 }
4331
4332 // ── Caixa::validate_restart_window wired into Supervisor verify ─────
4333 //
4334 // Until this wire-up landed `Caixa::validate_restart_window` lived as
4335 // `pub fn` on `Caixa` with full per-arm unit coverage in
4336 // `manifest::tests` (`validate_restart_window_rejects_*` — fractional,
4337 // decimal-shaped integer, half-unit minute, leading sign, unknown
4338 // unit, garbage, empty-after-trim) but no production path called it;
4339 // `feira build` silently accepted malformed `:restart-window` and
4340 // `Caixa::supervisor_view` soft-swallowed the parse failure as
4341 // `restart_window: None` (the canonical "no reset" sentinel), turning
4342 // every authoring footgun into a never-reset supervisor far from the
4343 // source caixa.lisp. The following pins fence the layout-pipeline
4344 // wire-up: every layout verify on a structurally-invalid `:restart-
4345 // window` axis surfaces the per-axis `RestartWindowViolation { caixa,
4346 // issue }` envelope before the typed `SupervisorSpec::validate` gate
4347 // sees the laundered `None`.
4348
4349 fn supervisor_with_window(window: Option<&str>) -> Caixa {
4350 use crate::{ChildSpec, RestartPolicy, RestartStrategy};
4351 let mut c = caixa(CaixaKind::Supervisor);
4352 c.estrategia = Some(RestartStrategy::OneForOne);
4353 c.max_restarts = Some(5);
4354 c.restart_window = window.map(str::to_string);
4355 c.children = vec![ChildSpec {
4356 caixa: "worker".into(),
4357 versao: "^0.1".into(),
4358 restart: RestartPolicy::Permanent,
4359 }];
4360 c
4361 }
4362
4363 #[test]
4364 fn restart_window_violation_on_fractional_seconds() {
4365 // `"1.5s"` is the canonical fractional-seconds drift footgun the
4366 // shared integer-magnitude codec (1c55a2a) rejects: round-trips
4367 // through `render` as `"1500ms"` on first serialize, breaking
4368 // THEORY.md §V.2.7 render-determinism. Before this wire-up
4369 // `supervisor_view` soft-swallowed the parse error as
4370 // `restart_window: None`, masking the drift as a never-reset
4371 // supervisor.
4372 let root = PathBuf::from("/tmp/x");
4373 let manifest = root.join("caixa.lisp");
4374 let manifest_clone = manifest.clone();
4375 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4376 let c = supervisor_with_window(Some("1.5s"));
4377 let err = layout.verify(&c, &root).unwrap_err();
4378 let LayoutError::RestartWindowViolation { caixa, issue } = err else {
4379 panic!("expected LayoutError::RestartWindowViolation, got {err:?}");
4380 };
4381 assert_eq!(caixa, "demo");
4382 assert!(
4383 issue.contains("1.5s"),
4384 "issue must quote the offending raw value: {issue}",
4385 );
4386 }
4387
4388 #[test]
4389 fn restart_window_violation_on_decimal_shaped_integer() {
4390 // `"1.0s"` — decimal-shaped integer the codec also rejects (a
4391 // canonical authoring form is `"1s"`). Sibling of the fractional
4392 // case; same codec arm.
4393 let root = PathBuf::from("/tmp/x");
4394 let manifest = root.join("caixa.lisp");
4395 let manifest_clone = manifest.clone();
4396 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4397 let c = supervisor_with_window(Some("1.0s"));
4398 let err = layout.verify(&c, &root).unwrap_err();
4399 assert!(
4400 matches!(
4401 err,
4402 LayoutError::RestartWindowViolation { ref caixa, ref issue }
4403 if caixa == "demo" && issue.contains("1.0s")
4404 ),
4405 "got {err:?}",
4406 );
4407 }
4408
4409 #[test]
4410 fn restart_window_violation_on_leading_sign() {
4411 // `"+30s"` / `"-30s"` — leading-sign drift the codec rejects.
4412 // Canonical form is `"30s"`. Pin both signs separately because
4413 // a future relaxation might accept one but not the other.
4414 for raw in ["+30s", "-30s"] {
4415 let root = PathBuf::from("/tmp/x");
4416 let manifest = root.join("caixa.lisp");
4417 let manifest_clone = manifest.clone();
4418 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4419 let c = supervisor_with_window(Some(raw));
4420 let err = layout.verify(&c, &root).unwrap_err();
4421 assert!(
4422 matches!(
4423 err,
4424 LayoutError::RestartWindowViolation { ref caixa, ref issue }
4425 if caixa == "demo" && issue.contains(raw)
4426 ),
4427 "leading-sign {raw:?} got {err:?}",
4428 );
4429 }
4430 }
4431
4432 #[test]
4433 fn restart_window_violation_on_unknown_unit() {
4434 // `"30x"` — unknown duration unit. The codec admits only
4435 // `ms`/`s`/`m`/`h`.
4436 let root = PathBuf::from("/tmp/x");
4437 let manifest = root.join("caixa.lisp");
4438 let manifest_clone = manifest.clone();
4439 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4440 let c = supervisor_with_window(Some("30x"));
4441 let err = layout.verify(&c, &root).unwrap_err();
4442 assert!(
4443 matches!(
4444 err,
4445 LayoutError::RestartWindowViolation { ref caixa, .. } if caixa == "demo"
4446 ),
4447 "got {err:?}",
4448 );
4449 }
4450
4451 #[test]
4452 fn restart_window_violation_on_garbage() {
4453 // `"abc"` — pure garbage. The codec's parse fails before the
4454 // unit dispatch; the wrap envelope still surfaces the
4455 // self-locating diagnostic at the source.
4456 let root = PathBuf::from("/tmp/x");
4457 let manifest = root.join("caixa.lisp");
4458 let manifest_clone = manifest.clone();
4459 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4460 let c = supervisor_with_window(Some("abc"));
4461 let err = layout.verify(&c, &root).unwrap_err();
4462 assert!(
4463 matches!(err, LayoutError::RestartWindowViolation { ref caixa, .. } if caixa == "demo"),
4464 "got {err:?}",
4465 );
4466 }
4467
4468 #[test]
4469 fn restart_window_violation_on_empty_string() {
4470 // `""` — empty after trim. The shared codec's digit-only gate
4471 // refuses an empty magnitude. Distinguished here from the
4472 // `None` ("omit the slot") canonical authoring shape: an empty
4473 // string is an authored-but-empty slot, never the author's
4474 // intent.
4475 let root = PathBuf::from("/tmp/x");
4476 let manifest = root.join("caixa.lisp");
4477 let manifest_clone = manifest.clone();
4478 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4479 let c = supervisor_with_window(Some(""));
4480 let err = layout.verify(&c, &root).unwrap_err();
4481 assert!(
4482 matches!(err, LayoutError::RestartWindowViolation { ref caixa, .. } if caixa == "demo"),
4483 "got {err:?}",
4484 );
4485 }
4486
4487 #[test]
4488 fn verify_accepts_supervisor_without_restart_window() {
4489 // `None` is the canonical "omit the slot to express no reset"
4490 // shape — never reaches the codec, validates cleanly.
4491 let root = PathBuf::from("/tmp/x");
4492 let manifest = root.join("caixa.lisp");
4493 let manifest_clone = manifest.clone();
4494 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4495 let c = supervisor_with_window(None);
4496 layout.verify(&c, &root).unwrap();
4497 }
4498
4499 #[test]
4500 fn verify_accepts_supervisor_with_canonical_restart_window() {
4501 // Every canonical form the shared codec round-trips losslessly
4502 // must pass — `"500ms"`, `"30s"`, `"60s"`, `"1m"`, `"2m"`,
4503 // `"1h"`. Pin every form so a future tightening of the codec's
4504 // accepted set surfaces here as a test failure.
4505 for form in ["500ms", "30s", "60s", "1m", "2m", "1h"] {
4506 let root = PathBuf::from("/tmp/x");
4507 let manifest = root.join("caixa.lisp");
4508 let manifest_clone = manifest.clone();
4509 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4510 let c = supervisor_with_window(Some(form));
4511 layout
4512 .verify(&c, &root)
4513 .unwrap_or_else(|e| panic!("canonical {form:?} must validate, got {e:?}"));
4514 }
4515 }
4516
4517 #[test]
4518 fn restart_window_violation_fires_before_supervisor_view_validate() {
4519 // Diagnostic-precedence pin: a Supervisor with a malformed
4520 // `:restart-window` AND a typed-shape defect on the typed view
4521 // (zero `:max-restarts`, which `SupervisorSpec::validate`'s
4522 // `ZeroMaxRestarts` arm rejects) surfaces the raw-string
4523 // diagnostic first — the narrower self-locating gate wins. Until
4524 // this wire-up landed `supervisor_view` would silently launder
4525 // the malformed `:restart-window` to `None` and then the typed
4526 // view's `ZeroMaxRestarts` gate would surface, masking the
4527 // raw-string footgun.
4528 use crate::{ChildSpec, RestartPolicy, RestartStrategy};
4529 let root = PathBuf::from("/tmp/x");
4530 let manifest = root.join("caixa.lisp");
4531 let manifest_clone = manifest.clone();
4532 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4533 let mut c = caixa(CaixaKind::Supervisor);
4534 c.estrategia = Some(RestartStrategy::OneForOne);
4535 c.max_restarts = Some(0);
4536 c.restart_window = Some("1.5s".into());
4537 c.children = vec![ChildSpec {
4538 caixa: "worker".into(),
4539 versao: "^0.1".into(),
4540 restart: RestartPolicy::Permanent,
4541 }];
4542 let err = layout.verify(&c, &root).unwrap_err();
4543 assert!(
4544 matches!(err, LayoutError::RestartWindowViolation { .. }),
4545 "got {err:?} — RestartWindowViolation must fire before SupervisorViolation",
4546 );
4547 }
4548
4549 #[test]
4550 fn supervisor_slots_on_non_supervisor_fires_before_restart_window_violation() {
4551 // Order pin: a non-Supervisor caixa with a malformed
4552 // `:restart-window` surfaces `SupervisorSlotsOnNonSupervisor`
4553 // (the kind-coherence gate at the top of verify) before the
4554 // raw-string parse gate inside the Supervisor branch — because
4555 // `:restart-window` is foreign to non-Supervisor kinds, the
4556 // kind-coherence diagnostic is the load-bearing one. Mirrors
4557 // the existing `nome_violation_on_*` ordering tests that fence
4558 // the precedence between universal and kind-specific gates.
4559 let root = PathBuf::from("/tmp/x");
4560 let manifest = root.join("caixa.lisp");
4561 let default_lib = root.join("lib").join("demo.lisp");
4562 let layout =
4563 StandardLayout::new().with_path_exists(move |p| p == manifest || p == default_lib);
4564 let mut c = caixa(CaixaKind::Biblioteca);
4565 c.restart_window = Some("1.5s".into());
4566 let err = layout.verify(&c, &root).unwrap_err();
4567 assert!(
4568 matches!(err, LayoutError::SupervisorSlotsOnNonSupervisor { .. }),
4569 "got {err:?} — kind-coherence must fire before RestartWindowViolation",
4570 );
4571 }
4572
4573 #[test]
4574 fn restart_window_violation_diagnostic_carries_offending_value() {
4575 // Diagnostic-shape pin: the wrap envelope's `issue` carries the
4576 // codec's parser-shaped reason verbatim (which names the
4577 // offending raw value), so the author can grep their caixa.lisp
4578 // for `:restart-window "<value>"` and fix in one edit. Mirrors
4579 // `nome_violation_*_carries_offending_*` shape pins.
4580 let root = PathBuf::from("/tmp/x");
4581 let manifest = root.join("caixa.lisp");
4582 let manifest_clone = manifest.clone();
4583 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4584 let c = supervisor_with_window(Some("0.5m"));
4585 let err = layout.verify(&c, &root).unwrap_err();
4586 let LayoutError::RestartWindowViolation { caixa, issue } = err else {
4587 panic!("expected LayoutError::RestartWindowViolation, got {err:?}");
4588 };
4589 assert_eq!(caixa, "demo");
4590 assert!(
4591 issue.contains("0.5m"),
4592 "issue must quote the offending raw value verbatim: {issue}",
4593 );
4594 assert!(
4595 !issue.is_empty(),
4596 "issue must carry the codec's parser-shaped reason",
4597 );
4598 }
4599
4600 // ── Aplicacao layout tests ──────────────────────────────────────────
4601
4602 #[test]
4603 fn aplicacao_must_have_membros() {
4604 use crate::{Membro, Placement, PlacementStrategy};
4605 let root = PathBuf::from("/tmp/x");
4606 let manifest = root.join("caixa.lisp");
4607 let manifest_clone = manifest.clone();
4608 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4609 let mut c = caixa(CaixaKind::Aplicacao);
4610 c.placement = Some(Placement {
4611 estrategia: PlacementStrategy::Replicated,
4612 clusters: vec!["rio".into()],
4613 affinity: None,
4614 shard_key: None,
4615 });
4616 // No membros → fails
4617 let err = layout.verify(&c, &root).unwrap_err();
4618 assert!(matches!(err, LayoutError::AplicacaoViolation { .. }));
4619
4620 // With membros → passes
4621 c.membros = vec![Membro {
4622 caixa: "service-a".into(),
4623 versao: "^0.1".into(),
4624 }];
4625 layout.verify(&c, &root).unwrap();
4626 }
4627
4628 #[test]
4629 fn aplicacao_self_referential_membro_is_violation() {
4630 // An Aplicacao whose `:membros` names its own `:nome` is a
4631 // one-node lacre-closure recursion. The cross-slot gate fires
4632 // at verify time, surfacing as an AplicacaoViolation that
4633 // names the offending aplicacao — not at lacre-resolve time
4634 // far from source. The `caixa()` helper's `:nome` is "demo".
4635 // Peer of `supervisor_self_referential_child_is_violation`
4636 // on the supervision-tree axis.
4637 use crate::{Membro, Placement, PlacementStrategy};
4638 let root = PathBuf::from("/tmp/x");
4639 let manifest = root.join("caixa.lisp");
4640 let manifest_clone = manifest.clone();
4641 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4642 let mut c = caixa(CaixaKind::Aplicacao);
4643 c.placement = Some(Placement {
4644 estrategia: PlacementStrategy::Replicated,
4645 clusters: vec!["rio".into()],
4646 affinity: None,
4647 shard_key: None,
4648 });
4649 c.membros = vec![
4650 Membro {
4651 caixa: "service-a".into(),
4652 versao: "^0.1".into(),
4653 },
4654 Membro {
4655 caixa: "demo".into(),
4656 versao: "^0.1".into(),
4657 },
4658 ];
4659 let err = layout.verify(&c, &root).unwrap_err();
4660 let LayoutError::AplicacaoViolation { caixa, issue } = err else {
4661 panic!("expected AplicacaoViolation for self-referential membro, got {err:?}");
4662 };
4663 assert_eq!(caixa, "demo");
4664 assert!(
4665 issue.contains("demo") && issue.contains("lists itself"),
4666 "issue must name the self-membering aplicacao, got {issue:?}"
4667 );
4668 }
4669
4670 #[test]
4671 fn aplicacao_distinct_membros_pass_self_membership_gate() {
4672 // Positive control: an Aplicacao whose membros are all distinct
4673 // from its own `:nome` verifies cleanly.
4674 use crate::{Membro, Placement, PlacementStrategy};
4675 let root = PathBuf::from("/tmp/x");
4676 let manifest = root.join("caixa.lisp");
4677 let manifest_clone = manifest.clone();
4678 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4679 let mut c = caixa(CaixaKind::Aplicacao);
4680 c.placement = Some(Placement {
4681 estrategia: PlacementStrategy::Replicated,
4682 clusters: vec!["rio".into()],
4683 affinity: None,
4684 shard_key: None,
4685 });
4686 c.membros = vec![
4687 Membro {
4688 caixa: "service-a".into(),
4689 versao: "^0.1".into(),
4690 },
4691 Membro {
4692 caixa: "service-b".into(),
4693 versao: "^0.1".into(),
4694 },
4695 ];
4696 layout.verify(&c, &root).unwrap();
4697 }
4698
4699 #[test]
4700 fn aplicacao_self_membership_fires_after_view_validate() {
4701 // Diagnostic-precedence pin: a self-referential membro alongside
4702 // a duplicate-:caixa shape surfaces the more-fundamental
4703 // `MembroDuplicate` (from `view.validate()`) first; only when the
4704 // per-membros shape diagnostics pass does the cross-slot
4705 // self-membership gate fire. Mirrors the ordering pin
4706 // `supervisor_self_referential_child_is_violation` carries on
4707 // the peer supervision-tree axis (`view.validate()` runs first,
4708 // then the cross-slot gate). Without this ordering a future
4709 // refactor that swaps the two calls would silently mask the
4710 // narrower per-membro defect.
4711 use crate::{Membro, Placement, PlacementStrategy};
4712 let root = PathBuf::from("/tmp/x");
4713 let manifest = root.join("caixa.lisp");
4714 let manifest_clone = manifest.clone();
4715 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4716 let mut c = caixa(CaixaKind::Aplicacao);
4717 c.placement = Some(Placement {
4718 estrategia: PlacementStrategy::Replicated,
4719 clusters: vec!["rio".into()],
4720 affinity: None,
4721 shard_key: None,
4722 });
4723 c.membros = vec![
4724 Membro {
4725 caixa: "service-a".into(),
4726 versao: "^0.1".into(),
4727 },
4728 Membro {
4729 caixa: "service-a".into(),
4730 versao: "^0.2".into(),
4731 },
4732 Membro {
4733 caixa: "demo".into(),
4734 versao: "^0.1".into(),
4735 },
4736 ];
4737 let err = layout.verify(&c, &root).unwrap_err();
4738 let LayoutError::AplicacaoViolation { issue, .. } = err else {
4739 panic!("expected AplicacaoViolation, got {err:?}");
4740 };
4741 // The per-membros duplicate diagnostic (from view.validate())
4742 // surfaces ahead of the cross-slot self-membership gate, so the
4743 // `service-a` duplicate is named — not the `demo` self-reference.
4744 assert!(
4745 issue.contains("service-a") && issue.contains("more than once"),
4746 "duplicate-:caixa diagnostic must surface before self-membership gate, \
4747 got {issue:?}"
4748 );
4749 }
4750
4751 #[test]
4752 fn mesh_slots_on_servico_rejected() {
4753 // The canonical real-world footgun: an author adds :entrada to a
4754 // :kind Servico expecting it to expose ingress. aplicacao_view
4755 // returns None for Servico, so the slot is the manifest's
4756 // "ignored otherwise" — never validated, never rendered. The
4757 // kind-coherence gate rejects it at build time (before the
4758 // :servicos existence loop), naming the offending slot + kind.
4759 use crate::Entrada;
4760 let root = PathBuf::from("/tmp/x");
4761 let manifest = root.join("caixa.lisp");
4762 let manifest_clone = manifest.clone();
4763 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4764 let mut c = caixa(CaixaKind::Servico);
4765 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
4766 c.entrada = Some(Entrada {
4767 host: "demo.example.com".into(),
4768 para: "demo".into(),
4769 paths: vec![],
4770 port: 8080,
4771 });
4772 let err = layout.verify(&c, &root).unwrap_err();
4773 match err {
4774 LayoutError::MeshSlotsOnNonAplicacao { caixa, kind, slots } => {
4775 assert_eq!(caixa, "demo");
4776 assert_eq!(kind, CaixaKind::Servico);
4777 assert_eq!(slots, crate::render::M3_AUTHOR_KEY_ENTRADA);
4778 }
4779 other => panic!("expected MeshSlotsOnNonAplicacao, got {other:?}"),
4780 }
4781 }
4782
4783 #[test]
4784 fn mesh_slots_on_non_aplicacao_lists_slots_in_canonical_order() {
4785 // All five mesh slots declared on a Biblioteca → the diagnostic
4786 // enumerates them in canonical declaration order, deterministic
4787 // across runs. The gate fires on declared-ness only (the values
4788 // need not be a *valid* AplicacaoSpec — aplicacao_view is never
4789 // called for a non-Aplicacao kind).
4790 use crate::{Entrada, Membro, MeshPolicy, Placement, PlacementStrategy, WitContract};
4791 let root = PathBuf::from("/tmp/x");
4792 let manifest = root.join("caixa.lisp");
4793 let manifest_clone = manifest.clone();
4794 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4795 let mut c = caixa(CaixaKind::Biblioteca);
4796 c.membros = vec![Membro {
4797 caixa: "a".into(),
4798 versao: "^0.1".into(),
4799 }];
4800 c.contratos = vec![WitContract {
4801 de: "a".into(),
4802 para: "a".into(),
4803 wit: "wasi:http/proxy".into(),
4804 endpoint: Some("/x".into()),
4805 subject: None,
4806 slot: None,
4807 }];
4808 c.politicas = Some(MeshPolicy::default());
4809 c.placement = Some(Placement {
4810 estrategia: PlacementStrategy::Replicated,
4811 clusters: vec!["rio".into()],
4812 affinity: None,
4813 shard_key: None,
4814 });
4815 c.entrada = Some(Entrada {
4816 host: "x.example.com".into(),
4817 para: "a".into(),
4818 paths: vec![],
4819 port: 8080,
4820 });
4821 let err = layout.verify(&c, &root).unwrap_err();
4822 match err {
4823 LayoutError::MeshSlotsOnNonAplicacao { slots, .. } => {
4824 assert_eq!(
4825 slots,
4826 format!(
4827 "{} {} {} {} {}",
4828 crate::render::M3_AUTHOR_KEY_MEMBROS,
4829 crate::render::M3_AUTHOR_KEY_CONTRATOS,
4830 crate::render::M3_AUTHOR_KEY_POLITICAS,
4831 crate::render::M3_AUTHOR_KEY_PLACEMENT,
4832 crate::render::M3_AUTHOR_KEY_ENTRADA,
4833 )
4834 );
4835 }
4836 other => panic!("expected MeshSlotsOnNonAplicacao, got {other:?}"),
4837 }
4838 }
4839
4840 #[test]
4841 fn servico_without_mesh_slots_still_verifies() {
4842 // Pass-after control: a well-formed Servico carrying no mesh
4843 // slots must remain accepted — the gate keys off declared-ness,
4844 // so it must not over-fire on the common case.
4845 let root = PathBuf::from("/tmp/x");
4846 let servico = root.join("servicos/demo.computeunit.yaml");
4847 let manifest = root.join("caixa.lisp");
4848 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == servico);
4849 let mut c = caixa(CaixaKind::Servico);
4850 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
4851 layout.verify(&c, &root).unwrap();
4852 }
4853
4854 #[test]
4855 fn supervisor_slots_on_servico_rejected() {
4856 // Mirror of `mesh_slots_on_servico_rejected` on the
4857 // supervisor-tree slot set: an author adds `:children` to a
4858 // `:kind Servico` expecting it to spawn workers. supervisor_view
4859 // returns None for Servico, so the slot is the manifest's
4860 // "ignored otherwise" — never validated, never reconciled. The
4861 // kind-coherence gate rejects it at build time (before the
4862 // :servicos existence loop), naming the offending slot + kind.
4863 use crate::{ChildSpec, RestartPolicy};
4864 let root = PathBuf::from("/tmp/x");
4865 let manifest = root.join("caixa.lisp");
4866 let manifest_clone = manifest.clone();
4867 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4868 let mut c = caixa(CaixaKind::Servico);
4869 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
4870 c.children = vec![ChildSpec {
4871 caixa: "worker".into(),
4872 versao: "^0.1".into(),
4873 restart: RestartPolicy::Permanent,
4874 }];
4875 let err = layout.verify(&c, &root).unwrap_err();
4876 match err {
4877 LayoutError::SupervisorSlotsOnNonSupervisor { caixa, kind, slots } => {
4878 assert_eq!(caixa, "demo");
4879 assert_eq!(kind, CaixaKind::Servico);
4880 assert_eq!(slots, crate::render::SUPERVISOR_AUTHOR_KEY_CHILDREN);
4881 }
4882 other => panic!("expected SupervisorSlotsOnNonSupervisor, got {other:?}"),
4883 }
4884 }
4885
4886 #[test]
4887 fn supervisor_slots_on_non_supervisor_lists_slots_in_canonical_order() {
4888 // All four supervisor slots declared on a Biblioteca → the
4889 // diagnostic enumerates them in canonical declaration order
4890 // (`:estrategia` → `:max-restarts` → `:restart-window` →
4891 // `:children`), deterministic across runs. The gate fires on
4892 // declared-ness only (the values need not be a *valid*
4893 // SupervisorSpec — supervisor_view is never called for a
4894 // non-Supervisor kind). Mirror of
4895 // `mesh_slots_on_non_aplicacao_lists_slots_in_canonical_order`.
4896 use crate::{ChildSpec, RestartPolicy, RestartStrategy};
4897 let root = PathBuf::from("/tmp/x");
4898 let manifest = root.join("caixa.lisp");
4899 let manifest_clone = manifest.clone();
4900 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4901 let mut c = caixa(CaixaKind::Biblioteca);
4902 c.estrategia = Some(RestartStrategy::OneForOne);
4903 c.max_restarts = Some(5);
4904 c.restart_window = Some("60s".into());
4905 c.children = vec![ChildSpec {
4906 caixa: "worker".into(),
4907 versao: "^0.1".into(),
4908 restart: RestartPolicy::Permanent,
4909 }];
4910 let err = layout.verify(&c, &root).unwrap_err();
4911 match err {
4912 LayoutError::SupervisorSlotsOnNonSupervisor { slots, .. } => {
4913 assert_eq!(slots, ":estrategia :max-restarts :restart-window :children");
4914 }
4915 other => panic!("expected SupervisorSlotsOnNonSupervisor, got {other:?}"),
4916 }
4917 }
4918
4919 #[test]
4920 fn aplicacao_with_supervisor_slots_rejected() {
4921 // Cross-kind pin: an Aplicacao (the other no-code orchestrator
4922 // kind) that declares a supervisor slot is rejected by the
4923 // supervisor-slot gate, just as a Supervisor declaring a mesh
4924 // slot is rejected by the mesh-slot gate — the two kind ↔ slot
4925 // coherence gates are symmetric and mutually exclusive. The
4926 // gate fires before the Aplicacao typed-graph validation, so
4927 // the diagnostic names the foreign supervisor slot rather than
4928 // a downstream AplicacaoViolation.
4929 use crate::{Membro, Placement, PlacementStrategy, RestartStrategy};
4930 let root = PathBuf::from("/tmp/x");
4931 let manifest = root.join("caixa.lisp");
4932 let manifest_clone = manifest.clone();
4933 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
4934 let mut c = caixa(CaixaKind::Aplicacao);
4935 c.membros = vec![Membro {
4936 caixa: "service-a".into(),
4937 versao: "^0.1".into(),
4938 }];
4939 c.placement = Some(Placement {
4940 estrategia: PlacementStrategy::Replicated,
4941 clusters: vec!["rio".into()],
4942 affinity: None,
4943 shard_key: None,
4944 });
4945 c.estrategia = Some(RestartStrategy::OneForAll);
4946 let err = layout.verify(&c, &root).unwrap_err();
4947 match err {
4948 LayoutError::SupervisorSlotsOnNonSupervisor { caixa, kind, slots } => {
4949 assert_eq!(caixa, "demo");
4950 assert_eq!(kind, CaixaKind::Aplicacao);
4951 assert_eq!(slots, crate::render::SUPERVISOR_AUTHOR_KEY_ESTRATEGIA);
4952 }
4953 other => panic!("expected SupervisorSlotsOnNonSupervisor, got {other:?}"),
4954 }
4955 }
4956
4957 #[test]
4958 fn servico_without_supervisor_slots_still_verifies() {
4959 // Pass-after control: a well-formed Servico carrying no
4960 // supervisor slots must remain accepted — the gate keys off
4961 // declared-ness, so it must not over-fire on the common case.
4962 let root = PathBuf::from("/tmp/x");
4963 let servico = root.join("servicos/demo.computeunit.yaml");
4964 let manifest = root.join("caixa.lisp");
4965 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == servico);
4966 let mut c = caixa(CaixaKind::Servico);
4967 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
4968 layout.verify(&c, &root).unwrap();
4969 }
4970
4971 #[test]
4972 fn servico_slots_on_biblioteca_rejected() {
4973 // Mirror of `mesh_slots_on_servico_rejected` /
4974 // `supervisor_slots_on_servico_rejected` on the M2
4975 // Servico-runtime slot set: an author adds `:limits` to a
4976 // `:kind Biblioteca` expecting per-process sandboxing. The
4977 // caixa-helm / caixa-flux renderers gate on `require_kind(_,
4978 // Servico)`, so the slot is the manifest's "ignored otherwise" —
4979 // never rendered into any artifact. The kind-coherence gate
4980 // rejects it at build time (before the M2 validate blocks),
4981 // naming the offending slot + kind.
4982 use crate::LimitsSpec;
4983 let root = PathBuf::from("/tmp/x");
4984 let manifest = root.join("caixa.lisp");
4985 let lib = root.join("lib").join("demo.lisp");
4986 let manifest_clone = manifest.clone();
4987 let layout =
4988 StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == lib);
4989 let mut c = caixa(CaixaKind::Biblioteca);
4990 c.limits = Some(LimitsSpec {
4991 fuel: Some(1_000_000),
4992 ..Default::default()
4993 });
4994 let err = layout.verify(&c, &root).unwrap_err();
4995 match err {
4996 LayoutError::ServicoSlotsOnNonServico { caixa, kind, slots } => {
4997 assert_eq!(caixa, "demo");
4998 assert_eq!(kind, CaixaKind::Biblioteca);
4999 assert_eq!(slots, crate::render::M2_AUTHOR_KEY_LIMITS);
5000 }
5001 other => panic!("expected ServicoSlotsOnNonServico, got {other:?}"),
5002 }
5003 }
5004
5005 #[test]
5006 fn servico_slots_on_non_servico_lists_slots_in_canonical_order() {
5007 // All three M2 slots declared on a Biblioteca → the diagnostic
5008 // enumerates them in canonical declaration order (`:limits` →
5009 // `:behavior` → `:upgrade-from`), deterministic across runs. The
5010 // gate fires on declared-ness only (the values need not pass the
5011 // M2 validate blocks — those run only after the kind-coherence
5012 // gate, and never for a non-Servico declared-slot caixa). Mirror
5013 // of the mesh/supervisor `*_lists_slots_in_canonical_order` pins.
5014 use crate::{BehaviorSpec, LimitsSpec, UpgradeFromEntry, UpgradeInstruction};
5015 let root = PathBuf::from("/tmp/x");
5016 let manifest = root.join("caixa.lisp");
5017 let manifest_clone = manifest.clone();
5018 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5019 let mut c = caixa(CaixaKind::Biblioteca);
5020 c.limits = Some(LimitsSpec {
5021 fuel: Some(1_000_000),
5022 ..Default::default()
5023 });
5024 c.behavior = Some(BehaviorSpec {
5025 on_init: Some(PathBuf::from("lib/init.lisp")),
5026 ..Default::default()
5027 });
5028 c.upgrade_from = vec![UpgradeFromEntry {
5029 from: "0.1.0".into(),
5030 instructions: vec![UpgradeInstruction::Restart],
5031 }];
5032 let err = layout.verify(&c, &root).unwrap_err();
5033 match err {
5034 LayoutError::ServicoSlotsOnNonServico { slots, .. } => {
5035 assert_eq!(
5036 slots,
5037 format!(
5038 "{} {} {}",
5039 crate::render::M2_AUTHOR_KEY_LIMITS,
5040 crate::render::M2_AUTHOR_KEY_BEHAVIOR,
5041 crate::render::M2_AUTHOR_KEY_UPGRADE_FROM,
5042 )
5043 );
5044 }
5045 other => panic!("expected ServicoSlotsOnNonServico, got {other:?}"),
5046 }
5047 }
5048
5049 #[test]
5050 fn aplicacao_with_servico_slots_rejected() {
5051 // Cross-kind pin (mirror of `aplicacao_with_supervisor_slots_rejected`):
5052 // an Aplicacao that declares an M2 Servico-runtime slot is
5053 // rejected by the Servico-slot gate, just as a Supervisor
5054 // declaring a mesh slot is rejected by the mesh-slot gate — the
5055 // three kind ↔ slot coherence gates are symmetric and mutually
5056 // exclusive. The gate fires before the Aplicacao typed-graph
5057 // validation, so the diagnostic names the foreign M2 slot rather
5058 // than a downstream AplicacaoViolation about missing :membros.
5059 use crate::{Membro, Placement, PlacementStrategy, UpgradeFromEntry, UpgradeInstruction};
5060 let root = PathBuf::from("/tmp/x");
5061 let manifest = root.join("caixa.lisp");
5062 let manifest_clone = manifest.clone();
5063 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5064 let mut c = caixa(CaixaKind::Aplicacao);
5065 c.membros = vec![Membro {
5066 caixa: "service-a".into(),
5067 versao: "^0.1".into(),
5068 }];
5069 c.placement = Some(Placement {
5070 estrategia: PlacementStrategy::Replicated,
5071 clusters: vec!["rio".into()],
5072 affinity: None,
5073 shard_key: None,
5074 });
5075 c.upgrade_from = vec![UpgradeFromEntry {
5076 from: "0.1.0".into(),
5077 instructions: vec![UpgradeInstruction::Restart],
5078 }];
5079 let err = layout.verify(&c, &root).unwrap_err();
5080 match err {
5081 LayoutError::ServicoSlotsOnNonServico { caixa, kind, slots } => {
5082 assert_eq!(caixa, "demo");
5083 assert_eq!(kind, CaixaKind::Aplicacao);
5084 assert_eq!(slots, crate::render::M2_AUTHOR_KEY_UPGRADE_FROM);
5085 }
5086 other => panic!("expected ServicoSlotsOnNonServico, got {other:?}"),
5087 }
5088 }
5089
5090 #[test]
5091 fn servico_with_servico_slots_still_verifies() {
5092 // Pass-after control: a well-formed Servico carrying all three M2
5093 // slots must remain accepted — the gate is guarded by `kind !=
5094 // Servico`, so it must not over-fire on the kind these slots
5095 // exist for. Mirror of `servico_without_{mesh,supervisor}_slots_
5096 // still_verifies` on the legitimate-declaration axis.
5097 use crate::{BehaviorSpec, LimitsSpec, UpgradeFromEntry, UpgradeInstruction};
5098 let root = PathBuf::from("/tmp/x");
5099 let manifest = root.join("caixa.lisp");
5100 let svc = root.join("servicos/demo.computeunit.yaml");
5101 let init = root.join("lib/init.lisp");
5102 let layout =
5103 StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc || p == init);
5104 let mut c = caixa(CaixaKind::Servico);
5105 c.versao = "0.2.0".into();
5106 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5107 c.limits = Some(LimitsSpec {
5108 fuel: Some(1_000_000),
5109 ..Default::default()
5110 });
5111 c.behavior = Some(BehaviorSpec {
5112 on_init: Some(PathBuf::from("lib/init.lisp")),
5113 ..Default::default()
5114 });
5115 c.upgrade_from = vec![UpgradeFromEntry {
5116 from: "0.1.0".into(),
5117 instructions: vec![UpgradeInstruction::Restart],
5118 }];
5119 layout.verify(&c, &root).unwrap();
5120 }
5121
5122 #[test]
5123 fn aplicacao_must_not_have_bibliotecas() {
5124 use crate::{Membro, Placement, PlacementStrategy};
5125 let root = PathBuf::from("/tmp/x");
5126 let manifest = root.join("caixa.lisp");
5127 let manifest_clone = manifest.clone();
5128 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5129 let mut c = caixa(CaixaKind::Aplicacao);
5130 c.bibliotecas = vec!["lib/code.lisp".into()];
5131 c.membros = vec![Membro {
5132 caixa: "x".into(),
5133 versao: "^0.1".into(),
5134 }];
5135 c.placement = Some(Placement {
5136 estrategia: PlacementStrategy::Replicated,
5137 clusters: vec!["rio".into()],
5138 affinity: None,
5139 shard_key: None,
5140 });
5141 let err = layout.verify(&c, &root).unwrap_err();
5142 assert!(matches!(err, LayoutError::AplicacaoOwnsCode(_)));
5143 }
5144
5145 #[test]
5146 fn acao_must_not_have_bibliotecas() {
5147 // Mirror of `supervisor_must_not_have_bibliotecas` /
5148 // `aplicacao_must_not_have_bibliotecas` on the third no-code
5149 // kind. `has_code` fires before the `:ci`-presence gates below
5150 // it, so this must surface `AcaoOwnsCode` even though the
5151 // caixa also lacks a `:ci` slot (which would otherwise surface
5152 // as `MissingCi`) — the more-fundamental "this kind runs no
5153 // code at all" diagnostic wins.
5154 let root = PathBuf::from("/tmp/x");
5155 let manifest = root.join("caixa.lisp");
5156 let manifest_clone = manifest.clone();
5157 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5158 let mut c = caixa(CaixaKind::Acao);
5159 c.bibliotecas = vec!["lib/code.lisp".into()];
5160 let err = layout.verify(&c, &root).unwrap_err();
5161 assert!(matches!(err, LayoutError::AcaoOwnsCode(_)));
5162 }
5163
5164 #[test]
5165 fn acao_without_ci_errors() {
5166 // Mirror of `binario_without_exe_errors` on the fifth required-
5167 // slot axis.
5168 let root = PathBuf::from("/tmp/x");
5169 let manifest = root.join("caixa.lisp");
5170 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
5171 let err = layout.verify(&caixa(CaixaKind::Acao), &root).unwrap_err();
5172 assert!(matches!(err, LayoutError::MissingCi(_)));
5173 }
5174
5175 #[test]
5176 fn acao_with_ci_passes() {
5177 let root = PathBuf::from("/tmp/x");
5178 let manifest = root.join("caixa.lisp");
5179 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest);
5180 let mut c = caixa(CaixaKind::Acao);
5181 c.ci = Some(canteiro_types::CiRun {
5182 workspace: "pleme-io".into(),
5183 repo: "caixa".into(),
5184 nodes: vec![],
5185 });
5186 layout
5187 .verify(&c, &root)
5188 .expect("an Acao caixa with a declared :ci slot passes layout verify");
5189 }
5190
5191 #[test]
5192 fn ci_on_non_acao_errors() {
5193 // Mirror of `mesh_slots_on_non_aplicacao_lists_slots_in_canonical_order`
5194 // on the Acao-only `:ci` axis — declaring `:ci` on any other
5195 // kind is the same "silently ignored" footgun the sibling
5196 // mesh-/supervisor-/servico-slot gates already close.
5197 let root = PathBuf::from("/tmp/x");
5198 let manifest = root.join("caixa.lisp");
5199 let manifest_clone = manifest.clone();
5200 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5201 let mut c = caixa(CaixaKind::Biblioteca);
5202 c.ci = Some(canteiro_types::CiRun {
5203 workspace: "pleme-io".into(),
5204 repo: "caixa".into(),
5205 nodes: vec![],
5206 });
5207 let err = layout.verify(&c, &root).unwrap_err();
5208 match err {
5209 LayoutError::CiOnNonAcao { caixa, kind } => {
5210 assert_eq!(caixa, "demo");
5211 assert_eq!(kind, CaixaKind::Biblioteca);
5212 }
5213 other => panic!("expected CiOnNonAcao, got {other:?}"),
5214 }
5215 }
5216
5217 // ── ForeignCodeSlot — kind ↔ code-surface coherence ────────────────
5218
5219 #[test]
5220 fn biblioteca_with_exe_rejected() {
5221 // Fail-before-pass-after pin: a `:kind Biblioteca` declaring
5222 // `:exe` is the "I added a CLI to my library" footgun — the nix
5223 // flake renderer for Binario gates on `require_kind(_, Binario)`,
5224 // so on a Biblioteca the `:exe` path is silently dropped past
5225 // the layout's path-existence check (no executable target is
5226 // ever generated). The diagnostic names the offending kind +
5227 // slot verbatim so the author can grep their caixa.lisp for
5228 // `:exe` and fix in one edit (drop the slot or change
5229 // `:kind Biblioteca` → `:kind Binario`).
5230 let root = PathBuf::from("/tmp/x");
5231 let manifest = root.join("caixa.lisp");
5232 let lib = root.join("lib").join("demo.lisp");
5233 let exe_path = root.join("exe").join("tool");
5234 let layout = StandardLayout::new()
5235 .with_path_exists(move |p| p == manifest || p == lib || p == exe_path);
5236 let mut c = caixa(CaixaKind::Biblioteca);
5237 c.exe = vec!["exe/tool".into()];
5238 let err = layout.verify(&c, &root).unwrap_err();
5239 match err {
5240 LayoutError::ForeignCodeSlot { caixa, kind, slots } => {
5241 assert_eq!(caixa, "demo");
5242 assert_eq!(kind, CaixaKind::Biblioteca);
5243 assert_eq!(slots, ":exe");
5244 }
5245 other => panic!("expected ForeignCodeSlot, got {other:?}"),
5246 }
5247 }
5248
5249 #[test]
5250 fn biblioteca_with_servicos_rejected() {
5251 // Symmetric to `biblioteca_with_exe_rejected` on the
5252 // `:servicos` axis: a `:kind Biblioteca` declaring a Servico
5253 // computeunit silently passed validate and the daemon's
5254 // ComputeUnit / lareira chart never materialized (caixa-helm /
5255 // caixa-flux gate emission on `require_kind(_, Servico)`).
5256 let root = PathBuf::from("/tmp/x");
5257 let manifest = root.join("caixa.lisp");
5258 let lib = root.join("lib").join("demo.lisp");
5259 let svc = root.join("servicos").join("demo.computeunit.yaml");
5260 let layout =
5261 StandardLayout::new().with_path_exists(move |p| p == manifest || p == lib || p == svc);
5262 let mut c = caixa(CaixaKind::Biblioteca);
5263 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5264 let err = layout.verify(&c, &root).unwrap_err();
5265 match err {
5266 LayoutError::ForeignCodeSlot { caixa, kind, slots } => {
5267 assert_eq!(caixa, "demo");
5268 assert_eq!(kind, CaixaKind::Biblioteca);
5269 assert_eq!(slots, ":servicos");
5270 }
5271 other => panic!("expected ForeignCodeSlot, got {other:?}"),
5272 }
5273 }
5274
5275 #[test]
5276 fn biblioteca_with_exe_and_servicos_lists_slots_in_canonical_order() {
5277 // Both foreign code slots declared on a Biblioteca → the
5278 // diagnostic enumerates them in canonical declaration order
5279 // (`:exe` → `:servicos`), deterministic across runs. Mirrors the
5280 // mesh/supervisor/servico-slot `*_lists_slots_in_canonical_order`
5281 // pins on the peer kind ↔ slot algebra axes; drift in the
5282 // [`Caixa::declared_foreign_code_slots`] iteration order surfaces
5283 // here.
5284 let root = PathBuf::from("/tmp/x");
5285 let manifest = root.join("caixa.lisp");
5286 let manifest_clone = manifest.clone();
5287 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5288 let mut c = caixa(CaixaKind::Biblioteca);
5289 c.exe = vec!["exe/tool".into()];
5290 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5291 let err = layout.verify(&c, &root).unwrap_err();
5292 match err {
5293 LayoutError::ForeignCodeSlot { slots, .. } => {
5294 assert_eq!(slots, ":exe :servicos");
5295 }
5296 other => panic!("expected ForeignCodeSlot, got {other:?}"),
5297 }
5298 }
5299
5300 #[test]
5301 fn binario_with_servicos_rejected() {
5302 // The peer footgun on the Binario kind: declaring a Servico
5303 // computeunit on a `:kind Binario` caixa. The caixa-helm /
5304 // caixa-flux renderers gate on `require_kind(_, Servico)`, so
5305 // the `:servicos` slot vanishes past the layout's path-
5306 // existence check — no ComputeUnit, no Helm chart. `:exe` stays
5307 // valid (Binario's native code surface), so the kind-coherence
5308 // diagnostic targets only `:servicos`.
5309 let root = PathBuf::from("/tmp/x");
5310 let manifest = root.join("caixa.lisp");
5311 let exe_path = root.join("exe").join("tool");
5312 let svc = root.join("servicos").join("demo.computeunit.yaml");
5313 let layout = StandardLayout::new()
5314 .with_path_exists(move |p| p == manifest || p == exe_path || p == svc);
5315 let mut c = caixa(CaixaKind::Binario);
5316 c.exe = vec!["exe/tool".into()];
5317 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5318 let err = layout.verify(&c, &root).unwrap_err();
5319 match err {
5320 LayoutError::ForeignCodeSlot { caixa, kind, slots } => {
5321 assert_eq!(caixa, "demo");
5322 assert_eq!(kind, CaixaKind::Binario);
5323 assert_eq!(slots, ":servicos");
5324 }
5325 other => panic!("expected ForeignCodeSlot, got {other:?}"),
5326 }
5327 }
5328
5329 #[test]
5330 fn servico_with_exe_rejected() {
5331 // Symmetric to `binario_with_servicos_rejected` on the other
5332 // code-running peer: a `:kind Servico` declaring an `:exe` is
5333 // the "I added a host-side CLI to my wasm component" footgun —
5334 // the nix flake's Binario target gates on `require_kind(_,
5335 // Binario)`, so the `:exe` path vanishes past the layout's
5336 // path-existence check.
5337 let root = PathBuf::from("/tmp/x");
5338 let manifest = root.join("caixa.lisp");
5339 let svc = root.join("servicos").join("demo.computeunit.yaml");
5340 let exe_path = root.join("exe").join("tool");
5341 let layout = StandardLayout::new()
5342 .with_path_exists(move |p| p == manifest || p == svc || p == exe_path);
5343 let mut c = caixa(CaixaKind::Servico);
5344 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5345 c.exe = vec!["exe/tool".into()];
5346 let err = layout.verify(&c, &root).unwrap_err();
5347 match err {
5348 LayoutError::ForeignCodeSlot { caixa, kind, slots } => {
5349 assert_eq!(caixa, "demo");
5350 assert_eq!(kind, CaixaKind::Servico);
5351 assert_eq!(slots, ":exe");
5352 }
5353 other => panic!("expected ForeignCodeSlot, got {other:?}"),
5354 }
5355 }
5356
5357 #[test]
5358 fn binario_without_servicos_still_verifies() {
5359 // Pass-after control: a well-formed Binario carrying only its
5360 // native `:exe` surface must remain accepted — the gate keys off
5361 // declared-ness of the *foreign* slots, so it must not over-fire
5362 // on the legitimate same-kind case. Mirror of
5363 // `servico_with_servico_slots_still_verifies` on the peer axis.
5364 let root = PathBuf::from("/tmp/x");
5365 let manifest = root.join("caixa.lisp");
5366 let exe_path = root.join("exe").join("tool");
5367 let layout =
5368 StandardLayout::new().with_path_exists(move |p| p == manifest || p == exe_path);
5369 let mut c = caixa(CaixaKind::Binario);
5370 c.exe = vec!["exe/tool".into()];
5371 layout.verify(&c, &root).unwrap();
5372 }
5373
5374 #[test]
5375 fn biblioteca_with_only_bibliotecas_still_verifies() {
5376 // Pass-after control: a well-formed Biblioteca carrying only
5377 // its native `:bibliotecas` surface (or the default
5378 // `lib/<nome>.lisp`) must remain accepted. The gate keys off
5379 // declared-ness of `:exe` + `:servicos` only — `:bibliotecas`
5380 // is deliberately excluded from the foreign-set on every
5381 // code-running kind (`declared_foreign_code_slots` doc), so a
5382 // Biblioteca with the canonical lib surface alone passes.
5383 let root = PathBuf::from("/tmp/x");
5384 let manifest = root.join("caixa.lisp");
5385 let lib = root.join("lib").join("demo.lisp");
5386 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == lib);
5387 layout
5388 .verify(&caixa(CaixaKind::Biblioteca), &root)
5389 .expect("Biblioteca with default lib must verify");
5390 }
5391
5392 #[test]
5393 fn binario_with_bibliotecas_helper_still_verifies() {
5394 // Pass-after control on the deliberate `:bibliotecas`-as-helper
5395 // shape: a `:kind Binario` may legitimately bundle a `lib/`
5396 // helper its nix flake build consumes (the same shape a
5397 // `:kind Servico` may bundle for its wasm-component source).
5398 // The foreign-code-slot gate must NOT fire on `:bibliotecas` for
5399 // either code-running kind; pinned here so a future tightening
5400 // that adds `:bibliotecas` to the foreign set on Binario /
5401 // Servico surfaces as a test failure rather than as a silent
5402 // over-reach.
5403 let root = PathBuf::from("/tmp/x");
5404 let manifest = root.join("caixa.lisp");
5405 let exe_path = root.join("exe").join("tool");
5406 let lib = root.join("lib").join("helper.lisp");
5407 let layout = StandardLayout::new()
5408 .with_path_exists(move |p| p == manifest || p == exe_path || p == lib);
5409 let mut c = caixa(CaixaKind::Binario);
5410 c.exe = vec!["exe/tool".into()];
5411 c.bibliotecas = vec!["lib/helper.lisp".into()];
5412 layout.verify(&c, &root).unwrap();
5413 }
5414
5415 #[test]
5416 fn supervisor_with_exe_still_surfaces_owns_code() {
5417 // Diagnostic-precedence pin: a `:kind Supervisor` declaring
5418 // `:exe` is *both* "Supervisor with code" and "foreign code
5419 // slot". The more-fundamental `SupervisorOwnsCode` must win
5420 // (Supervisor doesn't run code at all — the foreign-slot
5421 // diagnostic would mislead the author toward changing `:kind`
5422 // when the underlying defect is that supervisors orchestrate
5423 // children, not code). Guards the call order in `verify`
5424 // against silent reordering.
5425 use crate::{ChildSpec, RestartPolicy, RestartStrategy};
5426 let root = PathBuf::from("/tmp/x");
5427 let manifest = root.join("caixa.lisp");
5428 let manifest_clone = manifest.clone();
5429 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5430 let mut c = caixa(CaixaKind::Supervisor);
5431 c.estrategia = Some(RestartStrategy::OneForOne);
5432 c.max_restarts = Some(5);
5433 c.exe = vec!["exe/tool".into()];
5434 c.children = vec![ChildSpec {
5435 caixa: "worker".into(),
5436 versao: "^0.1".into(),
5437 restart: RestartPolicy::Permanent,
5438 }];
5439 let err = layout.verify(&c, &root).unwrap_err();
5440 assert!(
5441 matches!(err, LayoutError::SupervisorOwnsCode(_)),
5442 "Supervisor-with-:exe must surface as SupervisorOwnsCode (the more-fundamental \
5443 no-code-at-all diagnostic), got {err:?}"
5444 );
5445 }
5446
5447 #[test]
5448 fn declared_foreign_code_slots_returns_canonical_order() {
5449 // Unit-level pin for the lifted method: the canonical iteration
5450 // order is `:exe` → `:servicos`, independent of which subset is
5451 // populated. Empty input + each single-slot subset + the full
5452 // pair are all checked so a future axis added to the method
5453 // (a hypothetical fifth code-surface slot) is one extension
5454 // point + one assertion update here, not a coordinated rewrite
5455 // across the layout-test sites that reach for the canonical
5456 // order.
5457 let mut c = caixa(CaixaKind::Biblioteca);
5458 assert!(c.declared_foreign_code_slots().is_empty());
5459 c.exe = vec!["exe/tool".into()];
5460 assert_eq!(c.declared_foreign_code_slots(), vec![":exe"]);
5461 c.exe.clear();
5462 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5463 assert_eq!(c.declared_foreign_code_slots(), vec![":servicos"]);
5464 c.exe = vec!["exe/tool".into()];
5465 assert_eq!(c.declared_foreign_code_slots(), vec![":exe", ":servicos"]);
5466 }
5467
5468 #[test]
5469 fn aplicacao_with_unknown_contrato_member_fails() {
5470 use crate::{Membro, Placement, PlacementStrategy, WitContract};
5471 let root = PathBuf::from("/tmp/x");
5472 let manifest = root.join("caixa.lisp");
5473 let manifest_clone = manifest.clone();
5474 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5475 let mut c = caixa(CaixaKind::Aplicacao);
5476 c.membros = vec![Membro {
5477 caixa: "service-a".into(),
5478 versao: "^0.1".into(),
5479 }];
5480 c.contratos = vec![WitContract {
5481 de: "service-a".into(),
5482 para: "phantom".into(),
5483 wit: "wasi:http/proxy".into(),
5484 endpoint: Some("/x".into()),
5485 subject: None,
5486 slot: None,
5487 }];
5488 c.placement = Some(Placement {
5489 estrategia: PlacementStrategy::Replicated,
5490 clusters: vec!["rio".into()],
5491 affinity: None,
5492 shard_key: None,
5493 });
5494 let err = layout.verify(&c, &root).unwrap_err();
5495 assert!(matches!(err, LayoutError::AplicacaoViolation { .. }));
5496 }
5497
5498 #[test]
5499 fn limits_zero_axis_surfaces_as_layout_violation() {
5500 use crate::LimitsSpec;
5501 let root = PathBuf::from("/tmp/x");
5502 let manifest = root.join("caixa.lisp");
5503 let svc = root.join("servicos/demo.computeunit.yaml");
5504 let mut c = caixa(CaixaKind::Servico);
5505 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5506 c.limits = Some(LimitsSpec {
5507 fuel: Some(0),
5508 ..Default::default()
5509 });
5510 let manifest_clone = manifest.clone();
5511 let svc_clone = svc.clone();
5512 let layout =
5513 StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == svc_clone);
5514 let err = layout.verify(&c, &root).unwrap_err();
5515 let LayoutError::LimitsViolation { caixa, issue } = err else {
5516 panic!("expected LimitsViolation, got {err:?}");
5517 };
5518 assert_eq!(caixa, "demo");
5519 assert!(issue.contains(":fuel"), "issue must name the axis: {issue}");
5520 }
5521
5522 #[test]
5523 fn limits_well_formed_passes_layout() {
5524 use crate::LimitsSpec;
5525 use std::time::Duration;
5526 let root = PathBuf::from("/tmp/x");
5527 let manifest = root.join("caixa.lisp");
5528 let svc = root.join("servicos/demo.computeunit.yaml");
5529 let mut c = caixa(CaixaKind::Servico);
5530 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5531 c.limits = Some(LimitsSpec {
5532 memory: Some(64 * 1024 * 1024),
5533 fuel: Some(1_000_000),
5534 wall_clock: Some(Duration::from_secs(30)),
5535 cpu: Some(500),
5536 });
5537 let manifest_clone = manifest.clone();
5538 let svc_clone = svc.clone();
5539 let layout =
5540 StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == svc_clone);
5541 layout.verify(&c, &root).unwrap();
5542 }
5543
5544 #[test]
5545 fn supervisor_with_valid_children_passes() {
5546 use crate::{ChildSpec, RestartPolicy, RestartStrategy};
5547 let root = PathBuf::from("/tmp/x");
5548 let manifest = root.join("caixa.lisp");
5549 let manifest_clone = manifest.clone();
5550 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_clone);
5551 let mut c = caixa(CaixaKind::Supervisor);
5552 c.estrategia = Some(RestartStrategy::OneForOne);
5553 c.max_restarts = Some(5);
5554 c.children = vec![
5555 ChildSpec {
5556 caixa: "worker".into(),
5557 versao: "^0.1".into(),
5558 restart: RestartPolicy::Permanent,
5559 },
5560 ChildSpec {
5561 caixa: "cache".into(),
5562 versao: "^0.1".into(),
5563 restart: RestartPolicy::Transient,
5564 },
5565 ];
5566 layout.verify(&c, &root).unwrap();
5567 }
5568
5569 // ── :upgrade-from entry validation pipes through layout ─────────────
5570
5571 #[test]
5572 fn upgrade_invalid_module_surfaces_as_layout_violation() {
5573 // End-to-end pin that
5574 // [`crate::UpgradeFromEntry::validate`] runs *inside*
5575 // `LayoutInvariants::verify` and surfaces value-shape
5576 // violations through the new `UpgradeViolation` arm
5577 // (parallel to `BehaviorViolation`, `LimitsViolation`,
5578 // `SupervisorViolation`, `AplicacaoViolation`). Until this
5579 // wiring landed the entry validator was unreachable from any
5580 // build-pipeline caller — an `:upgrade-from
5581 // ((:from "0.1.0" :instructions ((:load-module "Hello")))` (uppercase
5582 // module name the K8s apiserver would reject on the per-
5583 // ComputeUnit `metadata.name` axis) silently passed
5584 // `feira lint` / `feira build` and surfaced only at wasm-engine
5585 // hot-upgrade time as a per-backend "module not found" /
5586 // `code:load_module/1` `badarg` runtime error, far from the
5587 // source caixa.lisp. Pinning the wiring here so a future
5588 // refactor that drops the `entry.validate()` call surfaces as
5589 // a build-pipeline regression at this test, not as a runtime
5590 // surprise per consumer.
5591 use crate::{UpgradeFromEntry, UpgradeInstruction};
5592 use std::path::PathBuf;
5593 let root = PathBuf::from("/tmp/x");
5594 let manifest = root.join("caixa.lisp");
5595 let svc = root.join("servicos/demo.computeunit.yaml");
5596 let mut c = caixa(CaixaKind::Servico);
5597 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5598 c.upgrade_from = vec![UpgradeFromEntry {
5599 from: "0.1.0".into(),
5600 instructions: vec![UpgradeInstruction::LoadModule {
5601 module: "Hello".into(), // uppercase — not DNS-1123
5602 }],
5603 }];
5604 let manifest_clone = manifest.clone();
5605 let svc_clone = svc.clone();
5606 let layout =
5607 StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == svc_clone);
5608 let err = layout.verify(&c, &root).unwrap_err();
5609 let LayoutError::UpgradeViolation { caixa, issue } = err else {
5610 panic!("expected UpgradeViolation, got {err:?}");
5611 };
5612 assert_eq!(caixa, "demo");
5613 assert!(
5614 issue.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE),
5615 "issue must name the lisp-form of the offending instruction: {issue}"
5616 );
5617 assert!(
5618 issue.contains("Hello"),
5619 "issue must name the offending :module verbatim: {issue}"
5620 );
5621 }
5622
5623 #[test]
5624 fn upgrade_empty_module_surfaces_as_layout_violation() {
5625 // Companion to the DNS-1123 footgun above on the narrower
5626 // empty arm. Every Module-bearing variant's empty value
5627 // reaches the layout pipeline through the kind-tagged
5628 // `ModuleEmpty` diagnostic naming its lisp-form.
5629 use crate::{UpgradeFromEntry, UpgradeInstruction};
5630 use std::path::PathBuf;
5631 let root = PathBuf::from("/tmp/x");
5632 let manifest = root.join("caixa.lisp");
5633 let svc = root.join("servicos/demo.computeunit.yaml");
5634 let mut c = caixa(CaixaKind::Servico);
5635 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5636 c.upgrade_from = vec![UpgradeFromEntry {
5637 from: "0.1.0".into(),
5638 instructions: vec![UpgradeInstruction::SoftPurge {
5639 module: String::new(),
5640 }],
5641 }];
5642 let manifest_clone = manifest.clone();
5643 let svc_clone = svc.clone();
5644 let layout =
5645 StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == svc_clone);
5646 let err = layout.verify(&c, &root).unwrap_err();
5647 let LayoutError::UpgradeViolation { caixa, issue } = err else {
5648 panic!("expected UpgradeViolation, got {err:?}");
5649 };
5650 assert_eq!(caixa, "demo");
5651 assert!(
5652 issue.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_SOFT_PURGE),
5653 "issue must name the lisp-form of the empty instruction: {issue}"
5654 );
5655 }
5656
5657 #[test]
5658 fn upgrade_invalid_state_change_script_surfaces_as_layout_violation() {
5659 // Pins that the b0c8389 script value-shape gates
5660 // (AbsoluteScript / ParentEscapeScript) — previously
5661 // unreachable from any build-pipeline caller — now fire
5662 // through the same `UpgradeViolation` arm before the path-
5663 // existence pass would otherwise emit the less-helpful
5664 // "missing upgrade-script" (or, worse, *succeed* against
5665 // /etc/passwd, proving the sandbox bypass — same defect
5666 // the b0c8389 BehaviorSpec wiring closed on the peer M2
5667 // slot).
5668 use crate::{UpgradeFromEntry, UpgradeInstruction};
5669 use std::path::PathBuf;
5670 let root = PathBuf::from("/tmp/x");
5671 let manifest = root.join("caixa.lisp");
5672 let svc = root.join("servicos/demo.computeunit.yaml");
5673 let etc_passwd = PathBuf::from("/etc/passwd");
5674 let mut c = caixa(CaixaKind::Servico);
5675 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5676 c.upgrade_from = vec![UpgradeFromEntry {
5677 from: "0.1.0".into(),
5678 instructions: vec![UpgradeInstruction::StateChange {
5679 script: PathBuf::from("/etc/passwd"),
5680 }],
5681 }];
5682 let manifest_clone = manifest.clone();
5683 let svc_clone = svc.clone();
5684 let etc_passwd_clone = etc_passwd.clone();
5685 // Critically: /etc/passwd "exists" in our mock — without the
5686 // value-shape pre-check, the existence loop would *succeed*
5687 // and the path-traversal exit from the project sandbox would
5688 // pass `feira build` silently.
5689 let layout = StandardLayout::new().with_path_exists(move |p| {
5690 p == manifest_clone || p == svc_clone || p == etc_passwd_clone
5691 });
5692 let err = layout.verify(&c, &root).unwrap_err();
5693 let LayoutError::UpgradeViolation { caixa, issue } = err else {
5694 panic!("expected UpgradeViolation, got {err:?}");
5695 };
5696 assert_eq!(caixa, "demo");
5697 assert!(
5698 issue.contains("absolute") || issue.contains("Absolute"),
5699 "issue must name the violation kind (absolute): {issue}"
5700 );
5701 }
5702
5703 #[test]
5704 fn upgrade_well_formed_passes_layout() {
5705 // Positive control — every documented authoring shape
5706 // (`:load-module`, `:state-change` with a relative path,
5707 // `:soft-purge`, `:purge`, sole `:restart`) passes the wired
5708 // gate. The typed sequence (`:load-module` → `:state-change`
5709 // → `:soft-purge` → `:purge`) lives in one entry; the sole
5710 // `:restart` fallback lives in a *separate* entry on a
5711 // different `:from` (the within-entry restart-exclusivity
5712 // gate added in this commit rejects mixing the fallback with
5713 // the typed sequence — per the UpgradeInstruction::Restart
5714 // doc, `:restart` is terminal and any other instructions in
5715 // the same entry are dead code). Drift here = a future
5716 // tighten that rejects any canonical shape surfaces as a
5717 // regression at this layout-level pin, not piecemeal across
5718 // per-renderer call sites.
5719 //
5720 // `:soft-purge` and `:purge` target *distinct* old-version
5721 // modules (`hello-rio-old` and `hello-rio-oldest`) so the
5722 // within-entry cleanup-singularity gate
5723 // (`UpgradeError::DuplicateCleanup`) passes — that gate
5724 // rejects more than one cleanup per module per entry (one
5725 // semantic per old version; mixing drain + discard on one
5726 // module is the soft-then-hard fallback footgun the author
5727 // shouldn't write because the operator handles cleanup
5728 // failure escalation itself). The two distinct names cover
5729 // the legitimate "drain a recent old, hard-discard an
5730 // older-still" shape — both authoring forms remain load-
5731 // bearing in this positive-control enumeration.
5732 use crate::{BehaviorSpec, UpgradeFromEntry, UpgradeInstruction};
5733 use std::path::PathBuf;
5734 let root = PathBuf::from("/tmp/x");
5735 let manifest = root.join("caixa.lisp");
5736 let svc = root.join("servicos/demo.computeunit.yaml");
5737 let migration = root.join("lib/migrations/v01-to-v02.lisp");
5738 let on_state_change = root.join("lib/migrations.lisp");
5739 let mut c = caixa(CaixaKind::Servico);
5740 // `:versao` past both entries' `:from` so the cross-slot
5741 // precedence gate (`FromNotBeforeVersao`) lets this canonical
5742 // authoring shape through to the positive-control assertion.
5743 c.versao = "0.2.0".into();
5744 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5745 // `:on-state-change` declared alongside the `(:state-change …)`
5746 // instruction below — the cross-slot composition gate
5747 // (`validate_upgrade_from_against_behavior`) rejects a
5748 // `:state-change` without the callback, so the canonical
5749 // authoring shape this positive control pins now includes the
5750 // runtime delivery hook (the `gen_server:code_change/3` analog
5751 // that the per-version script is invoked through during hot
5752 // upgrade per the upgrade.rs module doc "Composes with"
5753 // promise).
5754 c.behavior = Some(BehaviorSpec {
5755 on_state_change: Some(PathBuf::from("lib/migrations.lisp")),
5756 ..Default::default()
5757 });
5758 c.upgrade_from = vec![
5759 UpgradeFromEntry {
5760 from: "0.1.0".into(),
5761 instructions: vec![
5762 UpgradeInstruction::LoadModule {
5763 module: "hello-rio".into(),
5764 },
5765 UpgradeInstruction::StateChange {
5766 script: PathBuf::from("lib/migrations/v01-to-v02.lisp"),
5767 },
5768 UpgradeInstruction::SoftPurge {
5769 module: "hello-rio-old".into(),
5770 },
5771 UpgradeInstruction::Purge {
5772 module: "hello-rio-oldest".into(),
5773 },
5774 ],
5775 },
5776 UpgradeFromEntry {
5777 from: "0.0.9".into(),
5778 instructions: vec![UpgradeInstruction::Restart],
5779 },
5780 ];
5781 let manifest_clone = manifest.clone();
5782 let svc_clone = svc.clone();
5783 let migration_clone = migration.clone();
5784 let on_state_change_clone = on_state_change.clone();
5785 let layout = StandardLayout::new().with_path_exists(move |p| {
5786 p == manifest_clone
5787 || p == svc_clone
5788 || p == migration_clone
5789 || p == on_state_change_clone
5790 });
5791 layout.verify(&c, &root).unwrap();
5792 }
5793
5794 #[test]
5795 fn upgrade_from_restart_mixed_surfaces_as_upgrade_violation() {
5796 // Wiring pin: the within-entry `(:restart)`-exclusivity gate
5797 // (`UpgradeFromEntry::validate_restart_exclusive`) lands on
5798 // the same `LayoutError::UpgradeViolation` axis the per-entry
5799 // shape gate (26da2c7), the cross-entry duplicate-`:from`
5800 // gate (7c6aef2), and the cross-slot `:from < :versao`
5801 // precedence gate (de7ab1a) already do. A caixa.lisp whose
5802 // `:upgrade-from` entry mixes `(:restart)` with a typed
5803 // instruction surfaces at `feira build` time naming the
5804 // offending caixa + the entry's `:from` rather than silently
5805 // passing into the wasm-operator with semantically dead code
5806 // in the operator's dispatch table. Mirrors
5807 // `upgrade_from_duplicate_surfaces_as_upgrade_violation` on
5808 // the peer cross-entry gate.
5809 use crate::{UpgradeFromEntry, UpgradeInstruction};
5810 let root = PathBuf::from("/tmp/x");
5811 let manifest = root.join("caixa.lisp");
5812 let svc = root.join("servicos/demo.computeunit.yaml");
5813 let mut c = caixa(CaixaKind::Servico);
5814 c.versao = "0.2.0".into();
5815 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5816 c.upgrade_from = vec![UpgradeFromEntry {
5817 from: "0.1.0".into(),
5818 instructions: vec![
5819 UpgradeInstruction::LoadModule {
5820 module: "hello-rio".into(),
5821 },
5822 UpgradeInstruction::Restart,
5823 ],
5824 }];
5825 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc);
5826 let err = layout.verify(&c, &root).unwrap_err();
5827 let LayoutError::UpgradeViolation { caixa, issue } = err else {
5828 panic!("expected LayoutError::UpgradeViolation for restart-mixed entry, got {err:?}");
5829 };
5830 assert_eq!(caixa, "demo");
5831 assert!(
5832 issue.contains("0.1.0"),
5833 "UpgradeViolation issue must name the offending entry's `:from` verbatim, got \
5834 {issue:?}"
5835 );
5836 assert!(
5837 issue.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART),
5838 "UpgradeViolation issue must name the `:restart` axis verbatim, got {issue:?}"
5839 );
5840 assert!(
5841 issue.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_LOAD_MODULE),
5842 "UpgradeViolation issue must name the non-:restart peer instruction's lisp-form \
5843 verbatim, got {issue:?}"
5844 );
5845 }
5846
5847 #[test]
5848 fn upgrade_from_restart_duplicated_surfaces_as_upgrade_violation() {
5849 // Companion arm: the duplicate-`(:restart)` mode of
5850 // `RestartNotExclusive` (no typed peers, just multiple
5851 // `Restart` variants) surfaces through the same wiring as the
5852 // mixed-with-typed mode above. The diagnostic still names the
5853 // offending entry's `:from` verbatim even when `other_kinds`
5854 // is empty.
5855 use crate::{UpgradeFromEntry, UpgradeInstruction};
5856 let root = PathBuf::from("/tmp/x");
5857 let manifest = root.join("caixa.lisp");
5858 let svc = root.join("servicos/demo.computeunit.yaml");
5859 let mut c = caixa(CaixaKind::Servico);
5860 c.versao = "0.2.0".into();
5861 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5862 c.upgrade_from = vec![UpgradeFromEntry {
5863 from: "0.1.0".into(),
5864 instructions: vec![UpgradeInstruction::Restart, UpgradeInstruction::Restart],
5865 }];
5866 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest || p == svc);
5867 let err = layout.verify(&c, &root).unwrap_err();
5868 let LayoutError::UpgradeViolation { caixa, issue } = err else {
5869 panic!(
5870 "expected LayoutError::UpgradeViolation for duplicate-restart entry, got \
5871 {err:?}"
5872 );
5873 };
5874 assert_eq!(caixa, "demo");
5875 assert!(
5876 issue.contains("0.1.0"),
5877 "UpgradeViolation issue must name the offending entry's `:from` verbatim, got \
5878 {issue:?}"
5879 );
5880 assert!(
5881 issue.contains("(:restart)")
5882 || issue.contains(crate::render::M2_UPGRADE_INSTRUCTION_KIND_RESTART),
5883 "UpgradeViolation issue must name the `:restart` axis verbatim, got {issue:?}"
5884 );
5885 }
5886
5887 #[test]
5888 fn upgrade_from_invalid_surfaces_as_layout_violation() {
5889 // The `:from` semver gate (`UpgradeError::FromInvalid`)
5890 // was likewise unreachable before this wiring landed — a
5891 // typo-shaped `:from "v0.1.0"` (git-tag-shape leaking into
5892 // the semver slot) silently passed `feira build` and
5893 // surfaced only when the operator's hot-upgrade decision
5894 // engine tried to match against the version key it couldn't
5895 // parse. Now wired through `UpgradeViolation` with the
5896 // peer-shaped `{ from, reason }` payload — the
5897 // parser-shaped `reason` flows through `Display` so the
5898 // wrapped issue string carries both the offending value
5899 // *and* the SemVer-2 parser's wording (peer with the
5900 // `VersaoInvalid` / `MembroVersaoInvalid` envelopes on the
5901 // sibling SemVer-2 axes).
5902 use crate::{UpgradeFromEntry, UpgradeInstruction};
5903 use std::path::PathBuf;
5904 let root = PathBuf::from("/tmp/x");
5905 let manifest = root.join("caixa.lisp");
5906 let svc = root.join("servicos/demo.computeunit.yaml");
5907 let mut c = caixa(CaixaKind::Servico);
5908 c.servicos = vec!["servicos/demo.computeunit.yaml".into()];
5909 c.upgrade_from = vec![UpgradeFromEntry {
5910 from: "v0.1.0".into(), // git-tag-shape, not semver
5911 instructions: vec![UpgradeInstruction::Restart],
5912 }];
5913 let manifest_clone = manifest.clone();
5914 let svc_clone = svc.clone();
5915 let layout =
5916 StandardLayout::new().with_path_exists(move |p| p == manifest_clone || p == svc_clone);
5917 let err = layout.verify(&c, &root).unwrap_err();
5918 let LayoutError::UpgradeViolation { caixa, issue } = err else {
5919 panic!("expected UpgradeViolation, got {err:?}");
5920 };
5921 assert_eq!(caixa, "demo");
5922 assert!(
5923 issue.contains("v0.1.0"),
5924 "UpgradeViolation issue must name the offending :from value verbatim, got {issue:?}"
5925 );
5926 assert!(
5927 issue.contains(":from"),
5928 "UpgradeViolation issue must name the :from slot verbatim, got {issue:?}"
5929 );
5930 // Pin the parser-shaped reason flow-through: the renamed
5931 // `FromInvalid { from, reason }` carries the SemVer-2 parser's
5932 // wording verbatim, and the [`UpgradeError`] Display routes it
5933 // into the wrapped `issue` string so the layout envelope
5934 // surfaces both the offending value *and* the parser's
5935 // diagnosis. Mirrors the peer flow-through on
5936 // `ManifestError::VersaoInvalid` (top-level `:versao`) and
5937 // `AplicacaoError::MembroVersaoInvalid` (`:membros :versao`).
5938 assert!(
5939 issue.contains("SemVer-2"),
5940 "UpgradeViolation issue must carry the parser-shaped reason (\"SemVer-2\"), got {issue:?}"
5941 );
5942 }
5943
5944 #[test]
5945 fn missing_lib_gate_routes_through_kind_requires_lib_and_caixa_nome() {
5946 // Fail-before-pass-after pin on the two-part converge landed
5947 // at layout.rs:844-847:
5948 // (a) `caixa.kind().is_biblioteca()` →
5949 // `caixa.kind().requires_lib()` — routes the biblioteca
5950 // required-slot gate onto the same `requires_*()`
5951 // predicate family the three sibling required-slot
5952 // gates (`requires_exe()` at :856, `requires_servicos()`
5953 // at :860, `requires_ci()` at :874) already key off.
5954 // All four gates in the block now share one convention;
5955 // a future kind that gains its own required-slot gate
5956 // (an M4/M5 typed arm the CAIXA-SDLC §I six-kind roster
5957 // may grow) reaches for the same predicate family and
5958 // inherits the accessor discipline for free.
5959 // (b) raw `caixa.nome` → `caixa.nome()` — routes the
5960 // `expected` path composition through the typed
5961 // [`crate::Caixa::nome`] accessor, closing the last
5962 // unlifted raw `caixa.nome` production field-access
5963 // site in `caixa-core/src/layout.rs` (every peer
5964 // diagnostic in the file already routes through
5965 // `caixa.nome().to_string()`).
5966 //
5967 // The behavioral pin: for a Biblioteca kind with no fallback
5968 // `lib/<nome>.lisp` file, MissingLib fires and its `expected`
5969 // path composes through `Caixa::nome()`; for every other
5970 // kind, MissingLib does NOT fire (the gate short-circuits on
5971 // kinds where `requires_lib()` returns false), even when the
5972 // fallback file is likewise absent. A future regression that
5973 // reroutes the gate off `requires_lib()` (e.g. onto
5974 // `is_biblioteca()` again, or onto a hand-authored
5975 // `matches!(caixa.kind(), CaixaKind::Biblioteca)`) that
5976 // *happens* to agree byte-for-byte on today's arm-set trips
5977 // this test the moment a future kind's `requires_lib()`
5978 // returns true for a non-`Biblioteca` arm (or the sibling
5979 // required-slot gates diverge from the same convention).
5980 let root = PathBuf::from("/tmp/x");
5981 let manifest = root.join("caixa.lisp");
5982 let manifest_only = manifest.clone();
5983 let layout = StandardLayout::new().with_path_exists(move |p| p == manifest_only);
5984
5985 // Biblioteca kind + no lib fallback → MissingLib fires with
5986 // the expected path composed through `Caixa::nome()`.
5987 let bib = caixa(CaixaKind::Biblioteca);
5988 assert!(
5989 bib.kind().requires_lib(),
5990 "requires_lib() must return true for Biblioteca — the four-required-\
5991 slot-gate family's routing depends on this arm's assignment"
5992 );
5993 let err = layout.verify(&bib, &root).unwrap_err();
5994 let LayoutError::MissingLib {
5995 caixa: cname,
5996 expected,
5997 } = err
5998 else {
5999 panic!("expected MissingLib for Biblioteca kind with no lib fallback, got {err:?}");
6000 };
6001 assert_eq!(
6002 cname,
6003 bib.nome(),
6004 "MissingLib `caixa:` carrier must byte-equal Caixa::nome()"
6005 );
6006 assert_eq!(
6007 expected,
6008 root.join(crate::render::LAYOUT_DIR_LIB)
6009 .join(format!("{}.lisp", bib.nome())),
6010 "MissingLib `expected:` path must compose through Caixa::nome() \
6011 verbatim — a raw-field-access regression would silently drift \
6012 the composed path on any future `:nome` axis extension \
6013 (namespace-qualified rewrite, per-cluster alias overlay)"
6014 );
6015
6016 // Non-Biblioteca kinds → the MissingLib gate short-circuits.
6017 // Different kinds fail on their own required-slot gate
6018 // (BinarioWithoutExe, ServicoWithoutServicos, MissingCi) or
6019 // on downstream M2/M3 invariants; none of them may surface as
6020 // MissingLib, because `requires_lib()` returns false for each.
6021 for kind in [
6022 CaixaKind::Binario,
6023 CaixaKind::Servico,
6024 CaixaKind::Supervisor,
6025 CaixaKind::Aplicacao,
6026 CaixaKind::Acao,
6027 ] {
6028 assert!(
6029 !kind.requires_lib(),
6030 "requires_lib() must return false for {kind:?} — the \
6031 four-required-slot-gate family's arm assignment pins \
6032 exactly one kind (Biblioteca) as the arm that requires \
6033 a `lib/` entry"
6034 );
6035 let c = caixa(kind);
6036 let result = layout.verify(&c, &root);
6037 assert!(
6038 !matches!(result, Err(LayoutError::MissingLib { .. })),
6039 "MissingLib gate at layout.rs:844 must short-circuit for \
6040 kinds where requires_lib() returns false; unexpectedly \
6041 fired for {kind:?}: {result:?}"
6042 );
6043 }
6044 }
6045}