Просмотр исходного кода

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

Add exhaustive dependency matrix validation
Christian Lempa 2 дней назад
Родитель
Сommit
38eded207a

+ 1 - 0
CHANGELOG.md

@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Added
 - Static template kind for technology-agnostic file and directory boilerplates (#1786)
 - 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)
 - 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)

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

@@ -3,7 +3,7 @@
 from __future__ import annotations
 
 import logging
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
 from pathlib import Path
 
 from typer import Exit
@@ -17,6 +17,7 @@ from ..exceptions import (
 )
 from ..input import InputManager
 from ..template import Template
+from ..validation import DependencyMatrixBuilder, MatrixOptions, ValidationRunner
 from ..validators import get_validator_registry
 from .generation_destination import (
     GenerationDestination,
@@ -56,6 +57,20 @@ class GenerationConfig:
     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:
     """List all templates."""
     logger.debug(f"Listing templates for module '{module_instance.name}'")
@@ -577,17 +592,20 @@ def validate_templates(
     module_instance,
     template_id: str,
     path: str | None,
-    verbose: bool,
-    semantic: bool,
+    config: ValidationConfig,
 ) -> None:
     """Validate templates for Jinja2 syntax, undefined variables, and semantic correctness."""
     # 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)
 
     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:
-        _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):
@@ -620,21 +638,31 @@ def _load_template_for_validation(module_instance, template_id: str, path: str |
     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."""
     try:
         # Jinja2 validation
         _ = template.used_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
-        if verbose:
-            _display_validation_details(module_instance, template, semantic)
+        if config.verbose:
+            _display_validation_details(module_instance, template, config.semantic)
 
     except TemplateRenderError as e:
         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.info(f"\n{e}")
         raise Exit(code=1) from None
+    except Exit:
+        raise
     except Exception as e:
         module_instance.display.error(f"Unexpected error validating '{template_id}': {e}")
         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:
-    """Run semantic validation on rendered template files."""
+    """Run semantic validation on the default rendered template files."""
     module_instance.display.info("")
     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")
 
 
-def _validate_all_templates(module_instance, verbose: bool) -> None:
+def _validate_all_templates(module_instance, config: ValidationConfig) -> None:
     """Validate all templates in the module."""
     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()
     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:
         try:
-            _ = template.used_variables
-            _ = template.variables
+            _validate_single_template(module_instance, template, template.id, child_config)
             valid_count += 1
-            if verbose:
+            if config.verbose:
                 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:
             invalid_count += 1
             errors.append((template.id, str(e)))
-            if verbose:
+            if config.verbose:
                 module_instance.display.error(template.id)
         except Exception as e:
             invalid_count += 1
             errors.append((template.id, f"Load error: {e}"))
-            if verbose:
+            if config.verbose:
                 module_instance.display.warning(template.id)
 
     # Display summary

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

@@ -13,6 +13,7 @@ from ..library import LibraryManager
 from ..template import Template
 from .base_commands import (
     GenerationConfig,
+    ValidationConfig,
     generate_template,
     list_templates,
     search_templates,
@@ -44,6 +45,7 @@ class Module(ABC):
     # Class attributes that must be defined by subclasses
     name: str
     description: str
+    kind_validator_class = None
 
     def __init__(self) -> None:
         # Validate required class attributes
@@ -260,28 +262,60 @@ class Module(ABC):
             str | None,
             Option("--path", help="Path to template directory for validation"),
         ] = 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,
         semantic: Annotated[
             bool,
             Option(
                 "--semantic/--no-semantic",
-                help="Enable semantic validation (Docker Compose config, YAML structure, etc.)",
+                help="Enable semantic validation for rendered files",
             ),
         ] = 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:
-        """Validate templates for Jinja2 syntax, undefined variables, and semantic correctness.
+        """Validate templates for syntax, rendered semantics, and optional dependency matrix checks.
 
         Examples:
             # Validate specific template
-            cli compose validate netbox
+            cli terraform validate cloudflare-dns-record
 
             # 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(
         self,

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

@@ -26,7 +26,6 @@ logger = logging.getLogger(__name__)
 TEMPLATE_MANIFEST_FILENAME = "template.json"
 LEGACY_TEMPLATE_FILENAMES = ("template.yaml", "template.yml")
 TEMPLATE_FILES_DIRNAME = "files"
-LEGACY_JINJA_DELIMITERS = ("{{", "{%", "{#")
 VARIABLE_START = "<<"
 VARIABLE_END = ">>"
 BLOCK_START = "<%"
@@ -351,34 +350,12 @@ class Template:
         template_files.sort(key=lambda item: str(item.relative_path))
         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]:
         """Extract undeclared variables from all files under files/."""
         used_variables: set[str] = set()
         syntax_errors = []
         self._variable_usage_map: dict[str, list[str]] = {}
 
-        self._validate_delimiters()
-
         for template_file in self.template_files:
             file_path = self.files_dir / template_file.relative_path
             try:

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

@@ -305,6 +305,8 @@ class Variable:
     ) -> VariableConfig:
         if config_input is None:
             config_data: dict[str, Any] = {}
+        elif isinstance(config_input, VariableConfig):
+            return config_input.clone()
         elif isinstance(config_input, dict):
             config_data = config_input.copy()
         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.registry import registry
+from ...core.validation import AnsibleValidator
 
 
 class AnsibleModule(Module):
@@ -9,6 +10,7 @@ class AnsibleModule(Module):
 
     name = "ansible"
     description = "Manage Ansible configurations"
+    kind_validator_class = AnsibleValidator
 
 
 registry.register(AnsibleModule)

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

@@ -6,9 +6,9 @@ from typing import Annotated
 from typer import Argument, Option
 
 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 .validate import run_docker_validation
+from .validate import ComposeDockerValidator
 
 logger = logging.getLogger(__name__)
 
@@ -18,6 +18,7 @@ class ComposeModule(Module):
 
     name = "compose"
     description = "Manage Docker Compose configurations"
+    kind_validator_class = ComposeDockerValidator
 
     def validate(  # noqa: PLR0913
         self,
@@ -30,34 +31,64 @@ class ComposeModule(Module):
             str | None,
             Option("--path", help="Path to template directory for validation"),
         ] = 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,
         semantic: Annotated[
             bool,
             Option(
                 "--semantic/--no-semantic",
-                help="Enable semantic validation (Docker Compose config, YAML structure, etc.)",
+                help="Enable semantic validation for rendered files",
             ),
         ] = 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[
             bool,
             Option(
                 "--docker/--no-docker",
-                help="Enable Docker Compose validation using 'docker compose config'",
+                help="Alias for --kind Docker Compose validation",
             ),
         ] = False,
         docker_test_all: Annotated[
             bool,
             Option(
                 "--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,
     ) -> None:
         """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)

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

