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