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