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