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