Explorar o código

Merge pull request #1788 from ChristianLempa/feature/1780-exhaustive-dependency-validation-v021

Add exhaustive dependency matrix validation
Christian Lempa hai 3 días
pai
achega
38eded207a

+ 1 - 0
CHANGELOG.md

@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Added
 ### Added
 - Static template kind for technology-agnostic file and directory boilerplates (#1786)
 - Static template kind for technology-agnostic file and directory boilerplates (#1786)
 - Named generation output paths via `generate --name` / `-n` (#1783)
 - Named generation output paths via `generate --name` / `-n` (#1783)
+- Exhaustive dependency matrix validation for rendering reachable template states, with optional kind-specific validators (#1780)
 - `template.json` runtime support with required `files/` directories, custom `<< >>` / `<% %>` / `<# #>` delimiters, and legacy-format rejection for `0.2.0` (#1768)
 - `template.json` runtime support with required `files/` directories, custom `<< >>` / `<% %>` / `<# #>` delimiters, and legacy-format rejection for `0.2.0` (#1768)
 - Remote generation destinations via `generate --remote` and `--remote-path`, including SSH host discovery and SCP upload flow (#1765)
 - Remote generation destinations via `generate --remote` and `--remote-path`, including SSH host discovery and SCP upload flow (#1765)
 - Initial dedicated `swarm` module and validation flow as groundwork for the `compose` / `swarm` split in `0.2.0` (#1766)
 - Initial dedicated `swarm` module and validation flow as groundwork for the `compose` / `swarm` split in `0.2.0` (#1766)

+ 169 - 19
cli/core/module/base_commands.py

@@ -3,7 +3,7 @@
 from __future__ import annotations
 from __future__ import annotations
 
 
 import logging
 import logging
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
 from pathlib import Path
 from pathlib import Path
 
 
 from typer import Exit
 from typer import Exit
@@ -17,6 +17,7 @@ from ..exceptions import (
 )
 )
 from ..input import InputManager
 from ..input import InputManager
 from ..template import Template
 from ..template import Template
+from ..validation import DependencyMatrixBuilder, MatrixOptions, ValidationRunner
 from ..validators import get_validator_registry
 from ..validators import get_validator_registry
 from .generation_destination import (
 from .generation_destination import (
     GenerationDestination,
     GenerationDestination,
@@ -56,6 +57,20 @@ class GenerationConfig:
     name: str | None = None
     name: str | None = None
 
 
 
 
+@dataclass
+class ValidationConfig:
+    """Configuration for template validation."""
+
+    verbose: bool
+    semantic: bool = True
+    matrix: bool = False
+    kind: bool = False
+    all_templates: bool = False
+    matrix_max_combinations: int = 100
+    kind_validator: object | None = None
+    quiet_success: bool = False
+
+
 def list_templates(module_instance, raw: bool = False) -> list:
 def list_templates(module_instance, raw: bool = False) -> list:
     """List all templates."""
     """List all templates."""
     logger.debug(f"Listing templates for module '{module_instance.name}'")
     logger.debug(f"Listing templates for module '{module_instance.name}'")
@@ -577,17 +592,20 @@ def validate_templates(
     module_instance,
     module_instance,
     template_id: str,
     template_id: str,
     path: str | None,
     path: str | None,
-    verbose: bool,
-    semantic: bool,
+    config: ValidationConfig,
 ) -> None:
 ) -> None:
     """Validate templates for Jinja2 syntax, undefined variables, and semantic correctness."""
     """Validate templates for Jinja2 syntax, undefined variables, and semantic correctness."""
     # Load template based on input
     # Load template based on input
+    if config.all_templates and (template_id or path):
+        module_instance.display.error("--all cannot be combined with a template ID or --path")
+        raise Exit(code=1) from None
+
     template = _load_template_for_validation(module_instance, template_id, path)
     template = _load_template_for_validation(module_instance, template_id, path)
 
 
     if template:
     if template:
-        _validate_single_template(module_instance, template, template_id, verbose, semantic)
+        _validate_single_template(module_instance, template, template_id or template.id, config)
     else:
     else:
-        _validate_all_templates(module_instance, verbose)
+        _validate_all_templates(module_instance, config)
 
 
 
 
 def _load_template_for_validation(module_instance, template_id: str, path: str | None):
 def _load_template_for_validation(module_instance, template_id: str, path: str | None):
@@ -620,21 +638,31 @@ def _load_template_for_validation(module_instance, template_id: str, path: str |
     return None
     return None
 
 
 
 
-def _validate_single_template(module_instance, template, template_id: str, verbose: bool, semantic: bool) -> None:
+def _validate_single_template(
+    module_instance,
+    template,
+    template_id: str,
+    config: ValidationConfig,
+) -> None:
     """Validate a single template."""
     """Validate a single template."""
     try:
     try:
         # Jinja2 validation
         # Jinja2 validation
         _ = template.used_variables
         _ = template.used_variables
         _ = template.variables
         _ = template.variables
-        module_instance.display.success("Jinja2 validation passed")
+        if not config.quiet_success:
+            module_instance.display.success("Jinja2 validation passed")
 
 
-        # Semantic validation
-        if semantic:
-            _run_semantic_validation(module_instance, template, verbose)
+        if config.matrix or config.kind:
+            _run_matrix_validation(module_instance, template, config)
+            return
+
+        # Semantic validation for the default rendered output.
+        if config.semantic:
+            _run_semantic_validation(module_instance, template, config.verbose)
 
 
         # Verbose output
         # Verbose output
-        if verbose:
-            _display_validation_details(module_instance, template, semantic)
+        if config.verbose:
+            _display_validation_details(module_instance, template, config.semantic)
 
 
     except TemplateRenderError as e:
     except TemplateRenderError as e:
         module_instance.display.error(str(e), context=f"template '{template_id}'")
         module_instance.display.error(str(e), context=f"template '{template_id}'")
@@ -643,13 +671,123 @@ def _validate_single_template(module_instance, template, template_id: str, verbo
         module_instance.display.error(f"Validation failed for '{template_id}':")
         module_instance.display.error(f"Validation failed for '{template_id}':")
         module_instance.display.info(f"\n{e}")
         module_instance.display.info(f"\n{e}")
         raise Exit(code=1) from None
         raise Exit(code=1) from None
+    except Exit:
+        raise
     except Exception as e:
     except Exception as e:
         module_instance.display.error(f"Unexpected error validating '{template_id}': {e}")
         module_instance.display.error(f"Unexpected error validating '{template_id}': {e}")
         raise Exit(code=1) from None
         raise Exit(code=1) from None
 
 
 
 
+def _run_matrix_validation(
+    module_instance,
+    template,
+    config: ValidationConfig,
+) -> None:
+    """Run dependency matrix validation for one template."""
+    module_instance.display.info("")
+    module_instance.display.info("Running dependency matrix validation...")
+
+    options = MatrixOptions(max_combinations=config.matrix_max_combinations)
+    cases = DependencyMatrixBuilder(template, options).build()
+    runner = ValidationRunner(
+        template,
+        cases,
+        semantic=config.semantic,
+        kind_validator=config.kind_validator,
+    )
+    summary = runner.run()
+
+    module_instance.display.data_table(
+        columns=[
+            {"name": "Case", "style": "cyan", "no_wrap": False},
+            {"name": "Tpl", "justify": "center"},
+            {"name": "Sem", "justify": "center"},
+            {"name": "Kind", "justify": "center"},
+        ],
+        rows=_build_matrix_result_rows(
+            cases,
+            summary.failures,
+            kind_requested=config.kind,
+            kind_available=config.kind_validator is not None,
+            kind_skipped_cases=summary.kind_skipped_cases,
+        ),
+        title=f"Dependency Matrix ({len(cases)} cases)",
+    )
+
+    if config.kind and config.kind_validator is None:
+        module_instance.display.warning(f"No kind-specific validator available for '{module_instance.name}'")
+    elif summary.kind_skipped_cases:
+        module_instance.display.warning("Kind-specific validation skipped for one or more cases")
+
+    if summary.failures:
+        module_instance.display.info("")
+        for failure in summary.failures:
+            location = f" [{failure.file_path}]" if failure.file_path else ""
+            validator = f" ({failure.validator})" if failure.validator else ""
+            module_instance.display.error(
+                f"{failure.case_name}: {failure.stage}{location}{validator}: {failure.message}"
+            )
+        raise Exit(code=1) from None
+
+    module_instance.display.success(f"Dependency matrix validation passed ({summary.total_cases} case(s))")
+
+
+def _build_matrix_result_rows(
+    cases,
+    failures,
+    kind_requested: bool,
+    kind_available: bool,
+    kind_skipped_cases: set[str],
+) -> list[tuple[str, str, str, str]]:
+    """Build display rows for matrix validation results."""
+    failures_by_case: dict[str, dict[str, set[str]]] = {}
+    for failure in failures:
+        case_failures = failures_by_case.setdefault(failure.case_name, {})
+        case_failures.setdefault(failure.stage, set()).add(failure.message)
+
+    rows = []
+    for case in cases:
+        failed_stages = failures_by_case.get(case.name, {})
+        rows.append(
+            (
+                case.name,
+                "fail" if "tpl" in failed_stages else "pass",
+                "fail" if "sem" in failed_stages else "pass",
+                _matrix_stage_status(
+                    failed_stages,
+                    "kind",
+                    requested=kind_requested,
+                    available=kind_available,
+                    skipped=case.name in kind_skipped_cases,
+                ),
+            )
+        )
+    return rows
+
+
+def _matrix_stage_status(
+    failed_stages: dict[str, set[str]],
+    stage: str,
+    *,
+    requested: bool = True,
+    available: bool = True,
+    skipped: bool = False,
+) -> str:
+    if not requested:
+        return "skip"
+    if not available:
+        return "missing"
+    if any("not available" in message or "unavailable" in message for message in failed_stages.get(stage, set())):
+        return "missing"
+    if stage in failed_stages:
+        return "fail"
+    if skipped:
+        return "skip"
+    return "pass"
+
+
 def _run_semantic_validation(module_instance, template, verbose: bool) -> None:
 def _run_semantic_validation(module_instance, template, verbose: bool) -> None:
-    """Run semantic validation on rendered template files."""
+    """Run semantic validation on the default rendered template files."""
     module_instance.display.info("")
     module_instance.display.info("")
     module_instance.display.info("Running semantic validation...")
     module_instance.display.info("Running semantic validation...")
 
 
@@ -685,7 +823,7 @@ def _display_validation_details(module_instance, template, semantic: bool) -> No
         module_instance.display.info(f"Generated {len(rendered_files)} files")
         module_instance.display.info(f"Generated {len(rendered_files)} files")
 
 
 
 
-def _validate_all_templates(module_instance, verbose: bool) -> None:
+def _validate_all_templates(module_instance, config: ValidationConfig) -> None:
     """Validate all templates in the module."""
     """Validate all templates in the module."""
     module_instance.display.info(f"Validating all {module_instance.name} templates...")
     module_instance.display.info(f"Validating all {module_instance.name} templates...")
 
 
@@ -695,23 +833,35 @@ def _validate_all_templates(module_instance, verbose: bool) -> None:
 
 
     all_templates = module_instance._load_all_templates()
     all_templates = module_instance._load_all_templates()
     total = len(all_templates)
     total = len(all_templates)
+    # Preserve historical all-template validation behavior: by default this
+    # checks template syntax/variables only. Matrix or kind validation can be
+    # explicitly enabled for all templates with --matrix/--kind.
+    child_config = replace(
+        config,
+        semantic=config.semantic if config.matrix or config.kind else False,
+        quiet_success=not config.verbose,
+    )
 
 
     for template in all_templates:
     for template in all_templates:
         try:
         try:
-            _ = template.used_variables
-            _ = template.variables
+            _validate_single_template(module_instance, template, template.id, child_config)
             valid_count += 1
             valid_count += 1
-            if verbose:
+            if config.verbose:
                 module_instance.display.success(template.id)
                 module_instance.display.success(template.id)
+        except Exit:
+            invalid_count += 1
+            errors.append((template.id, "Validation failed"))
+            if config.verbose:
+                module_instance.display.error(template.id)
         except ValueError as e:
         except ValueError as e:
             invalid_count += 1
             invalid_count += 1
             errors.append((template.id, str(e)))
             errors.append((template.id, str(e)))
-            if verbose:
+            if config.verbose:
                 module_instance.display.error(template.id)
                 module_instance.display.error(template.id)
         except Exception as e:
         except Exception as e:
             invalid_count += 1
             invalid_count += 1
             errors.append((template.id, f"Load error: {e}"))
             errors.append((template.id, f"Load error: {e}"))
-            if verbose:
+            if config.verbose:
                 module_instance.display.warning(template.id)
                 module_instance.display.warning(template.id)
 
 
     # Display summary
     # Display summary

+ 41 - 7
cli/core/module/base_module.py

@@ -13,6 +13,7 @@ from ..library import LibraryManager
 from ..template import Template
 from ..template import Template
 from .base_commands import (
 from .base_commands import (
     GenerationConfig,
     GenerationConfig,
+    ValidationConfig,
     generate_template,
     generate_template,
     list_templates,
     list_templates,
     search_templates,
     search_templates,
@@ -44,6 +45,7 @@ class Module(ABC):
     # Class attributes that must be defined by subclasses
     # Class attributes that must be defined by subclasses
     name: str
     name: str
     description: str
     description: str
+    kind_validator_class = None
 
 
     def __init__(self) -> None:
     def __init__(self) -> None:
         # Validate required class attributes
         # Validate required class attributes
@@ -260,28 +262,60 @@ class Module(ABC):
             str | None,
             str | None,
             Option("--path", help="Path to template directory for validation"),
             Option("--path", help="Path to template directory for validation"),
         ] = None,
         ] = None,
+        all_templates: Annotated[
+            bool,
+            Option("--all", help="Validate all templates in this module (default when no template ID is provided)"),
+        ] = False,
         verbose: Annotated[bool, Option("--verbose", "-v", help="Show detailed validation information")] = False,
         verbose: Annotated[bool, Option("--verbose", "-v", help="Show detailed validation information")] = False,
         semantic: Annotated[
         semantic: Annotated[
             bool,
             bool,
             Option(
             Option(
                 "--semantic/--no-semantic",
                 "--semantic/--no-semantic",
-                help="Enable semantic validation (Docker Compose config, YAML structure, etc.)",
+                help="Enable semantic validation for rendered files",
             ),
             ),
         ] = True,
         ] = True,
+        matrix: Annotated[
+            bool,
+            Option(
+                "--matrix",
+                help="Validate all reachable dependency states for a single template",
+            ),
+        ] = False,
+        kind: Annotated[
+            bool,
+            Option(
+                "--kind",
+                help="Enable dependency-matrix kind-specific validation when available",
+            ),
+        ] = False,
     ) -> None:
     ) -> None:
-        """Validate templates for Jinja2 syntax, undefined variables, and semantic correctness.
+        """Validate templates for syntax, rendered semantics, and optional dependency matrix checks.
 
 
         Examples:
         Examples:
             # Validate specific template
             # Validate specific template
-            cli compose validate netbox
+            cli terraform validate cloudflare-dns-record
 
 
             # Validate all templates
             # Validate all templates
-            cli compose validate
+            cli terraform validate
 
 
-            # Validate with verbose output
-            cli compose validate netbox --verbose
+            # Validate rendered semantic and kind-specific matrix cases
+            cli terraform validate cloudflare-dns-record --matrix --kind
         """
         """
-        return validate_templates(self, template_id, path, verbose, semantic)
+        return validate_templates(
+            self,
+            template_id,
+            path,
+            ValidationConfig(
+                verbose=verbose,
+                semantic=semantic,
+                matrix=matrix,
+                kind=kind,
+                all_templates=all_templates,
+                kind_validator=self.kind_validator_class(verbose).validate_rendered_files
+                if kind and self.kind_validator_class
+                else None,
+            ),
+        )
 
 
     def config_get(
     def config_get(
         self,
         self,

+ 0 - 23
cli/core/template/template.py

@@ -26,7 +26,6 @@ logger = logging.getLogger(__name__)
 TEMPLATE_MANIFEST_FILENAME = "template.json"
 TEMPLATE_MANIFEST_FILENAME = "template.json"
 LEGACY_TEMPLATE_FILENAMES = ("template.yaml", "template.yml")
 LEGACY_TEMPLATE_FILENAMES = ("template.yaml", "template.yml")
 TEMPLATE_FILES_DIRNAME = "files"
 TEMPLATE_FILES_DIRNAME = "files"
-LEGACY_JINJA_DELIMITERS = ("{{", "{%", "{#")
 VARIABLE_START = "<<"
 VARIABLE_START = "<<"
 VARIABLE_END = ">>"
 VARIABLE_END = ">>"
 BLOCK_START = "<%"
 BLOCK_START = "<%"
@@ -351,34 +350,12 @@ class Template:
         template_files.sort(key=lambda item: str(item.relative_path))
         template_files.sort(key=lambda item: str(item.relative_path))
         self.__template_files = template_files
         self.__template_files = template_files
 
 
-    def _validate_delimiters(self) -> None:
-        """Reject legacy Jinja delimiters in 0.2.0 templates."""
-        for template_file in self.template_files:
-            file_path = self.files_dir / template_file.relative_path
-            try:
-                content = file_path.read_text(encoding="utf-8")
-            except OSError as exc:
-                raise TemplateValidationError(
-                    f"Failed to read template file '{template_file.relative_path}': {exc}"
-                ) from exc
-
-            for delimiter in LEGACY_JINJA_DELIMITERS:
-                if delimiter in content:
-                    raise TemplateValidationError(
-                        f"Legacy Jinja delimiter '{delimiter}' found in '{template_file.relative_path}'. "
-                        f"Use {VARIABLE_START} {VARIABLE_END} for variables, "
-                        f"{BLOCK_START} {BLOCK_END} for blocks, and "
-                        f"{COMMENT_START} {COMMENT_END} for comments."
-                    )
-
     def _extract_all_used_variables(self) -> set[str]:
     def _extract_all_used_variables(self) -> set[str]:
         """Extract undeclared variables from all files under files/."""
         """Extract undeclared variables from all files under files/."""
         used_variables: set[str] = set()
         used_variables: set[str] = set()
         syntax_errors = []
         syntax_errors = []
         self._variable_usage_map: dict[str, list[str]] = {}
         self._variable_usage_map: dict[str, list[str]] = {}
 
 
-        self._validate_delimiters()
-
         for template_file in self.template_files:
         for template_file in self.template_files:
             file_path = self.files_dir / template_file.relative_path
             file_path = self.files_dir / template_file.relative_path
             try:
             try:

+ 2 - 0
cli/core/template/variable.py

@@ -305,6 +305,8 @@ class Variable:
     ) -> VariableConfig:
     ) -> VariableConfig:
         if config_input is None:
         if config_input is None:
             config_data: dict[str, Any] = {}
             config_data: dict[str, Any] = {}
+        elif isinstance(config_input, VariableConfig):
+            return config_input.clone()
         elif isinstance(config_input, dict):
         elif isinstance(config_input, dict):
             config_data = config_input.copy()
             config_data = config_input.copy()
         else:
         else:

+ 33 - 0
cli/core/validation/__init__.py

@@ -0,0 +1,33 @@
+"""Validation orchestration helpers."""
+
+from .dependency_matrix import DependencyMatrixBuilder, MatrixOptions, ValidationCase
+from .kind_validators import (
+    AnsibleValidator,
+    HelmValidator,
+    KubernetesValidator,
+    PackerValidator,
+    TerraformValidator,
+)
+from .validation_runner import (
+    KindValidationFailure,
+    KindValidationResult,
+    MatrixValidationSummary,
+    ValidationFailure,
+    ValidationRunner,
+)
+
+__all__ = [
+    "AnsibleValidator",
+    "DependencyMatrixBuilder",
+    "HelmValidator",
+    "KindValidationFailure",
+    "KindValidationResult",
+    "KubernetesValidator",
+    "MatrixOptions",
+    "MatrixValidationSummary",
+    "PackerValidator",
+    "TerraformValidator",
+    "ValidationCase",
+    "ValidationFailure",
+    "ValidationRunner",
+]

+ 216 - 0
cli/core/validation/dependency_matrix.py

@@ -0,0 +1,216 @@
+"""Build dependency-aware template validation cases."""
+
+from __future__ import annotations
+
+import itertools
+import json
+from dataclasses import dataclass
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+    from cli.core.template import Template
+    from cli.core.template.variable import Variable
+    from cli.core.template.variable_collection import VariableCollection
+
+
+@dataclass(frozen=True)
+class MatrixOptions:
+    """Options controlling dependency matrix generation."""
+
+    max_combinations: int = 100
+
+
+@dataclass(frozen=True)
+class DependencyCondition:
+    """Structured representation of a needs condition."""
+
+    variable: str
+    positive: bool
+    values: tuple[str, ...] | None = None
+
+
+@dataclass
+class ValidationCase:
+    """A named variable configuration to render and validate."""
+
+    name: str
+    variables: VariableCollection
+    overrides: dict[str, Any]
+
+
+class DependencyMatrixBuilder:
+    """Create practical dependency-aware validation cases for one template."""
+
+    def __init__(self, template: Template, options: MatrixOptions | None = None) -> None:
+        self.template = template
+        self.options = options or MatrixOptions()
+        self.base_variables = template.variables
+        self._conditions = self._collect_conditions()
+
+    def build(self) -> list[ValidationCase]:
+        """Build named, satisfiable, deduplicated validation cases."""
+        variable_values = self._build_branch_value_sets()
+        raw_cases = self._build_raw_cases(variable_values)
+        return self._materialize_cases(raw_cases)
+
+    def _collect_conditions(self) -> list[DependencyCondition]:
+        conditions: list[DependencyCondition] = []
+
+        for section in self.base_variables.get_sections().values():
+            conditions.extend(self._parse_conditions(section.needs))
+            for variable in section.variables.values():
+                conditions.extend(self._parse_conditions(variable.needs))
+
+        return conditions
+
+    def _parse_conditions(self, needs: list[str]) -> list[DependencyCondition]:
+        conditions = []
+        for need in needs:
+            condition = self._parse_condition(need)
+            if condition is not None:
+                conditions.append(condition)
+        return conditions
+
+    @staticmethod
+    def _parse_condition(need: str) -> DependencyCondition | None:
+        if "!=" in need:
+            variable, raw_values = need.split("!=", 1)
+            return DependencyCondition(variable.strip(), False, DependencyMatrixBuilder._split_values(raw_values))
+
+        if "=" in need:
+            variable, raw_values = need.split("=", 1)
+            return DependencyCondition(variable.strip(), True, DependencyMatrixBuilder._split_values(raw_values))
+
+        return None
+
+    @staticmethod
+    def _split_values(raw_values: str) -> tuple[str, ...]:
+        return tuple(value.strip() for value in raw_values.split(",") if value.strip())
+
+    def _build_branch_value_sets(self) -> dict[str, list[Any]]:
+        value_sets: dict[str, list[Any]] = {}
+        variables = self.base_variables._variable_map
+
+        # Bool variables are cheap and often control template branches directly.
+        for name, variable in variables.items():
+            if variable.type == "bool":
+                value_sets[name] = [False, True]
+
+        # Section toggles may not follow a naming convention, so include them explicitly.
+        for section in self.base_variables.get_sections().values():
+            if section.toggle and section.toggle in variables:
+                value_sets[section.toggle] = [False, True]
+
+        for condition in self._conditions:
+            variable = variables.get(condition.variable)
+            if variable is None:
+                continue
+
+            values = self._condition_values(variable, condition)
+            if not values:
+                continue
+
+            current_values = value_sets.setdefault(condition.variable, [])
+            for value in values:
+                if value not in current_values:
+                    current_values.append(value)
+
+        return {name: values for name, values in value_sets.items() if values}
+
+    def _condition_values(self, variable: Variable, condition: DependencyCondition) -> list[Any]:
+        values: list[Any] = []
+
+        if variable.type == "bool":
+            return [False, True]
+
+        if variable.type == "enum":
+            values.extend(self._matching_enum_values(variable, condition))
+            if variable.value is not None and variable.value not in values:
+                values.append(variable.value)
+            return values
+
+        if condition.values:
+            values.extend(condition.values)
+        if variable.value is not None and variable.value not in values:
+            values.append(variable.value)
+        return values
+
+    @staticmethod
+    def _matching_enum_values(variable: Variable, condition: DependencyCondition) -> list[str]:
+        options = list(variable.options or [])
+        if not condition.values:
+            return options
+
+        if condition.positive:
+            return [value for value in condition.values if value in options]
+
+        excluded = set(condition.values)
+        return [value for value in options if value not in excluded]
+
+    def _build_raw_cases(self, value_sets: dict[str, list[Any]]) -> list[tuple[str, dict[str, Any]]]:
+        if not value_sets:
+            return [("defaults", {})]
+
+        count = 1
+        for values in value_sets.values():
+            count *= len(values)
+
+        if count <= self.options.max_combinations:
+            return self._cartesian_cases(value_sets)
+
+        return self._reduced_cases(value_sets)
+
+    def _cartesian_cases(self, value_sets: dict[str, list[Any]]) -> list[tuple[str, dict[str, Any]]]:
+        names = sorted(value_sets)
+        cases: list[tuple[str, dict[str, Any]]] = [("defaults", {})]
+
+        for index, values in enumerate(itertools.product(*(value_sets[name] for name in names)), start=1):
+            overrides = dict(zip(names, values, strict=True))
+            cases.append((self._case_name(f"matrix-{index}", overrides), overrides))
+
+        return cases
+
+    def _reduced_cases(self, value_sets: dict[str, list[Any]]) -> list[tuple[str, dict[str, Any]]]:
+        cases: list[tuple[str, dict[str, Any]]] = [("defaults", {})]
+
+        bool_names = sorted(name for name in value_sets if self.base_variables._variable_map.get(name).type == "bool")
+        if bool_names:
+            cases.append(("all-bools-false", dict.fromkeys(bool_names, False)))
+            cases.append(("all-bools-true", dict.fromkeys(bool_names, True)))
+
+        for name in sorted(value_sets):
+            for value in value_sets[name]:
+                overrides = {name: value}
+                cases.append((self._case_name("branch", overrides), overrides))
+
+        return cases
+
+    def _materialize_cases(self, raw_cases: list[tuple[str, dict[str, Any]]]) -> list[ValidationCase]:
+        cases: list[ValidationCase] = []
+        seen_effective_states: set[str] = set()
+
+        for name, overrides in raw_cases:
+            variables = self._fresh_variables()
+            if overrides:
+                variables.apply_defaults(overrides, origin="matrix")
+            variables.reset_disabled_bool_variables()
+
+            state_key = self._state_key(variables.get_satisfied_values())
+            if state_key in seen_effective_states:
+                continue
+            seen_effective_states.add(state_key)
+            cases.append(ValidationCase(name=name, variables=variables, overrides=overrides))
+
+        return cases
+
+    def _fresh_variables(self) -> VariableCollection:
+        return self.base_variables.merge({}, origin="matrix")
+
+    @staticmethod
+    def _state_key(values: dict[str, Any]) -> str:
+        return json.dumps(values, sort_keys=True, default=str)
+
+    @staticmethod
+    def _case_name(prefix: str, overrides: dict[str, Any]) -> str:
+        details = ", ".join(f"{key}={value}" for key, value in sorted(overrides.items()))
+        return f"{prefix}: {details}" if details else prefix

+ 260 - 0
cli/core/validation/kind_validators.py

@@ -0,0 +1,260 @@
+"""Kind-specific validators for rendered templates."""
+
+from __future__ import annotations
+
+import os
+import shutil
+import subprocess
+import tempfile
+from pathlib import Path
+
+from .validation_runner import KindValidationFailure, KindValidationResult
+
+
+class RenderedFilesValidator:
+    """Base class for validators that run CLI tools against rendered files."""
+
+    validator_name: str
+    unavailable_message: str
+
+    def __init__(self, verbose: bool = False) -> None:
+        self.verbose = verbose
+        self._available: bool | None = None
+
+    def command_available(self, command: str) -> bool:
+        return shutil.which(command) is not None
+
+    def validate_rendered_files(self, rendered_files: dict[str, str], case_name: str) -> KindValidationResult:
+        result = KindValidationResult(validator=self.validator_name, available=self.is_available())
+        if not result.available:
+            result.details.append(self.unavailable_message)
+            return result
+
+        with tempfile.TemporaryDirectory(prefix=f"boilerplates-{case_name[:20]}-") as tmp_dir:
+            workdir = Path(tmp_dir)
+            self._write_rendered_files(rendered_files, workdir)
+            return self.validate_directory(workdir)
+
+    def is_available(self) -> bool:
+        raise NotImplementedError
+
+    def validate_directory(self, workdir: Path) -> KindValidationResult:
+        raise NotImplementedError
+
+    @staticmethod
+    def _write_rendered_files(rendered_files: dict[str, str], workdir: Path) -> None:
+        for filename, content in rendered_files.items():
+            path = workdir / filename
+            path.parent.mkdir(parents=True, exist_ok=True)
+            path.write_text(content, encoding="utf-8")
+
+    def run_command(
+        self,
+        args: list[str],
+        workdir: Path,
+        *,
+        env: dict[str, str] | None = None,
+    ) -> subprocess.CompletedProcess[str]:
+        return subprocess.run(
+            args,
+            cwd=workdir,
+            env=env,
+            capture_output=True,
+            text=True,
+            check=False,
+        )
+
+    def failure_from_process(
+        self,
+        result: subprocess.CompletedProcess[str],
+        file_path: str = "",
+    ) -> KindValidationFailure | None:
+        if result.returncode == 0:
+            return None
+
+        message = result.stderr.strip() or result.stdout.strip() or f"{self.validator_name} failed"
+        return KindValidationFailure(file_path=file_path, validator=self.validator_name, message=message)
+
+
+class TerraformValidator(RenderedFilesValidator):
+    """Validate Terraform/OpenTofu configurations."""
+
+    validator_name = "tofu validate"
+    unavailable_message = "Required command is unavailable: tofu or terraform"
+
+    def __init__(self, verbose: bool = False) -> None:
+        super().__init__(verbose)
+        self.command = "tofu" if self.command_available("tofu") else "terraform"
+        self.validator_name = f"{self.command} validate"
+
+    def is_available(self) -> bool:
+        if self._available is None:
+            self._available = self.command_available(self.command)
+        return self._available
+
+    def validate_directory(self, workdir: Path) -> KindValidationResult:
+        result = KindValidationResult(validator=self.validator_name)
+        init = self.run_command([self.command, "init", "-backend=false", "-input=false", "-no-color"], workdir)
+        failure = self.failure_from_process(init)
+        if failure is not None:
+            if self._is_provider_resolution_failure(failure.message):
+                result.skipped = True
+                result.warnings.append(failure.message)
+            else:
+                result.failures.append(failure)
+            return result
+
+        validate = self.run_command([self.command, "validate", "-no-color"], workdir)
+        failure = self.failure_from_process(validate)
+        if failure is not None:
+            result.failures.append(failure)
+        return result
+
+    @staticmethod
+    def _is_provider_resolution_failure(message: str) -> bool:
+        return "Failed to resolve provider packages" in message or "could not connect to registry" in message
+
+
+class KubernetesValidator(RenderedFilesValidator):
+    """Validate Kubernetes manifests with kubectl client dry-run."""
+
+    validator_name = "kubectl create --dry-run=client"
+    unavailable_message = "Required command is unavailable: kubectl"
+
+    def is_available(self) -> bool:
+        if self._available is None:
+            self._available = self.command_available("kubectl")
+        return self._available
+
+    def validate_directory(self, workdir: Path) -> KindValidationResult:
+        result = KindValidationResult(validator=self.validator_name)
+        process = self.run_command(["kubectl", "create", "--dry-run=client", "--validate=false", "-f", "."], workdir)
+        failure = self.failure_from_process(process)
+        if failure is not None:
+            if self._is_cluster_discovery_failure(failure.message):
+                result.skipped = True
+                result.warnings.append(failure.message)
+            else:
+                result.failures.append(failure)
+        return result
+
+    @staticmethod
+    def _is_cluster_discovery_failure(message: str) -> bool:
+        return "couldn't get current server API group list" in message or "unable to recognize" in message
+
+
+class HelmValidator(RenderedFilesValidator):
+    """Validate Helm chart files."""
+
+    validator_name = "helm lint"
+    unavailable_message = "Required command is unavailable: helm"
+
+    def is_available(self) -> bool:
+        if self._available is None:
+            self._available = self.command_available("helm")
+        return self._available
+
+    def validate_directory(self, workdir: Path) -> KindValidationResult:
+        result = KindValidationResult(validator=self.validator_name)
+        if not (workdir / "Chart.yaml").exists():
+            result.skipped = True
+            result.warnings.append("Rendered files do not include Chart.yaml")
+            return result
+
+        process = self.run_command(["helm", "lint", "."], workdir)
+        failure = self.failure_from_process(process)
+        if failure is not None:
+            result.failures.append(failure)
+        return result
+
+
+class PackerValidator(RenderedFilesValidator):
+    """Validate Packer templates."""
+
+    validator_name = "packer validate"
+    unavailable_message = "Required command is unavailable: packer"
+
+    def is_available(self) -> bool:
+        if self._available is None:
+            self._available = self.command_available("packer")
+        return self._available
+
+    def validate_directory(self, workdir: Path) -> KindValidationResult:
+        result = KindValidationResult(validator=self.validator_name)
+        if list(workdir.glob("*.pkr.hcl")):
+            target = "."
+        else:
+            candidates = sorted(path for path in workdir.rglob("*") if path.is_file() and path.suffix == ".json")
+            if not candidates:
+                result.skipped = True
+                result.warnings.append("No Packer template files found")
+                return result
+            target = str(candidates[0].relative_to(workdir))
+
+        process = self.run_command(["packer", "validate", target], workdir)
+        failure = self.failure_from_process(process)
+        if failure is not None:
+            result.failures.append(failure)
+        return result
+
+
+class AnsibleValidator(RenderedFilesValidator):
+    """Validate Ansible playbooks with syntax-check."""
+
+    validator_name = "ansible-playbook --syntax-check"
+    unavailable_message = "Required command is unavailable: ansible-playbook"
+
+    def is_available(self) -> bool:
+        if self._available is None:
+            self._available = self.command_available("ansible-playbook")
+        return self._available
+
+    def validate_directory(self, workdir: Path) -> KindValidationResult:
+        result = KindValidationResult(validator=self.validator_name)
+        playbooks = self._find_playbooks(workdir)
+        if not playbooks:
+            result.skipped = True
+            result.warnings.append("No Ansible playbooks found")
+            return result
+
+        env = os.environ.copy()
+        env["ANSIBLE_LOCAL_TEMP"] = tempfile.mkdtemp(prefix="ansible-local-")
+        env["ANSIBLE_REMOTE_TEMP"] = "/tmp/.ansible-${USER}/tmp"
+
+        for playbook in playbooks:
+            process = self.run_command(
+                ["ansible-playbook", "--syntax-check", str(playbook.relative_to(workdir))],
+                workdir,
+                env=env,
+            )
+            failure = self.failure_from_process(process, str(playbook.relative_to(workdir)))
+            if failure is not None:
+                if self._is_dependency_resolution_failure(failure.message):
+                    result.skipped = True
+                    result.warnings.append(failure.message)
+                    continue
+                result.failures.append(failure)
+        return result
+
+    @staticmethod
+    def _find_playbooks(workdir: Path) -> list[Path]:
+        candidates = []
+        for path in workdir.rglob("*"):
+            if not path.is_file() or path.suffix.lower() not in {".yaml", ".yml"}:
+                continue
+            if "playbook" in path.name.lower() or AnsibleValidator._looks_like_playbook(path):
+                candidates.append(path)
+        return candidates
+
+    @staticmethod
+    def _looks_like_playbook(path: Path) -> bool:
+        try:
+            content = path.read_text(encoding="utf-8")
+        except OSError:
+            return False
+
+        return any(line.lstrip().startswith("hosts:") for line in content.splitlines())
+
+    @staticmethod
+    def _is_dependency_resolution_failure(message: str) -> bool:
+        return ("the role" in message and "was not found" in message) or "couldn't resolve module/action" in message

+ 153 - 0
cli/core/validation/validation_runner.py

@@ -0,0 +1,153 @@
+"""Run template validation across dependency matrix cases."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING
+
+from ..exceptions import TemplateRenderError, TemplateSyntaxError, TemplateValidationError
+from ..validators import get_validator_registry
+
+if TYPE_CHECKING:
+    from cli.core.template import Template
+
+    from .dependency_matrix import ValidationCase
+
+
+@dataclass(frozen=True)
+class ValidationFailure:
+    """A failure from template, semantic, or kind-specific validation."""
+
+    case_name: str
+    stage: str
+    message: str
+    file_path: str = ""
+    validator: str = ""
+
+
+@dataclass(frozen=True)
+class KindValidationFailure:
+    """A kind-specific validation failure."""
+
+    file_path: str
+    message: str
+    validator: str
+
+
+@dataclass
+class KindValidationResult:
+    """Kind-specific validation result."""
+
+    validator: str
+    available: bool = True
+    skipped: bool = False
+    failures: list[KindValidationFailure] = field(default_factory=list)
+    warnings: list[str] = field(default_factory=list)
+    details: list[str] = field(default_factory=list)
+
+    @property
+    def ok(self) -> bool:
+        return self.available and not self.failures
+
+
+KindValidator = Callable[[dict[str, str], str], KindValidationResult]
+
+
+@dataclass
+class MatrixValidationSummary:
+    """Aggregated validation results for a matrix run."""
+
+    total_cases: int = 0
+    failures: list[ValidationFailure] = field(default_factory=list)
+    kind_available: bool = True
+    kind_skipped_cases: set[str] = field(default_factory=set)
+
+    @property
+    def ok(self) -> bool:
+        return not self.failures
+
+
+class ValidationRunner:
+    """Render validation cases and run semantic and optional kind validation."""
+
+    def __init__(
+        self,
+        template: Template,
+        cases: list[ValidationCase],
+        *,
+        semantic: bool = True,
+        kind_validator: KindValidator | None = None,
+    ) -> None:
+        self.template = template
+        self.cases = cases
+        self.semantic = semantic
+        self.kind_validator = kind_validator
+
+    def run(self) -> MatrixValidationSummary:
+        summary = MatrixValidationSummary(total_cases=len(self.cases))
+
+        for case in self.cases:
+            try:
+                rendered_files, _ = self.template.render(case.variables)
+            except (TemplateRenderError, TemplateSyntaxError, TemplateValidationError, ValueError) as exc:
+                summary.failures.append(ValidationFailure(case_name=case.name, stage="tpl", message=str(exc)))
+                continue
+
+            if self.semantic:
+                self._run_semantic(case.name, rendered_files, summary)
+
+            if self.kind_validator is not None:
+                self._run_kind(case.name, rendered_files, summary)
+
+        return summary
+
+    def _run_semantic(
+        self,
+        case_name: str,
+        rendered_files: dict[str, str],
+        summary: MatrixValidationSummary,
+    ) -> None:
+        registry = get_validator_registry()
+
+        for file_path, content in rendered_files.items():
+            result = registry.validate_file(content, file_path)
+            for error in result.errors:
+                validator = registry.get_validator(file_path)
+                summary.failures.append(
+                    ValidationFailure(
+                        case_name=case_name,
+                        stage="sem",
+                        file_path=file_path,
+                        validator=validator.__class__.__name__ if validator else "semantic",
+                        message=error,
+                    )
+                )
+
+    def _run_kind(
+        self,
+        case_name: str,
+        rendered_files: dict[str, str],
+        summary: MatrixValidationSummary,
+    ) -> None:
+        result = self.kind_validator(rendered_files, case_name)
+        summary.kind_available = summary.kind_available and result.available
+
+        if not result.available:
+            summary.kind_skipped_cases.add(case_name)
+            return
+
+        if result.skipped:
+            summary.kind_skipped_cases.add(case_name)
+            return
+
+        for failure in result.failures:
+            summary.failures.append(
+                ValidationFailure(
+                    case_name=case_name,
+                    stage="kind",
+                    file_path=failure.file_path,
+                    validator=failure.validator,
+                    message=failure.message,
+                )
+            )

+ 2 - 0
cli/modules/ansible/__init__.py

@@ -2,6 +2,7 @@
 
 
 from ...core.module import Module
 from ...core.module import Module
 from ...core.registry import registry
 from ...core.registry import registry
+from ...core.validation import AnsibleValidator
 
 
 
 
 class AnsibleModule(Module):
 class AnsibleModule(Module):
@@ -9,6 +10,7 @@ class AnsibleModule(Module):
 
 
     name = "ansible"
     name = "ansible"
     description = "Manage Ansible configurations"
     description = "Manage Ansible configurations"
+    kind_validator_class = AnsibleValidator
 
 
 
 
 registry.register(AnsibleModule)
 registry.register(AnsibleModule)

+ 40 - 9
cli/modules/compose/__init__.py

@@ -6,9 +6,9 @@ from typing import Annotated
 from typer import Argument, Option
 from typer import Argument, Option
 
 
 from ...core.module import Module
 from ...core.module import Module
-from ...core.module.base_commands import validate_templates
+from ...core.module.base_commands import ValidationConfig, validate_templates
 from ...core.registry import registry
 from ...core.registry import registry
-from .validate import run_docker_validation
+from .validate import ComposeDockerValidator
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -18,6 +18,7 @@ class ComposeModule(Module):
 
 
     name = "compose"
     name = "compose"
     description = "Manage Docker Compose configurations"
     description = "Manage Docker Compose configurations"
+    kind_validator_class = ComposeDockerValidator
 
 
     def validate(  # noqa: PLR0913
     def validate(  # noqa: PLR0913
         self,
         self,
@@ -30,34 +31,64 @@ class ComposeModule(Module):
             str | None,
             str | None,
             Option("--path", help="Path to template directory for validation"),
             Option("--path", help="Path to template directory for validation"),
         ] = None,
         ] = None,
+        all_templates: Annotated[
+            bool,
+            Option("--all", help="Validate all Compose templates (default when no template ID is provided)"),
+        ] = False,
         verbose: Annotated[bool, Option("--verbose", "-v", help="Show detailed validation information")] = False,
         verbose: Annotated[bool, Option("--verbose", "-v", help="Show detailed validation information")] = False,
         semantic: Annotated[
         semantic: Annotated[
             bool,
             bool,
             Option(
             Option(
                 "--semantic/--no-semantic",
                 "--semantic/--no-semantic",
-                help="Enable semantic validation (Docker Compose config, YAML structure, etc.)",
+                help="Enable semantic validation for rendered files",
             ),
             ),
         ] = True,
         ] = True,
+        matrix: Annotated[
+            bool,
+            Option(
+                "--matrix",
+                help="Validate all reachable dependency states for a single template",
+            ),
+        ] = False,
+        kind: Annotated[
+            bool,
+            Option(
+                "--kind",
+                help="Enable dependency-matrix Docker Compose validation",
+            ),
+        ] = False,
         docker: Annotated[
         docker: Annotated[
             bool,
             bool,
             Option(
             Option(
                 "--docker/--no-docker",
                 "--docker/--no-docker",
-                help="Enable Docker Compose validation using 'docker compose config'",
+                help="Alias for --kind Docker Compose validation",
             ),
             ),
         ] = False,
         ] = False,
         docker_test_all: Annotated[
         docker_test_all: Annotated[
             bool,
             bool,
             Option(
             Option(
                 "--docker-test-all",
                 "--docker-test-all",
-                help="Test all variable combinations (minimal, maximal, each toggle). Requires --docker",
+                help="Alias for --matrix --kind Docker Compose validation. Requires --docker.",
             ),
             ),
         ] = False,
         ] = False,
     ) -> None:
     ) -> None:
         """Validate Compose templates."""
         """Validate Compose templates."""
-        validate_templates(self, template_id, path, verbose, semantic)
-
-        if docker and (template_id or path):
-            run_docker_validation(self, template_id, path, docker_test_all, verbose)
+        kind_enabled = kind or docker or docker_test_all
+        matrix_enabled = matrix or docker_test_all
+        kind_validator = self.kind_validator_class(verbose).validate_rendered_files if kind_enabled else None
+        validate_templates(
+            self,
+            template_id,
+            path,
+            ValidationConfig(
+                verbose=verbose,
+                semantic=semantic,
+                matrix=matrix_enabled,
+                kind=kind_enabled,
+                all_templates=all_templates,
+                kind_validator=kind_validator,
+            ),
+        )
 
 
 
 
 registry.register(ComposeModule)
 registry.register(ComposeModule)

+ 72 - 236
cli/modules/compose/validate.py

@@ -1,252 +1,88 @@
 """Docker Compose validation functionality."""
 """Docker Compose validation functionality."""
 
 
 import logging
 import logging
+import shutil
 import subprocess
 import subprocess
 import tempfile
 import tempfile
 from pathlib import Path
 from pathlib import Path
 
 
-from typer import Exit
-
-from ...core.template import Template
+from ...core.validation import KindValidationFailure, KindValidationResult
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
 
 
-def run_docker_validation(
-    module_instance,
-    template_id: str | None,
-    path: str | None,
-    test_all: bool,
-    verbose: bool,
-) -> None:
-    """Run Docker Compose validation using docker compose config.
-
-    Args:
-        module_instance: The module instance (for display and template loading)
-        template_id: Template ID to validate
-        path: Path to template directory
-        test_all: Test all variable combinations
-        verbose: Show detailed output
-
-    Raises:
-        Exit: If validation fails or docker is not available
-    """
-    try:
-        # Load the template
-        if path:
-            template_path = Path(path).resolve()
-            template = Template(template_path, library_name="local")
-        else:
-            template = module_instance._load_template_by_id(template_id)
-
-        module_instance.display.info("")
-        module_instance.display.info("Running Docker Compose validation...")
-
-        # Test multiple combinations or single configuration
-        if test_all:
-            _test_variable_combinations(module_instance, template, verbose)
-        else:
-            # Single configuration with template defaults
-            success = _validate_compose_files(
-                module_instance, template, template.variables, verbose, "Template defaults"
-            )
-            if success:
-                module_instance.display.success("Docker Compose validation passed")
-            else:
-                module_instance.display.error("Docker Compose validation failed")
-                raise Exit(code=1) from None
-
-    except FileNotFoundError as e:
-        module_instance.display.error(
-            "Docker Compose CLI not found",
-            context="Install Docker Desktop or Docker Engine with Compose plugin",
+class ComposeDockerValidator:
+    """Kind-specific validator backed by Docker Compose."""
+
+    validator_name = "docker compose config"
+    unavailable_message = "Required command is unavailable: docker compose"
+
+    def __init__(self, verbose: bool = False) -> None:
+        self.verbose = verbose
+        self._available: bool | None = None
+
+    def is_available(self) -> bool:
+        """Check whether Docker Compose is available locally."""
+        if self._available is not None:
+            return self._available
+
+        if shutil.which("docker") is None:
+            self._available = False
+            return self._available
+
+        result = subprocess.run(
+            ["docker", "compose", "version"],
+            capture_output=True,
+            text=True,
+            check=False,
         )
         )
-        raise Exit(code=1) from e
-    except Exception as e:
-        module_instance.display.error(f"Docker validation failed: {e}")
-        raise Exit(code=1) from e
-
-
-def _validate_compose_files(module_instance, template, variables, verbose: bool, config_name: str) -> bool:
-    """Validate rendered compose files using docker compose config.
-
-    Args:
-        module_instance: The module instance
-        template: The template object
-        variables: VariableCollection with configured values
-        verbose: Show detailed output
-        config_name: Name of this configuration (for display)
-
-    Returns:
-        True if validation passed, False otherwise
-    """
-    try:
-        # Render the template
-        debug_mode = logger.isEnabledFor(logging.DEBUG)
-        rendered_files, _ = template.render(variables, debug=debug_mode)
-
-        # Find compose files
-        compose_files = [
-            (filename, content)
-            for filename, content in rendered_files.items()
-            if filename.endswith(("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"))
-        ]
+        self._available = result.returncode == 0
+        return self._available
 
 
+    def validate_rendered_files(self, rendered_files: dict[str, str], _case_name: str) -> KindValidationResult:
+        """Validate rendered Compose files with Docker Compose."""
+        result = KindValidationResult(validator=self.validator_name, available=self.is_available())
+        if not result.available:
+            result.details.append(self.unavailable_message)
+            return result
+
+        compose_files = _find_compose_files(rendered_files)
         if not compose_files:
         if not compose_files:
-            module_instance.display.warning(f"[{config_name}] No Docker Compose files found")
-            return True
+            result.warnings.append("No Docker Compose files found")
+            return result
 
 
-        # Validate each compose file
-        has_errors = False
         for filename, content in compose_files:
         for filename, content in compose_files:
-            if verbose:
-                module_instance.display.info(f"[{config_name}] Validating: {filename}")
-
-            # Write to temporary file
-            with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp_file:
-                tmp_file.write(content)
-                tmp_path = tmp_file.name
-
-            try:
-                # Run docker compose config
-                result = subprocess.run(
-                    ["docker", "compose", "-f", tmp_path, "config", "--quiet"],
-                    capture_output=True,
-                    text=True,
-                    check=False,
-                )
-
-                if result.returncode != 0:
-                    has_errors = True
-                    module_instance.display.error(f"[{config_name}] Docker validation failed for {filename}")
-                    if result.stderr:
-                        module_instance.display.info(f"\n{result.stderr}")
-                elif verbose:
-                    module_instance.display.success(f"[{config_name}] Docker validation passed: {filename}")
-
-            finally:
-                # Clean up temporary file
-                Path(tmp_path).unlink(missing_ok=True)
-
-        return not has_errors
-
-    except Exception as e:
-        module_instance.display.error(f"[{config_name}] Validation failed: {e}")
-        return False
-
-
-def _test_variable_combinations(module_instance, template, verbose: bool) -> None:
-    """Test multiple variable combinations intelligently.
-
-    Tests:
-    1. Minimal config (all toggles OFF)
-    2. Maximal config (all toggles ON)
-    3. Each toggle individually ON (to isolate toggle-specific issues)
-
-    Args:
-        module_instance: The module instance
-        template: The template object
-        verbose: Show detailed output
-
-    Raises:
-        Exit: If any validation fails
-    """
-    module_instance.display.info("Testing multiple variable combinations...")
-    module_instance.display.info("")
-
-    # Find all boolean toggle variables
-    toggle_vars = _find_toggle_variables(template)
-
-    if not toggle_vars:
-        module_instance.display.warning("No toggle variables found - testing default configuration only")
-        success = _validate_compose_files(module_instance, template, template.variables, verbose, "Default")
-        if not success:
-            raise Exit(code=1) from None
-        module_instance.display.success("Docker Compose validation passed")
-        return
-
-    module_instance.display.info(f"Found {len(toggle_vars)} toggle variable(s): {', '.join(toggle_vars)}")
-    module_instance.display.info("")
-
-    all_passed = True
-    test_count = 0
-
-    # Test 1: Minimal (all OFF)
-    module_instance.display.info("[1/3] Testing minimal configuration (all toggles OFF)...")
-    toggle_config = dict.fromkeys(toggle_vars, False)
-    variables = _get_variables_with_toggles(module_instance, template, toggle_config)
-    if not _validate_compose_files(module_instance, template, variables, verbose, "Minimal"):
-        all_passed = False
-    test_count += 1
-    module_instance.display.info("")
-
-    # Test 2: Maximal (all ON)
-    module_instance.display.info("[2/3] Testing maximal configuration (all toggles ON)...")
-    toggle_config = dict.fromkeys(toggle_vars, True)
-    variables = _get_variables_with_toggles(module_instance, template, toggle_config)
-    if not _validate_compose_files(module_instance, template, variables, verbose, "Maximal"):
-        all_passed = False
-    test_count += 1
-    module_instance.display.info("")
-
-    # Test 3: Each toggle individually
-    module_instance.display.info(f"[3/3] Testing each toggle individually ({len(toggle_vars)} tests)...")
-    for i, toggle in enumerate(toggle_vars, 1):
-        # Set all OFF except the current one
-        toggle_config = {t: t == toggle for t in toggle_vars}
-        variables = _get_variables_with_toggles(module_instance, template, toggle_config)
-        config_name = f"{toggle}=true"
-        if not _validate_compose_files(module_instance, template, variables, verbose, config_name):
-            all_passed = False
-        test_count += 1
-        if verbose and i < len(toggle_vars):
-            module_instance.display.info("")
-
-    # Summary
-    module_instance.display.info("")
-    module_instance.display.info("─" * 80)
-    if all_passed:
-        module_instance.display.success(f"All {test_count} configuration(s) passed Docker Compose validation")
-    else:
-        module_instance.display.error("Some configurations failed Docker Compose validation")
-        raise Exit(code=1) from None
-
-
-def _find_toggle_variables(template) -> list[str]:
-    """Find all boolean toggle variables in a template.
-
-    Args:
-        template: The template object
-
-    Returns:
-        List of toggle variable names
-    """
-    toggle_vars = []
-    for var_name, var in template.variables._variable_map.items():
-        if var.type == "bool" and var_name.endswith("_enabled"):
-            toggle_vars.append(var_name)
-    return sorted(toggle_vars)
-
-
-def _get_variables_with_toggles(module_instance, template, toggle_config: dict[str, bool]):  # noqa: ARG001
-    """Get VariableCollection with specific toggle settings.
-
-    Args:
-        module_instance: The module instance (unused, for signature consistency)
-        template: The template object
-        toggle_config: Dict mapping toggle names to boolean values
-
-    Returns:
-        VariableCollection with configured toggle values
-    """
-    # Reload template to get fresh VariableCollection
-    # (template.variables is mutated by previous calls)
-    fresh_template = Template(template.template_dir, library_name=template.metadata.library)
-    variables = fresh_template.variables
-
-    # Apply toggle configuration
-    for toggle_name, toggle_value in toggle_config.items():
-        if toggle_name in variables._variable_map:
-            variables._variable_map[toggle_name].value = toggle_value
-
-    return variables
+            failure = self._validate_compose_content(filename, content)
+            if failure is not None:
+                result.failures.append(failure)
+
+        return result
+
+    def _validate_compose_content(self, filename: str, content: str) -> KindValidationFailure | None:
+        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp_file:
+            tmp_file.write(content)
+            tmp_path = tmp_file.name
+
+        try:
+            result = subprocess.run(
+                ["docker", "compose", "-f", tmp_path, "config", "--quiet"],
+                capture_output=True,
+                text=True,
+                check=False,
+            )
+        finally:
+            Path(tmp_path).unlink(missing_ok=True)
+
+        if result.returncode == 0:
+            return None
+
+        message = result.stderr.strip() or result.stdout.strip() or "Docker Compose validation failed"
+        return KindValidationFailure(file_path=filename, validator=self.validator_name, message=message)
+
+
+def _find_compose_files(rendered_files: dict[str, str]) -> list[tuple[str, str]]:
+    return [
+        (filename, content)
+        for filename, content in rendered_files.items()
+        if filename.endswith(("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"))
+    ]

+ 2 - 0
cli/modules/helm/__init__.py

@@ -2,6 +2,7 @@
 
 
 from ...core.module import Module
 from ...core.module import Module
 from ...core.registry import registry
 from ...core.registry import registry
+from ...core.validation import HelmValidator
 
 
 
 
 class HelmModule(Module):
 class HelmModule(Module):
@@ -9,6 +10,7 @@ class HelmModule(Module):
 
 
     name = "helm"
     name = "helm"
     description = "Manage Helm configurations"
     description = "Manage Helm configurations"
+    kind_validator_class = HelmValidator
 
 
 
 
 registry.register(HelmModule)
 registry.register(HelmModule)

+ 2 - 0
cli/modules/kubernetes/__init__.py

@@ -2,6 +2,7 @@
 
 
 from ...core.module import Module
 from ...core.module import Module
 from ...core.registry import registry
 from ...core.registry import registry
+from ...core.validation import KubernetesValidator
 
 
 
 
 class KubernetesModule(Module):
 class KubernetesModule(Module):
@@ -9,6 +10,7 @@ class KubernetesModule(Module):
 
 
     name = "kubernetes"
     name = "kubernetes"
     description = "Manage Kubernetes configurations"
     description = "Manage Kubernetes configurations"
+    kind_validator_class = KubernetesValidator
 
 
 
 
 registry.register(KubernetesModule)
 registry.register(KubernetesModule)

+ 2 - 0
cli/modules/packer/__init__.py

@@ -2,6 +2,7 @@
 
 
 from ...core.module import Module
 from ...core.module import Module
 from ...core.registry import registry
 from ...core.registry import registry
+from ...core.validation import PackerValidator
 
 
 
 
 class PackerModule(Module):
 class PackerModule(Module):
@@ -9,6 +10,7 @@ class PackerModule(Module):
 
 
     name = "packer"
     name = "packer"
     description = "Manage Packer configurations"
     description = "Manage Packer configurations"
+    kind_validator_class = PackerValidator
 
 
 
 
 registry.register(PackerModule)
 registry.register(PackerModule)

+ 40 - 9
cli/modules/swarm/__init__.py

@@ -6,9 +6,9 @@ from typing import Annotated
 from typer import Argument, Option
 from typer import Argument, Option
 
 
 from ...core.module import Module
 from ...core.module import Module
-from ...core.module.base_commands import validate_templates
+from ...core.module.base_commands import ValidationConfig, validate_templates
 from ...core.registry import registry
 from ...core.registry import registry
-from ..compose.validate import run_docker_validation
+from ..compose.validate import ComposeDockerValidator
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -18,6 +18,7 @@ class SwarmModule(Module):
 
 
     name = "swarm"
     name = "swarm"
     description = "Manage Docker Swarm stack templates"
     description = "Manage Docker Swarm stack templates"
+    kind_validator_class = ComposeDockerValidator
 
 
     def validate(  # noqa: PLR0913
     def validate(  # noqa: PLR0913
         self,
         self,
@@ -30,34 +31,64 @@ class SwarmModule(Module):
             str | None,
             str | None,
             Option("--path", help="Path to template directory for validation"),
             Option("--path", help="Path to template directory for validation"),
         ] = None,
         ] = None,
+        all_templates: Annotated[
+            bool,
+            Option("--all", help="Validate all Swarm templates (default when no template ID is provided)"),
+        ] = False,
         verbose: Annotated[bool, Option("--verbose", "-v", help="Show detailed validation information")] = False,
         verbose: Annotated[bool, Option("--verbose", "-v", help="Show detailed validation information")] = False,
         semantic: Annotated[
         semantic: Annotated[
             bool,
             bool,
             Option(
             Option(
                 "--semantic/--no-semantic",
                 "--semantic/--no-semantic",
-                help="Enable semantic validation (Docker Compose config, YAML structure, etc.)",
+                help="Enable semantic validation for rendered files",
             ),
             ),
         ] = True,
         ] = True,
+        matrix: Annotated[
+            bool,
+            Option(
+                "--matrix",
+                help="Validate all reachable dependency states for a single template",
+            ),
+        ] = False,
+        kind: Annotated[
+            bool,
+            Option(
+                "--kind",
+                help="Enable dependency-matrix Docker Compose validation",
+            ),
+        ] = False,
         docker: Annotated[
         docker: Annotated[
             bool,
             bool,
             Option(
             Option(
                 "--docker/--no-docker",
                 "--docker/--no-docker",
-                help="Enable Docker Compose validation using 'docker compose config'",
+                help="Alias for --kind Docker Compose validation",
             ),
             ),
         ] = False,
         ] = False,
         docker_test_all: Annotated[
         docker_test_all: Annotated[
             bool,
             bool,
             Option(
             Option(
                 "--docker-test-all",
                 "--docker-test-all",
-                help="Test all variable combinations (minimal, maximal, each toggle). Requires --docker",
+                help="Alias for --matrix --kind Docker Compose validation. Requires --docker.",
             ),
             ),
         ] = False,
         ] = False,
     ) -> None:
     ) -> None:
         """Validate Swarm templates."""
         """Validate Swarm templates."""
-        validate_templates(self, template_id, path, verbose, semantic)
-
-        if docker and (template_id or path):
-            run_docker_validation(self, template_id, path, docker_test_all, verbose)
+        kind_enabled = kind or docker or docker_test_all
+        matrix_enabled = matrix or docker_test_all
+        kind_validator = self.kind_validator_class(verbose).validate_rendered_files if kind_enabled else None
+        validate_templates(
+            self,
+            template_id,
+            path,
+            ValidationConfig(
+                verbose=verbose,
+                semantic=semantic,
+                matrix=matrix_enabled,
+                kind=kind_enabled,
+                all_templates=all_templates,
+                kind_validator=kind_validator,
+            ),
+        )
 
 
 
 
 registry.register(SwarmModule)
 registry.register(SwarmModule)

+ 2 - 0
cli/modules/terraform/__init__.py

@@ -2,6 +2,7 @@
 
 
 from ...core.module import Module
 from ...core.module import Module
 from ...core.registry import registry
 from ...core.registry import registry
+from ...core.validation import TerraformValidator
 
 
 
 
 class TerraformModule(Module):
 class TerraformModule(Module):
@@ -9,6 +10,7 @@ class TerraformModule(Module):
 
 
     name = "terraform"
     name = "terraform"
     description = "Manage Terraform configurations"
     description = "Manage Terraform configurations"
+    kind_validator_class = TerraformValidator
 
 
 
 
 registry.register(TerraformModule)
 registry.register(TerraformModule)

+ 75 - 1
tests/test_base_commands.py

@@ -4,7 +4,15 @@ from __future__ import annotations
 
 
 from types import SimpleNamespace
 from types import SimpleNamespace
 
 
-from cli.core.module.base_commands import GenerationConfig, apply_output_name, generate_template, list_templates
+from cli.core.module.base_commands import (
+    GenerationConfig,
+    ValidationConfig,
+    _validate_all_templates,
+    apply_output_name,
+    generate_template,
+    list_templates,
+    validate_templates,
+)
 
 
 
 
 def _noop(*_args, **_kwargs) -> None:
 def _noop(*_args, **_kwargs) -> None:
@@ -53,6 +61,16 @@ class _DisplayCapture:
         raise AssertionError("info should not be used when templates exist")
         raise AssertionError("info should not be used when templates exist")
 
 
 
 
+class _ValidationDisplayCapture(_DisplayCapture):
+    def data_table(self, *args, **kwargs) -> None:
+        del args, kwargs
+        self.lines.append("data_table")
+
+    def info(self, value: str = "", *args, **kwargs) -> None:
+        del args, kwargs
+        self.lines.append(value)
+
+
 def test_list_templates_raw_outputs_tab_separated_rows() -> None:
 def test_list_templates_raw_outputs_tab_separated_rows() -> None:
     """Raw listing should emit one tab-separated row per template."""
     """Raw listing should emit one tab-separated row per template."""
     template = SimpleNamespace(
     template = SimpleNamespace(
@@ -161,3 +179,59 @@ def test_generate_template_dry_run_skips_destination_prompt_and_overwrite_check(
 
 
     assert any("boilerplate rendered successfully" in line for line in display.lines)
     assert any("boilerplate rendered successfully" in line for line in display.lines)
     assert any("preview only" in line for line in display.lines)
     assert any("preview only" in line for line in display.lines)
+
+
+def test_validate_templates_without_id_validates_all_templates(monkeypatch) -> None:
+    """Omitting the template ID should keep the legacy validate-all behavior."""
+    display = _ValidationDisplayCapture()
+    module_instance = SimpleNamespace(name="compose", display=display)
+    called = {}
+
+    def capture_validate_all(received_module, received_config):
+        called["module"] = received_module
+        called["config"] = received_config
+
+    monkeypatch.setattr("cli.core.module.base_commands._validate_all_templates", capture_validate_all)
+
+    validate_templates(module_instance, None, None, ValidationConfig(verbose=False))
+
+    assert called["module"] is module_instance
+    assert called["config"].semantic is True
+
+
+def test_validate_single_template_runs_default_semantic_validation(monkeypatch) -> None:
+    """Single-template validation should still run semantic validation by default."""
+    display = _ValidationDisplayCapture()
+    template = SimpleNamespace(id="whoami", used_variables=[], variables=SimpleNamespace())
+    module_instance = SimpleNamespace(name="compose", display=display)
+    called = {}
+
+    monkeypatch.setattr("cli.core.module.base_commands._load_template_for_validation", lambda *_args: template)
+
+    def capture_semantic(received_module, received_template, verbose):
+        called["module"] = received_module
+        called["template"] = received_template
+        called["verbose"] = verbose
+
+    monkeypatch.setattr("cli.core.module.base_commands._run_semantic_validation", capture_semantic)
+
+    validate_templates(module_instance, "whoami", None, ValidationConfig(verbose=False))
+
+    assert called == {"module": module_instance, "template": template, "verbose": False}
+
+
+def test_validate_all_templates_keeps_basic_validation_by_default(monkeypatch) -> None:
+    """Validate-all should not start rendering every template semantically unless matrix/kind is requested."""
+    display = _ValidationDisplayCapture()
+    template = SimpleNamespace(id="whoami", used_variables=[], variables=SimpleNamespace())
+    module_instance = SimpleNamespace(
+        name="compose",
+        display=display,
+        _load_all_templates=lambda: [template],
+    )
+
+    monkeypatch.setattr("cli.core.module.base_commands._run_semantic_validation", _raise_output_check)
+
+    _validate_all_templates(module_instance, ValidationConfig(verbose=False))
+
+    assert any("All templates are valid" in line for line in display.lines)

+ 182 - 0
tests/test_dependency_matrix.py

@@ -0,0 +1,182 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from cli.core.template import Template
+from cli.core.validation import (
+    AnsibleValidator,
+    DependencyMatrixBuilder,
+    KindValidationResult,
+    MatrixOptions,
+    ValidationRunner,
+)
+
+
+def _write_template(tmp_path: Path, manifest: dict, files: dict[str, str]) -> Template:
+    template_dir = tmp_path / "sample-compose"
+    files_dir = template_dir / "files"
+    files_dir.mkdir(parents=True)
+
+    (template_dir / "template.json").write_text(json.dumps(manifest), encoding="utf-8")
+    for relative_path, content in files.items():
+        output_path = files_dir / relative_path
+        output_path.parent.mkdir(parents=True, exist_ok=True)
+        output_path.write_text(content, encoding="utf-8")
+
+    return Template(template_dir, library_name="test", library_type="static")
+
+
+def test_dependency_matrix_covers_bool_and_enum_branches(tmp_path: Path) -> None:
+    template = _write_template(
+        tmp_path,
+        {
+            "kind": "compose",
+            "slug": "sample-compose",
+            "metadata": {
+                "name": "Sample",
+                "description": "Sample",
+                "author": "test",
+                "date": "2026-01-01",
+            },
+            "variables": [
+                {
+                    "name": "general",
+                    "title": "General",
+                    "items": [
+                        {"name": "service_name", "type": "str", "default": "app"},
+                        {
+                            "name": "network_mode",
+                            "type": "enum",
+                            "default": "bridge",
+                            "config": {"options": ["bridge", "host", "macvlan"]},
+                        },
+                    ],
+                },
+                {
+                    "name": "traefik",
+                    "title": "Traefik",
+                    "toggle": "traefik_enabled",
+                    "needs": "network_mode=bridge,macvlan",
+                    "items": [
+                        {"name": "traefik_enabled", "type": "bool", "default": False},
+                        {"name": "traefik_host", "type": "str", "default": "app.example.com"},
+                    ],
+                },
+            ],
+        },
+        {
+            "compose.yaml": """
+services:
+  << service_name >>:
+    image: nginx:1.25.3
+<% if traefik_enabled %>
+    labels:
+      - traefik.http.routers.<< service_name >>.rule=Host(`<< traefik_host >>`)
+<% endif %>
+""",
+        },
+    )
+
+    cases = DependencyMatrixBuilder(template, MatrixOptions(max_combinations=20)).build()
+    rendered_values = [case.variables.get_satisfied_values() for case in cases]
+
+    assert any(values.get("network_mode") == "bridge" for values in rendered_values)
+    assert any(values.get("network_mode") == "macvlan" for values in rendered_values)
+    assert any(values.get("traefik_enabled") is True for values in rendered_values)
+    assert any(case.overrides.get("traefik_enabled") is False for case in cases)
+
+
+def test_validation_runner_reports_semantic_failure_for_matrix_case(tmp_path: Path) -> None:
+    template = _write_template(
+        tmp_path,
+        {
+            "kind": "compose",
+            "slug": "broken-compose",
+            "metadata": {
+                "name": "Broken",
+                "description": "Broken",
+                "author": "test",
+                "date": "2026-01-01",
+            },
+            "variables": [
+                {
+                    "name": "general",
+                    "title": "General",
+                    "items": [
+                        {"name": "service_name", "type": "str", "default": "app"},
+                        {"name": "invalid_enabled", "type": "bool", "default": False},
+                    ],
+                }
+            ],
+        },
+        {
+            "compose.yaml": """
+<% if invalid_enabled %>
+services: []
+<% else %>
+services:
+  << service_name >>:
+    image: nginx:1.25.3
+<% endif %>
+""",
+        },
+    )
+
+    cases = DependencyMatrixBuilder(template, MatrixOptions(max_combinations=10)).build()
+    summary = ValidationRunner(template, cases, semantic=True).run()
+
+    assert not summary.ok
+    assert any(failure.stage == "sem" and failure.file_path == "compose.yaml" for failure in summary.failures)
+
+
+def test_validation_runner_treats_unavailable_kind_validator_as_skip(tmp_path: Path) -> None:
+    template = _write_template(
+        tmp_path,
+        {
+            "kind": "custom",
+            "slug": "custom-template",
+            "metadata": {
+                "name": "Custom",
+                "description": "Custom",
+                "author": "test",
+                "date": "2026-01-01",
+            },
+            "variables": [
+                {
+                    "name": "general",
+                    "title": "General",
+                    "items": [{"name": "service_name", "type": "str", "default": "app"}],
+                }
+            ],
+        },
+        {"config.yaml": "name: << service_name >>\n"},
+    )
+
+    def unavailable_validator(_rendered_files: dict[str, str], _case_name: str) -> KindValidationResult:
+        return KindValidationResult(
+            validator="missing-tool",
+            available=False,
+            details=["Required command is unavailable: missing-tool"],
+        )
+
+    cases = DependencyMatrixBuilder(template, MatrixOptions(max_combinations=10)).build()
+    summary = ValidationRunner(template, cases, semantic=True, kind_validator=unavailable_validator).run()
+
+    assert summary.ok
+    assert summary.kind_available is False
+    assert summary.kind_skipped_cases == {"defaults"}
+    assert summary.failures == []
+
+
+def test_ansible_validator_detects_main_yml_playbook_by_hosts_key(tmp_path: Path) -> None:
+    playbook = tmp_path / "main.yml"
+    playbook.write_text("- name: Configure host\n  hosts: all\n  tasks: []\n", encoding="utf-8")
+
+    assert AnsibleValidator._find_playbooks(tmp_path) == [playbook]
+
+
+def test_ansible_validator_classifies_missing_collection_as_dependency_resolution_failure() -> None:
+    assert AnsibleValidator._is_dependency_resolution_failure("ERROR! the role 'vendor.role' was not found")
+    assert AnsibleValidator._is_dependency_resolution_failure("ERROR! couldn't resolve module/action 'vendor.module'")
+    assert not AnsibleValidator._is_dependency_resolution_failure("ERROR! Syntax Error while loading YAML")

+ 25 - 13
tests/test_template.py

@@ -8,7 +8,7 @@ from pathlib import Path
 
 
 import pytest
 import pytest
 
 
-from cli.core.exceptions import TemplateLoadError, TemplateValidationError
+from cli.core.exceptions import TemplateLoadError
 from cli.core.library import Library
 from cli.core.library import Library
 from cli.core.template import Template, normalize_template_slug
 from cli.core.template import Template, normalize_template_slug
 
 
@@ -244,17 +244,17 @@ def test_legacy_template_yaml_is_rejected(tmp_path: Path) -> None:
         Template(template_dir, library_name="default")
         Template(template_dir, library_name="default")
 
 
 
 
-def test_legacy_jinja_delimiters_are_rejected(tmp_path: Path) -> None:
-    """files/ content must use the new custom delimiters."""
-    template_dir = tmp_path / "compose" / "legacy-delimiters"
+def test_raw_jinja_delimiters_are_allowed_in_rendered_files(tmp_path: Path) -> None:
+    """Rendered files may contain raw Jinja syntax for downstream tools like Ansible."""
+    template_dir = tmp_path / "ansible" / "raw-jinja"
     _write_template_json(
     _write_template_json(
         template_dir,
         template_dir,
         {
         {
-            "slug": "legacy-delimiters",
-            "kind": "compose",
+            "slug": "raw-jinja",
+            "kind": "ansible",
             "metadata": {
             "metadata": {
-                "name": "Legacy delimiters",
-                "description": "Bad template",
+                "name": "Raw Jinja",
+                "description": "Ansible template",
                 "version": {
                 "version": {
                     "name": "v1.0.0",
                     "name": "v1.0.0",
                 },
                 },
@@ -265,23 +265,35 @@ def test_legacy_jinja_delimiters_are_rejected(tmp_path: Path) -> None:
                     "title": "General",
                     "title": "General",
                     "items": [
                     "items": [
                         {
                         {
-                            "name": "container_hostname",
+                            "name": "target_host",
                             "type": "str",
                             "type": "str",
-                            "title": "Container hostname",
+                            "title": "Target host",
+                            "default": "all",
                         }
                         }
                     ],
                     ],
                 }
                 }
             ],
             ],
         },
         },
         {
         {
-            "compose.yaml": "hostname: {{ container_hostname }}\n",
+            "playbook.yaml": (
+                "- hosts: << target_host >>\n"
+                "  tasks:\n"
+                "    - debug:\n"
+                '        msg: "{{ ansible_hostname }}"\n'
+                "    - name: Raw Ansible block delimiter remains literal\n"
+                "      debug:\n"
+                '        msg: "{% raw %}{{ value }}{% endraw %}"\n'
+            ),
         },
         },
     )
     )
 
 
     template = Template(template_dir, library_name="default")
     template = Template(template_dir, library_name="default")
+    rendered_files, _ = template.render(template.variables)
 
 
-    with pytest.raises(TemplateValidationError, match="Legacy Jinja delimiter"):
-        _ = template.used_variables
+    assert template.used_variables == {"target_host"}
+    assert "{{ ansible_hostname }}" in rendered_files["playbook.yaml"]
+    assert "{% raw %}{{ value }}{% endraw %}" in rendered_files["playbook.yaml"]
+    assert "- hosts: all" in rendered_files["playbook.yaml"]
 
 
 
 
 def test_template_json_rejects_string_metadata_version(tmp_path: Path) -> None:
 def test_template_json_rejects_string_metadata_version(tmp_path: Path) -> None: