Prechádzať zdrojové kódy

validation firt pass

ChristianLempa 1 týždeň pred
rodič
commit
83d51899ae

+ 156 - 46
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,7 +17,7 @@ from ..exceptions import (
 )
 from ..input import InputManager
 from ..template import Template
-from ..validators import get_validator_registry
+from ..validation import DependencyMatrixBuilder, MatrixOptions, ValidationRunner
 from .generation_destination import (
     GenerationDestination,
     format_remote_destination,
@@ -56,6 +56,19 @@ class GenerationConfig:
     name: str | None = None
 
 
+@dataclass
+class ValidationConfig:
+    """Configuration for template validation."""
+
+    verbose: bool
+    semantic: 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 +590,24 @@ 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
+
+    if not config.all_templates and not template_id and not path:
+        module_instance.display.error("Provide a template ID, --path, or --all")
+        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 +640,27 @@ 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.semantic or config.kind:
+            _run_matrix_validation(module_instance, template, config)
+            return
 
         # Verbose output
-        if verbose:
-            _display_validation_details(module_instance, template, semantic)
+        if config.verbose:
+            _display_validation_details(module_instance, template)
 
     except TemplateRenderError as e:
         module_instance.display.error(str(e), context=f"template '{template_id}'")
@@ -643,49 +669,128 @@ 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_semantic_validation(module_instance, template, verbose: bool) -> None:
-    """Run semantic validation on rendered template files."""
+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 semantic validation...")
-
-    registry = get_validator_registry()
-    debug_mode = logger.isEnabledFor(logging.DEBUG)
-    rendered_files, _ = template.render(template.variables, debug=debug_mode)
-
-    has_semantic_errors = False
-    for file_path, content in rendered_files.items():
-        result = registry.validate_file(content, file_path)
-
-        if result.errors or result.warnings or (verbose and result.info):
-            module_instance.display.info(f"\nFile: {file_path}")
-            result.display(f"{file_path}")
+    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 result.errors:
-                has_semantic_errors = True
+    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 has_semantic_errors:
-        module_instance.display.error("Semantic validation found errors")
+    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("Semantic validation passed")
-
-
-def _display_validation_details(module_instance, template, semantic: bool) -> 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 _display_validation_details(module_instance, template) -> None:
     """Display verbose validation details."""
     module_instance.display.info(f"\nTemplate path: {template.template_dir}")
     module_instance.display.info(f"Found {len(template.used_variables)} variables")
-    if semantic:
-        debug_mode = logger.isEnabledFor(logging.DEBUG)
-        rendered_files, _ = template.render(template.variables, debug=debug_mode)
-        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 +800,28 @@ def _validate_all_templates(module_instance, verbose: bool) -> None:
 
     all_templates = module_instance._load_all_templates()
     total = len(all_templates)
+    child_config = replace(config, 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

+ 36 - 10
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
@@ -253,35 +255,59 @@ class Module(ABC):
         self,
         template_id: Annotated[
             str | None,
-            Argument(help="Template ID to validate (omit to validate all templates)"),
+            Argument(help="Template ID to validate"),
         ] = None,
         *,
         path: Annotated[
             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"),
+        ] = 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.)",
+                "--semantic",
+                help="Enable dependency-matrix semantic validation",
             ),
-        ] = True,
+        ] = 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 kind-specific checks.
 
         Examples:
             # Validate specific template
-            cli compose validate netbox
+            cli terraform validate cloudflare-dns-record
 
             # Validate all templates
-            cli compose validate
+            cli terraform validate --all
 
-            # Validate with verbose output
-            cli compose validate netbox --verbose
+            # Validate rendered semantic and kind-specific matrix cases
+            cli terraform validate cloudflare-dns-record --semantic --kind
         """
-        return validate_templates(self, template_id, path, verbose, semantic)
+        return validate_templates(
+            self,
+            template_id,
+            path,
+            ValidationConfig(
+                verbose=verbose,
+                semantic=semantic,
+                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,

+ 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

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

@@ -0,0 +1,243 @@
+"""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:
+                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():
+                candidates.append(path)
+        return candidates

+ 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)

+ 26 - 19
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,46 +18,53 @@ class ComposeModule(Module):
 
     name = "compose"
     description = "Manage Docker Compose configurations"
+    kind_validator_class = ComposeDockerValidator
 
     def validate(  # noqa: PLR0913
         self,
         template_id: Annotated[
             str | None,
-            Argument(help="Template ID to validate (omit to validate all templates)"),
+            Argument(help="Template ID to validate"),
         ] = None,
         *,
         path: Annotated[
             str | None,
             Option("--path", help="Path to template directory for validation"),
         ] = None,
+        all_templates: Annotated[
+            bool,
+            Option("--all", help="Validate all Compose templates"),
+        ] = 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.)",
-            ),
-        ] = True,
-        docker: Annotated[
-            bool,
-            Option(
-                "--docker/--no-docker",
-                help="Enable Docker Compose validation using 'docker compose config'",
+                "--semantic",
+                help="Enable dependency-matrix semantic validation",
             ),
         ] = False,
-        docker_test_all: Annotated[
+        kind: Annotated[
             bool,
             Option(
-                "--docker-test-all",
-                help="Test all variable combinations (minimal, maximal, each toggle). Requires --docker",
+                "--kind",
+                help="Enable dependency-matrix Docker Compose validation",
             ),
         ] = 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_validator = self.kind_validator_class(verbose).validate_rendered_files if kind else None
+        validate_templates(
+            self,
+            template_id,
+            path,
+            ValidationConfig(
+                verbose=verbose,
+                semantic=semantic,
+                kind=kind,
+                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)

+ 26 - 19
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,46 +18,53 @@ class SwarmModule(Module):
 
     name = "swarm"
     description = "Manage Docker Swarm stack templates"
+    kind_validator_class = ComposeDockerValidator
 
     def validate(  # noqa: PLR0913
         self,
         template_id: Annotated[
             str | None,
-            Argument(help="Template ID to validate (omit to validate all templates)"),
+            Argument(help="Template ID to validate"),
         ] = None,
         *,
         path: Annotated[
             str | None,
             Option("--path", help="Path to template directory for validation"),
         ] = None,
+        all_templates: Annotated[
+            bool,
+            Option("--all", help="Validate all Swarm templates"),
+        ] = 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.)",
-            ),
-        ] = True,
-        docker: Annotated[
-            bool,
-            Option(
-                "--docker/--no-docker",
-                help="Enable Docker Compose validation using 'docker compose config'",
+                "--semantic",
+                help="Enable dependency-matrix semantic validation",
             ),
         ] = False,
-        docker_test_all: Annotated[
+        kind: Annotated[
             bool,
             Option(
-                "--docker-test-all",
-                help="Test all variable combinations (minimal, maximal, each toggle). Requires --docker",
+                "--kind",
+                help="Enable dependency-matrix Docker Compose validation",
             ),
         ] = 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_validator = self.kind_validator_class(verbose).validate_rendered_files if kind else None
+        validate_templates(
+            self,
+            template_id,
+            path,
+            ValidationConfig(
+                verbose=verbose,
+                semantic=semantic,
+                kind=kind,
+                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)

+ 163 - 0
tests/test_dependency_matrix.py

@@ -0,0 +1,163 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from cli.core.template import Template
+from cli.core.validation import 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 == []