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