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