@@ -1,252 +1,88 @@
 """Docker Compose validation functionality."""
 
 import logging
+import shutil
 import subprocess
 import tempfile
 from pathlib import Path
 
-from typer import Exit
-
-from ...core.template import Template
+from ...core.validation import KindValidationFailure, KindValidationResult
 
 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:
-            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:
-            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.registry import registry
+from ...core.validation import HelmValidator
 
 
 class HelmModule(Module):
@@ -9,6 +10,7 @@ class HelmModule(Module):
 
     name = "helm"
     description = "Manage Helm configurations"
+    kind_validator_class = HelmValidator
 
 
 registry.register(HelmModule)

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

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

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

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

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

@@ -6,9 +6,9 @@ from typing import Annotated
 from typer import Argument, Option
 
 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 ..compose.validate import run_docker_validation
+from ..compose.validate import ComposeDockerValidator
 
 logger = logging.getLogger(__name__)
 
@@ -18,6 +18,7 @@ class SwarmModule(Module):
 
     name = "swarm"
     description = "Manage Docker Swarm stack templates"
+    kind_validator_class = ComposeDockerValidator
 
     def validate(  # noqa: PLR0913
         self,
@@ -30,34 +31,64 @@ class SwarmModule(Module):
             str | None,
             Option("--path", help="Path to template directory for validation"),
         ] = 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,
         semantic: Annotated[
             bool,
             Option(
                 "--semantic/--no-semantic",
-                help="Enable semantic validation (Docker Compose config, YAML structure, etc.)",
+                help="Enable semantic validation for rendered files",
             ),
         ] = 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[
             bool,
             Option(
                 "--docker/--no-docker",
-                help="Enable Docker Compose validation using 'docker compose config'",
+                help="Alias for --kind Docker Compose validation",
             ),
         ] = False,
         docker_test_all: Annotated[
             bool,
             Option(
                 "--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,
     ) -> None:
         """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)

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

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

+ 75 - 1
tests/test_base_commands.py

@@ -4,7 +4,15 @@ from __future__ import annotations
 
 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:
@@ -53,6 +61,16 @@ class _DisplayCapture:
         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:
     """Raw listing should emit one tab-separated row per template."""
     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("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
 
-from cli.core.exceptions import TemplateLoadError, TemplateValidationError
+from cli.core.exceptions import TemplateLoadError
 from cli.core.library import Library
 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")
 
 
-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(
         template_dir,
         {
-            "slug": "legacy-delimiters",
-            "kind": "compose",
+            "slug": "raw-jinja",
+            "kind": "ansible",
             "metadata": {
-                "name": "Legacy delimiters",
-                "description": "Bad template",
+                "name": "Raw Jinja",
+                "description": "Ansible template",
                 "version": {
                     "name": "v1.0.0",
                 },
@@ -265,23 +265,35 @@ def test_legacy_jinja_delimiters_are_rejected(tmp_path: Path) -> None:
                     "title": "General",
                     "items": [
                         {
-                            "name": "container_hostname",
+                            "name": "target_host",
                             "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")
+    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: