diff --git a/README.md b/README.md index fe98a50..b3cb2fa 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,13 @@ sh ./run.sh ## Supported compose subset -compose2pod supports an honest subset and errors clearly on anything outside it. See the design spec for the full matrix: `image`/`build`, `command`, `environment`/`env_file`, short-form bind `volumes`, `healthcheck` (CMD/CMD-SHELL), `depends_on` (all conditions), and network `aliases`. +compose2pod supports an honest subset and errors clearly on anything outside +it: `image`/`build`, `command`, `environment`/`env_file`, short-form bind +`volumes`, `healthcheck` (CMD/CMD-SHELL), `depends_on` (all conditions), and +network `aliases`. Compose extension fields (any `x-`-prefixed key) and YAML +anchors are accepted as-is, so a top-level `x-*` anchor block for shared +config just works. See `architecture/supported-subset.md` for the full +accept/ignore/reject matrix. ## Status diff --git a/architecture/supported-subset.md b/architecture/supported-subset.md new file mode 100644 index 0000000..63dd67c --- /dev/null +++ b/architecture/supported-subset.md @@ -0,0 +1,56 @@ +# Supported compose subset + +compose2pod converts an honest subset of Docker Compose and refuses the rest +loudly rather than silently dropping behavior. `validate()` +(`compose2pod/parsing.py`) is the gate: anything it does not recognize either +warns (ignored, behavior-neutral inside a single pod) or raises +`UnsupportedComposeError`. + +## Top-level keys + +- **Supported:** `services` (required, non-empty), `version`, `name`, + `networks`. +- **Ignored (warns):** `networks` — all services share the pod's single + network namespace, so top-level network definitions have no effect. +- **Extension fields:** any key prefixed `x-` is accepted and ignored + silently, per the Compose spec. This is what lets a document hold shared + config in a top-level `x-*` block for reuse via YAML anchors. +- Everything else raises. + +## Service keys + +- **Supported:** `image`, `build`, `command`, `environment`, `env_file`, + `volumes`, `healthcheck`, `depends_on`, `networks`. compose2pod never builds: + a `build` section is accepted but its contents (context, dockerfile, args) are + not read — `image_for` (`compose2pod/emit.py`) runs the CI image supplied via + `--image` for any service that has one. +- **Ignored (warns):** `ports`, `restart`, `stdin_open`, `tty` — meaningless + or irrelevant inside a single shared-namespace pod. +- **Extension fields:** any `x-`-prefixed service key is accepted and ignored + silently. +- Everything else raises. + +## Healthcheck keys + +- **Supported:** `test`, `interval`, `timeout`, `retries`, `start_period`. +- **Extension fields:** any `x-`-prefixed healthcheck key is accepted and + ignored silently. +- Everything else raises. + +## Volumes + +Short bind-mount syntax only (`source:target`). The source must be a host path +(starts with `.` or `/`); named volumes and the long mapping form raise. + +## depends_on + +All three conditions are honored: `service_started`, `service_healthy`, +`service_completed_successfully`. A `service_healthy` dependency on a service +with no usable healthcheck raises. + +## YAML anchors and merge keys + +Anchors (`&name` / `*name`) and the merge key (`<<:`) need no handling in +compose2pod: PyYAML's `safe_load` resolves them at load time, so `validate()` +and `emit` see already-merged service mappings. JSON input has no anchors but +can still carry literal `x-` extension keys, handled identically. diff --git a/compose2pod/parsing.py b/compose2pod/parsing.py index 62ecae3..6d63362 100644 --- a/compose2pod/parsing.py +++ b/compose2pod/parsing.py @@ -24,19 +24,18 @@ DEPENDS_ON_CONDITIONS = {"service_started", "service_healthy", "service_completed_successfully"} -def _validate_service(name: str, svc: dict[str, Any]) -> list[str]: - """Validate one service; returns warnings, raises UnsupportedComposeError.""" - warnings: list[str] = [] - for key in sorted(svc): - if key in IGNORED_SERVICE_KEYS: - warnings.append(f"service {name!r}: ignoring '{key}'") - elif key not in SUPPORTED_SERVICE_KEYS: - msg = f"service {name!r}: unsupported key '{key}'" - raise UnsupportedComposeError(msg) +def _validate_service_healthcheck(name: str, svc: dict[str, Any]) -> None: + """Check healthcheck keys against the supported subset, skipping 'x-' extension keys.""" for key in sorted(svc.get("healthcheck") or {}): + if key.startswith("x-"): + continue if key not in SUPPORTED_HEALTHCHECK_KEYS: msg = f"service {name!r}: unsupported healthcheck key '{key}'" raise UnsupportedComposeError(msg) + + +def _validate_service_volumes(name: str, svc: dict[str, Any]) -> None: + """Check volumes use short bind-mount syntax only.""" for volume in svc.get("volumes") or []: if not isinstance(volume, str): msg = f"service {name!r}: only short volume syntax is supported" @@ -45,6 +44,21 @@ def _validate_service(name: str, svc: dict[str, Any]) -> list[str]: if not source.startswith((".", "/")): msg = f"service {name!r}: named volume '{source}' is not supported (bind mounts only)" raise UnsupportedComposeError(msg) + + +def _validate_service(name: str, svc: dict[str, Any]) -> list[str]: + """Validate one service; returns warnings, raises UnsupportedComposeError.""" + warnings: list[str] = [] + for key in sorted(svc): + if key.startswith("x-"): + continue + if key in IGNORED_SERVICE_KEYS: + warnings.append(f"service {name!r}: ignoring '{key}'") + elif key not in SUPPORTED_SERVICE_KEYS: + msg = f"service {name!r}: unsupported key '{key}'" + raise UnsupportedComposeError(msg) + _validate_service_healthcheck(name, svc) + _validate_service_volumes(name, svc) return warnings @@ -70,7 +84,7 @@ def validate(compose: dict[str, Any]) -> list[str]: msg = f"compose document must be a mapping, got {type(compose).__name__}" raise UnsupportedComposeError(msg) warnings: list[str] = [] - unknown_top = set(compose) - SUPPORTED_TOP_LEVEL_KEYS + unknown_top = {k for k in compose if k not in SUPPORTED_TOP_LEVEL_KEYS and not k.startswith("x-")} if unknown_top: msg = f"unsupported top-level keys: {sorted(unknown_top)}" raise UnsupportedComposeError(msg) diff --git a/planning/changes/2026-07-04.01-extension-fields/design.md b/planning/changes/2026-07-04.01-extension-fields/design.md new file mode 100644 index 0000000..1f881f9 --- /dev/null +++ b/planning/changes/2026-07-04.01-extension-fields/design.md @@ -0,0 +1,109 @@ +--- +summary: Accept Compose `x-` extension fields at every validated level, ignoring them silently. +--- + +# Design: Support Compose extension fields (`x-`) + +## Summary + +compose2pod rejects any top-level key it does not explicitly support, so a +document that carries a Compose extension field — e.g. `x-application-defaults` +holding a YAML anchor for reuse — fails with +`unsupported top-level keys: ['x-application-defaults']`. The Compose spec +reserves the `x-` prefix for arbitrary user data that tools must ignore. This +change teaches `validate()` to skip any `x-`-prefixed key at every mapping level +it inspects (top level, service, healthcheck), silently and without warning. +The change is confined to `compose2pod/parsing.py`; a new +`architecture/supported-subset.md` capability file pins down the supported +subset, including this rule. + +## Motivation + +A real-world CI compose file uses the idiomatic anchor pattern: + +```yaml +x-application-defaults: &application-defaults + build: + context: . + dockerfile: ./Dockerfile +services: + application: + <<: *application-defaults + ... +``` + +PyYAML's `safe_load` already resolves the `&anchor` and `<<:` merge key at load +time — after loading, each service correctly contains the merged `build:` block. +The *only* thing that fails is the leftover top-level `x-application-defaults` +key, which `validate()` (`parsing.py:73`) rejects because it is not in +`SUPPORTED_TOP_LEVEL_KEYS`. Verified: once `x-` keys are accepted, the entire +file validates and emits with only the normal `ports`/`restart`/`stdin_open`/ +`tty` "ignoring" warnings. + +Extension fields are a first-class Compose construct, not an edge case: the spec +states any key prefixed with `x-` at any level is a user extension that +consuming tools ignore. Holding YAML anchors in a top-level `x-` block is the +canonical way to share config across services. Rejecting them makes compose2pod +refuse valid, common compose documents. + +## Non-goals + +- No handling of YAML anchors / `<<:` merge keys in code — PyYAML resolves them + at load time, and JSON input has none. +- No interpretation of extension-field *contents*. `x-` values are ignored + wholesale, never read for behavior. +- No change to which real service keys are supported, ignored, or rejected. + +## Design + +### 1. Ignore `x-` keys at every validated level (`parsing.py`) + +The rule is a single predicate — `key.startswith("x-")`, lowercase as the spec +mandates — applied at each of the three places `validate()` inspects keys: + +- **Top level** (`validate`, line 73): exclude `x-` keys from the `unknown_top` + set so `x-application-defaults` and peers no longer raise. The `unknown_top` + computation becomes a comprehension that drops supported keys *and* `x-` keys. +- **Service level** (`_validate_service`, line 30 loop): an `x-`-prefixed service + key is skipped before the ignored/unsupported branches — no warning, no raise. +- **Healthcheck level** (`_validate_service`, line 36 loop): an `x-`-prefixed + healthcheck key is skipped rather than raising `unsupported healthcheck key`. + +Ignoring is **silent**: unlike `ports`/`restart` (which warn because compose2pod +drops real runtime behavior), an `x-` field carries nothing actionable, so a +warning would be noise. This is a deliberate decision (see Testing — a test +guards the no-warning behavior). + +Nothing downstream needs to change: `emit.py` and `graph.py` only ever access +*known* service keys and iterate `compose["services"]`; they never walk service +keys generically nor read top-level keys other than `services`. Skipping `x-` +in `validate()` is therefore sufficient. + +### 2. Seed `architecture/supported-subset.md` + +The parsing subset has no capability file yet. This change creates +`architecture/supported-subset.md` as the living truth for what compose2pod +accepts, ignores, and rejects: the supported top-level keys, the supported / +ignored / rejected service keys, the healthcheck subset, volume constraints, +`depends_on` conditions, and — added here — the `x-` extension-field rule. The +file carries no frontmatter (living prose, dated by git), per the +`architecture/` convention. + +## Testing + +TDD, and `just test-ci` must stay at 100% line coverage. + +- Unit: a top-level `x-foo` key is accepted (`validate` does not raise). +- Unit: a service-level `x-foo` key is accepted **and produces no warning** — + asserts the returned warnings list contains nothing mentioning `x-foo`, + guarding the "silent" decision. +- Unit: a healthcheck-level `x-foo` key is accepted (does not raise). +- Integration: the anchor-based compose document from Motivation round-trips + through `_read_compose` → `validate` → `emit_script` and produces a script. + +## Risk + +Low. The `x-` prefix is spec-reserved and unambiguous, so skipping such keys +cannot mask a real supported/unsupported key. The blast radius is three edits in +one function. The main risk is *over*-narrow matching (e.g. only top-level), +which the spec-faithful "everywhere" scope and the three-level test set avoid. diff --git a/planning/changes/2026-07-04.01-extension-fields/plan.md b/planning/changes/2026-07-04.01-extension-fields/plan.md new file mode 100644 index 0000000..de5c8f1 --- /dev/null +++ b/planning/changes/2026-07-04.01-extension-fields/plan.md @@ -0,0 +1,310 @@ +# extension-fields — implementation plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use +> superpowers:subagent-driven-development (recommended) or +> superpowers:executing-plans to implement this plan task-by-task. Steps +> use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Accept Compose `x-` extension fields at every level `validate()` +inspects, silently, so anchor-based compose files convert instead of erroring. + +**Spec:** [`design.md`](./design.md) + +**Branch:** `feat/extension-fields` (already created; the spec commit lives here). + +**Commit strategy:** Per-task commits, conventional-commit subjects, **no** +`Co-authored-by` trailer (project rule). + +## Global Constraints + +Copied verbatim from `CLAUDE.md` — every task must respect these: + +- Core package has **zero runtime dependencies** (stdlib only); PyYAML is only + the optional `[yaml]` extra. This change adds no imports. +- All imports at module level. Annotate every function argument. +- Use `ty: ignore`, never `type: ignore`. +- `just lint-ci` must pass clean. **Never run bare `ruff check`** — the repo + config autofixes destructively. Use `just lint` (safe) or `just lint-ci`. +- `just test-ci` must pass at **100% line coverage** (`--cov-fail-under=100`). +- `just check-planning` must pass before pushing. +- The `x-` prefix match is lowercase exactly (`key.startswith("x-")`), as the + Compose spec mandates. + +--- + +### Task 1: Accept `x-` extension fields in `validate()` + +**Files:** +- Modify: `compose2pod/parsing.py` (three key-validation loops) +- Test: `tests/test_parsing.py` (add to `TestValidate`) +- Test: `tests/test_cli.py` (add end-to-end anchor test to `TestMain`) + +Teach the three key-inspection loops in `validate()` to skip any `x-`-prefixed +key silently, and prove it end to end through the YAML/anchor pipeline. + +- [ ] **Step 1: Write the failing unit tests** + + Add these three methods to the `TestValidate` class in + `tests/test_parsing.py`: + + ```python + def test_top_level_extension_key_is_accepted(self) -> None: + compose = {"x-application-defaults": {"build": {}}, "services": {"app": {"image": "x"}}} + assert validate(compose) == [] + + def test_service_extension_key_is_accepted_silently(self) -> None: + warnings = validate({"services": {"app": {"image": "x", "x-labels": {"team": "a"}}}}) + assert warnings == [] + + def test_healthcheck_extension_key_is_accepted(self) -> None: + compose = {"services": {"app": {"image": "x", "healthcheck": {"test": "true", "x-note": "n"}}}} + assert validate(compose) == [] + ``` + +- [ ] **Step 2: Run the unit tests to verify they fail** + + Run: `uv run --no-sync pytest tests/test_parsing.py -k extension -v` + Expected: 3 FAILs — top-level raises `unsupported top-level keys: + ['x-application-defaults']`, service raises `unsupported key 'x-labels'`, + healthcheck raises `unsupported healthcheck key 'x-note'`. + +- [ ] **Step 3: Implement the three skips in `compose2pod/parsing.py`** + + In `_validate_service`, add an `x-` skip at the top of the service-key loop + (before the `IGNORED_SERVICE_KEYS` branch): + + ```python + for key in sorted(svc): + if key.startswith("x-"): + continue + if key in IGNORED_SERVICE_KEYS: + warnings.append(f"service {name!r}: ignoring '{key}'") + elif key not in SUPPORTED_SERVICE_KEYS: + msg = f"service {name!r}: unsupported key '{key}'" + raise UnsupportedComposeError(msg) + ``` + + In the same function, add an `x-` skip to the healthcheck-key loop: + + ```python + for key in sorted(svc.get("healthcheck") or {}): + if key.startswith("x-"): + continue + if key not in SUPPORTED_HEALTHCHECK_KEYS: + msg = f"service {name!r}: unsupported healthcheck key '{key}'" + raise UnsupportedComposeError(msg) + ``` + + In `validate`, replace the top-level unknown-key computation (the + `unknown_top = set(compose) - SUPPORTED_TOP_LEVEL_KEYS` line) with a + comprehension that also drops `x-` keys: + + ```python + unknown_top = {k for k in compose if k not in SUPPORTED_TOP_LEVEL_KEYS and not k.startswith("x-")} + ``` + +- [ ] **Step 4: Run the unit tests to verify they pass** + + Run: `uv run --no-sync pytest tests/test_parsing.py -k extension -v` + Expected: 3 PASS. + +- [ ] **Step 5: Write the failing end-to-end anchor test** + + Add this method to the `TestMain` class in `tests/test_cli.py` (exercises a + top-level `x-` anchor block, a `<<:` merge, and a service-level `x-` key + through the real `_read_compose` → `validate` → `emit_script` pipeline): + + ```python + def test_yaml_anchor_extension_fields_convert( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + yaml_text = ( + "x-defaults: &defaults\n" + " image: base:latest\n" + "services:\n" + " app:\n" + " <<: *defaults\n" + " x-meta: keep\n" + ) + rc = run_main(yaml_text, ["--target", "app", "--image", "i", "--format", "yaml"], monkeypatch) + out = capsys.readouterr() + assert rc == 0 + assert "podman pod create" in out.out + ``` + +- [ ] **Step 6: Run the end-to-end test to verify it passes** + + Run: `uv run --no-sync pytest tests/test_cli.py -k anchor_extension -v` + Expected: PASS (implementation from Step 3 already makes it green; this test + guards the full pipeline, not just `validate()`). + +- [ ] **Step 7: Run the full suite at 100% coverage** + + Run: `just test-ci` + Expected: all pass, coverage 100%. If any new branch is uncovered, the run + fails — the three tests above each hit one of the three new `x-` skips. + +- [ ] **Step 8: Lint** + + Run: `just lint-ci` + Expected: clean. (If it flags formatting, run `just lint` to autofix safely, + then re-run `just lint-ci`.) + +- [ ] **Step 9: Commit** + + ```bash + git add compose2pod/parsing.py tests/test_parsing.py tests/test_cli.py + git commit -m "feat: accept compose x- extension fields" + ``` + +--- + +### Task 2: Document the supported subset + +**Files:** +- Create: `architecture/supported-subset.md` +- Modify: `README.md` (the "Supported compose subset" section) + +Pin the accept/ignore/reject rules — including the new `x-` rule — into the +living architecture doc, and point the README at it. + +- [ ] **Step 1: Create `architecture/supported-subset.md`** + + Write this file exactly (no frontmatter — living prose, per the + `architecture/` convention): + + ```markdown + # Supported compose subset + + compose2pod converts an honest subset of Docker Compose and refuses the rest + loudly rather than silently dropping behavior. `validate()` + (`compose2pod/parsing.py`) is the gate: anything it does not recognize either + warns (ignored, behavior-neutral inside a single pod) or raises + `UnsupportedComposeError`. + + ## Top-level keys + + - **Supported:** `services` (required, non-empty), `version`, `name`, + `networks`. + - **Ignored (warns):** `networks` — all services share the pod's single + network namespace, so top-level network definitions have no effect. + - **Extension fields:** any key prefixed `x-` is accepted and ignored + silently, per the Compose spec. This is what lets a document hold shared + config in a top-level `x-*` block for reuse via YAML anchors. + - Everything else raises. + + ## Service keys + + - **Supported:** `image`, `build`, `command`, `environment`, `env_file`, + `volumes`, `healthcheck`, `depends_on`, `networks`. + - **Ignored (warns):** `ports`, `restart`, `stdin_open`, `tty` — meaningless + or irrelevant inside a single shared-namespace pod. + - **Extension fields:** any `x-`-prefixed service key is accepted and ignored + silently. + - Everything else raises. + + ## Healthcheck keys + + - **Supported:** `test`, `interval`, `timeout`, `retries`, `start_period`. + - **Extension fields:** any `x-`-prefixed healthcheck key is accepted and + ignored silently. + - Everything else raises. + + ## Volumes + + Short bind-mount syntax only (`source:target`). The source must be a host path + (starts with `.` or `/`); named volumes and the long mapping form raise. + + ## depends_on + + All three conditions are honored: `service_started`, `service_healthy`, + `service_completed_successfully`. A `service_healthy` dependency on a service + with no usable healthcheck raises. + + ## YAML anchors and merge keys + + Anchors (`&name` / `*name`) and the merge key (`<<:`) need no handling in + compose2pod: PyYAML's `safe_load` resolves them at load time, so `validate()` + and `emit` see already-merged service mappings. JSON input has no anchors but + can still carry literal `x-` extension keys, handled identically. + ``` + +- [ ] **Step 2: Update the README subset section** + + In `README.md`, replace the paragraph under `## Supported compose subset` + (the one starting "compose2pod supports an honest subset...") with: + + ```markdown + compose2pod supports an honest subset and errors clearly on anything outside + it: `image`/`build`, `command`, `environment`/`env_file`, short-form bind + `volumes`, `healthcheck` (CMD/CMD-SHELL), `depends_on` (all conditions), and + network `aliases`. Compose extension fields (any `x-`-prefixed key) and YAML + anchors are accepted as-is, so a top-level `x-*` anchor block for shared + config just works. See `architecture/supported-subset.md` for the full + accept/ignore/reject matrix. + ``` + +- [ ] **Step 3: Verify the eof-fixer / formatting on docs** + + Run: `just lint-ci` + Expected: clean (eof-fixer requires a trailing newline on both files). + +- [ ] **Step 4: Commit** + + ```bash + git add architecture/supported-subset.md README.md + git commit -m "docs: document supported subset and x- extension fields" + ``` + +--- + +### Task 3: Finalize the change bundle and run all gates + +**Files:** +- Modify: `planning/changes/2026-07-04.01-extension-fields/design.md` (finalize + `summary:` if wording needs it) + +Confirm the whole change is green and the planning bundle is ship-ready. + +- [ ] **Step 1: Confirm the `design.md` summary reads as the realized result** + + Open `planning/changes/2026-07-04.01-extension-fields/design.md`. The + `summary:` line should state what shipped: + `Accept Compose x- extension fields at every validated level, ignoring them + silently.` Adjust only if the implementation diverged; otherwise leave it. + +- [ ] **Step 2: Run the full gate set** + + ```bash + just lint-ci + just test-ci + just check-planning + ``` + Expected: all three succeed; coverage 100%; `planning: OK`. + +- [ ] **Step 3: Commit any bundle edit (skip if nothing changed)** + + ```bash + git add planning/changes/2026-07-04.01-extension-fields/design.md + git commit -m "docs: finalize extension-fields change summary" + ``` + +- [ ] **Step 4: Hand off to finishing-a-development-branch** + + Push `feat/extension-fields` and open a PR (never local-merge, per project + workflow). Use the `superpowers:finishing-a-development-branch` skill to do + this and watch CI. + +--- + +## Self-review notes + +- **Spec coverage:** Design §1 (three-level `x-` skip) → Task 1 Steps 1-4; + silent decision → Task 1 Step 1 `test_service_extension_key_is_accepted_silently` + (`warnings == []`); end-to-end anchor round-trip → Task 1 Steps 5-6; Design §2 + (seed `architecture/supported-subset.md`) → Task 2 Step 1; README note → Task 2 + Step 2; testing/coverage → Task 1 Step 7. +- **No placeholders:** every code and doc step shows full content. +- **Type/name consistency:** `validate()` signature and `UnsupportedComposeError` + messages match `parsing.py` exactly; test helper `run_main` and fixtures match + `tests/test_cli.py`. diff --git a/tests/test_cli.py b/tests/test_cli.py index 07bae1a..6cf4c38 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -128,6 +128,17 @@ def test_missing_file_returns_2(self, capsys: pytest.CaptureFixture[str]) -> Non assert rc == EXIT_USAGE_ERROR assert "compose2pod: error:" in capsys.readouterr().err + def test_yaml_anchor_extension_fields_convert( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + yaml_text = ( + "x-defaults: &defaults\n image: base:latest\nservices:\n app:\n <<: *defaults\n x-meta: keep\n" + ) + rc = run_main(yaml_text, ["--target", "app", "--image", "i", "--format", "yaml"], monkeypatch) + out = capsys.readouterr() + assert rc == 0 + assert "podman pod create" in out.out + class TestModuleEntrypoint: def test_python_m_runs(self, chats_compose: dict) -> None: diff --git a/tests/test_parsing.py b/tests/test_parsing.py index a85c58d..e95ba7d 100644 --- a/tests/test_parsing.py +++ b/tests/test_parsing.py @@ -95,3 +95,15 @@ def test_unknown_depends_on_condition_raises(self) -> None: UnsupportedComposeError, match=r"service 'app': depends_on 'db' has unsupported condition 'service_ready'" ): validate(compose) + + def test_top_level_extension_key_is_accepted(self) -> None: + compose = {"x-application-defaults": {"build": {}}, "services": {"app": {"image": "x"}}} + assert validate(compose) == [] + + def test_service_extension_key_is_accepted_silently(self) -> None: + warnings = validate({"services": {"app": {"image": "x", "x-labels": {"team": "a"}}}}) + assert warnings == [] + + def test_healthcheck_extension_key_is_accepted(self) -> None: + compose = {"services": {"app": {"image": "x", "healthcheck": {"test": "true", "x-note": "n"}}}} + assert validate(compose) == []