diff --git a/codex_skills/homeassistant-dashboard-designer/README.md b/codex_skills/homeassistant-dashboard-designer/README.md index 9535ab26..2f2fd223 100644 --- a/codex_skills/homeassistant-dashboard-designer/README.md +++ b/codex_skills/homeassistant-dashboard-designer/README.md @@ -67,6 +67,13 @@ Invoke in chat: Then describe what you want in natural language (what to change + where + any constraints). The skill will infer the structured intent internally and enforce the button-card-first / layout constraints defined in `SKILL.md`. +Workflow notes: +- This skill uses direct updates for `config/dashboards/**` (no staged rollout workflow in-skill). +- It requires post-edit validation in this order: +- `pwsh -NoProfile -File tools/validate_dashboards.ps1` +- `pwsh -NoProfile -File tools/ha_ui_smoke.ps1` +- `python codex_skills/homeassistant-dashboard-designer/scripts/validate_lovelace_view.py ` + Examples: - "Refactor `config/dashboards/infrastructure/partials/mariadb_sections.yaml` to match the existing Infrastructure design language. Preserve existing templates and keep diffs small." - "Add a new Infrastructure view for Docker containers using the same layout rules as the other views (4 columns desktop / 2 columns mobile)." diff --git a/codex_skills/homeassistant-dashboard-designer/SKILL.md b/codex_skills/homeassistant-dashboard-designer/SKILL.md index f6e7c7f6..18ff9d11 100644 --- a/codex_skills/homeassistant-dashboard-designer/SKILL.md +++ b/codex_skills/homeassistant-dashboard-designer/SKILL.md @@ -175,21 +175,28 @@ Fallback behavior when Stitch is unavailable: ## Workflow (Do This In Order) -1. Read the target dashboard/view/partials/templates to understand existing patterns and avoid drift. -2. Determine intent from the user's request and existing dashboard context: `infra` (NOC), `home`, `energy`, or `environment`. Keep one intent per view. -3. Validate entities and services before editing: +1. Use direct-update mode for this repo: + - Edit production YAML directly (no staged dashboard copies in this skill workflow). + - Keep rollback safety by minimizing diffs and validating before HA restart/reload. +2. Read the target dashboard/view/partials/templates and include targets before editing: + - Confirm the exact source view/partial/template files. + - Confirm referenced `!include` files/directories exist and are in-scope. +3. Determine intent from the user's request and existing dashboard context: `infra` (NOC), `home`, `energy`, or `environment`. Keep one intent per view. +4. Validate entities and services before editing: - Prefer the Home Assistant MCP for live entity/service validation (required when available). - Record the MCP validation step in the work notes before writing YAML. - If MCP is not available, ask the user to confirm entity IDs and services (do not guess). -4. Draft layout with constraints: a top-level `grid` and optional `vertical-stack` groups. +5. Draft layout with constraints: a top-level `grid` and optional `vertical-stack` groups. - If using Stitch, first summarize `stitch_intent` and treat it as advisory input to this step. - After removals, reflow cards/sections upward to collapse gaps and reduce empty rows. -5. Implement using Tier 1 cards first; reuse existing templates; avoid one-off styles. -6. If fallback cards are necessary, add an inline comment explaining why Tier 1 cannot satisfy the requirement. -7. Validate: +6. Implement using Tier 1 cards first; reuse existing templates; avoid one-off styles. +7. If fallback cards are necessary, add an inline comment explaining why Tier 1 cannot satisfy the requirement. +8. Validate in this order: + - Run `pwsh -NoProfile -File tools/validate_dashboards.ps1`. + - Run `pwsh -NoProfile -File tools/ha_ui_smoke.ps1`. - Run Home Assistant config validation (`ha core check` or `homeassistant --script check_config`) when available. - - Optionally run `scripts/validate_lovelace_view.py` from this skill against the changed view file to catch violations early. -8. Failure behavior: + - Run `scripts/validate_lovelace_view.py` from this skill against each changed view file. +9. Failure behavior: - If requirements can't be met: state the violated rule and propose a compliant alternative. - If validation fails: stop, surface the error output, and propose corrected YAML. Do not leave invalid config applied. diff --git a/codex_skills/homeassistant-dashboard-designer/references/dashboard_rules.md b/codex_skills/homeassistant-dashboard-designer/references/dashboard_rules.md index d25cbd59..6175659b 100644 --- a/codex_skills/homeassistant-dashboard-designer/references/dashboard_rules.md +++ b/codex_skills/homeassistant-dashboard-designer/references/dashboard_rules.md @@ -149,6 +149,7 @@ Anti-drift checklist: If working in this repo's `config/dashboards/` tree: - Do not edit `config/.storage` (runtime state). +- Use direct-update workflow for dashboard YAML in this repo (no staged dashboard promotion flow in this skill). - Includes must use absolute container paths starting with `/config/`. - Views are one file per view, and the dashboard file uses `!include_dir_list`. - Files under `config/dashboards/**/*.yaml` must include the standard `@CCOSTAN` header block. @@ -160,3 +161,9 @@ When available, use the Home Assistant MCP to validate: - Service names and payload fields used by actions (for example, `button.press`, `script.*`, etc.). If MCP is not available, do not guess entity IDs. Ask the user to confirm them. + +## Validation Chain (Required Before Restart/Reload) + +- Run `pwsh -NoProfile -File tools/validate_dashboards.ps1`. +- Run `pwsh -NoProfile -File tools/ha_ui_smoke.ps1`. +- Run `scripts/validate_lovelace_view.py` for each changed view file. diff --git a/codex_skills/homeassistant-dashboard-designer/scripts/validate_lovelace_view.py b/codex_skills/homeassistant-dashboard-designer/scripts/validate_lovelace_view.py index ab42f33d..3e61fa23 100644 --- a/codex_skills/homeassistant-dashboard-designer/scripts/validate_lovelace_view.py +++ b/codex_skills/homeassistant-dashboard-designer/scripts/validate_lovelace_view.py @@ -58,6 +58,8 @@ ALLOWED_CARD_TYPES = { "grid", "vertical-stack", "custom:button-card", + "custom:layout-card", + "custom:vertical-stack-in-card", "custom:flex-horseshoe-card", "custom:mini-graph-card", # Tier-2 fallbacks (validator does not enforce justification comments). @@ -157,6 +159,22 @@ def _walk_cards(node: Any, node_path: str, findings: list[Finding]) -> None: message="Per-card styles: are not allowed; move styling into centralized templates.", ) ) + if "style" in node: + findings.append( + Finding( + level="ERROR", + path=node_path, + message="Per-card style: is not allowed; move styling into centralized templates.", + ) + ) + if "extra_styles" in node: + findings.append( + Finding( + level="ERROR", + path=node_path, + message="Per-card extra_styles: is not allowed; move styling into centralized templates.", + ) + ) if "card_mod" in node: findings.append( Finding( @@ -165,6 +183,17 @@ def _walk_cards(node: Any, node_path: str, findings: list[Finding]) -> None: message="Per-card card_mod: is not allowed on button-card instances; use shared snippets or templates.", ) ) + state_entries = node.get("state") + if _is_sequence(state_entries): + for sidx, state_entry in enumerate(state_entries): + if _is_mapping(state_entry) and "styles" in state_entry: + findings.append( + Finding( + level="ERROR", + path=f"{node_path}.state[{sidx}]", + message="Per-card state styles are not allowed; move styling into centralized templates.", + ) + ) cards = node.get("cards") if _is_sequence(cards): @@ -181,6 +210,82 @@ def _load_yaml(path: Path) -> Any: return yaml.load(txt, Loader=_Loader) +def _validate_sections_wrapper(sections: list[Any], findings: list[Finding]) -> None: + """Validate first sections wrapper constraints from dashboard rules.""" + if not sections: + findings.append( + Finding( + level="ERROR", + path="$.sections", + message="sections list cannot be empty for a sections view.", + ) + ) + return + + first_section = sections[0] + if not _is_mapping(first_section): + findings.append( + Finding( + level="ERROR", + path="$.sections[0]", + message="First section must be a mapping.", + ) + ) + return + + column_span = first_section.get("column_span") + if column_span != 4: + findings.append( + Finding( + level="ERROR", + path="$.sections[0].column_span", + message="First section must set column_span: 4.", + ) + ) + + cards = first_section.get("cards") + if not _is_sequence(cards): + findings.append( + Finding( + level="ERROR", + path="$.sections[0].cards", + message="First section must define cards as a list.", + ) + ) + return + + if len(cards) != 1: + findings.append( + Finding( + level="ERROR", + path="$.sections[0].cards", + message="First section must contain exactly one wrapper card.", + ) + ) + return + + wrapper_card = cards[0] + if not _is_mapping(wrapper_card): + findings.append( + Finding( + level="ERROR", + path="$.sections[0].cards[0]", + message="Wrapper card must be a mapping.", + ) + ) + return + + grid_options = wrapper_card.get("grid_options") + if not _is_mapping(grid_options) or grid_options.get("columns") != "full": + findings.append( + Finding( + level="ERROR", + path="$.sections[0].cards[0].grid_options.columns", + message='Wrapper card must set grid_options.columns: "full".', + ) + ) + + def main(argv: list[str]) -> int: ap = argparse.ArgumentParser() ap.add_argument("view_yaml", type=Path, help="Path to a Lovelace view YAML file") @@ -225,6 +330,7 @@ def main(argv: list[str]) -> int: ) ) elif _is_sequence(sections): + _validate_sections_wrapper(sections, findings) for sidx, section in enumerate(sections): spath = f"$.sections[{sidx}]" if not _is_mapping(section): diff --git a/codex_skills/homeassistant-dashboard-designer/tests/test_validate_lovelace_view.py b/codex_skills/homeassistant-dashboard-designer/tests/test_validate_lovelace_view.py new file mode 100644 index 00000000..f3c2335d --- /dev/null +++ b/codex_skills/homeassistant-dashboard-designer/tests/test_validate_lovelace_view.py @@ -0,0 +1,127 @@ +import contextlib +import importlib.util +import io +import sys +import tempfile +import unittest +from pathlib import Path + + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] / "scripts" / "validate_lovelace_view.py" +) +MODULE_NAME = "validate_lovelace_view" +SPEC = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"Unable to load module spec from {MODULE_PATH}") +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[MODULE_NAME] = MODULE +SPEC.loader.exec_module(MODULE) + + +class ValidateLovelaceViewTests(unittest.TestCase): + def _run_validator(self, yaml_text: str, *extra_args: str) -> tuple[int, str, str]: + with tempfile.TemporaryDirectory() as tmpdir: + view_path = Path(tmpdir) / "view.yaml" + view_path.write_text(yaml_text, encoding="utf-8") + + stdout = io.StringIO() + stderr = io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + rc = MODULE.main([str(view_path), *extra_args]) + return rc, stdout.getvalue(), stderr.getvalue() + + def test_valid_classic_view_passes(self) -> None: + yaml_text = """ +title: Main +path: main +cards: + - type: custom:button-card + template: + - status_chip +""" + rc, out, err = self._run_validator(yaml_text) + self.assertEqual(rc, 0, out + err) + self.assertEqual(out.strip(), "") + self.assertEqual(err.strip(), "") + + def test_horizontal_stack_fails(self) -> None: + yaml_text = """ +title: Main +path: main +cards: + - type: horizontal-stack + cards: + - type: custom:button-card + template: status_chip +""" + rc, out, _ = self._run_validator(yaml_text) + self.assertEqual(rc, 1) + self.assertIn("Disallowed card type: horizontal-stack", out) + + def test_button_card_missing_template_fails(self) -> None: + yaml_text = """ +title: Main +path: main +cards: + - type: custom:button-card + entity: light.kitchen +""" + rc, out, _ = self._run_validator(yaml_text) + self.assertEqual(rc, 1) + self.assertIn("custom:button-card must set template", out) + + def test_sections_wrapper_rule_fails_when_full_width_missing(self) -> None: + yaml_text = """ +title: Sections +path: sections +type: sections +sections: + - type: grid + column_span: 4 + cards: + - type: custom:vertical-stack-in-card + cards: + - type: custom:button-card + template: status_chip +""" + rc, out, _ = self._run_validator(yaml_text) + self.assertEqual(rc, 1) + self.assertIn('Wrapper card must set grid_options.columns: "full".', out) + + def test_sections_wrapper_rule_passes_when_full_width_wrapper_present(self) -> None: + yaml_text = """ +title: Sections +path: sections +type: sections +sections: + - type: grid + column_span: 4 + cards: + - type: custom:vertical-stack-in-card + grid_options: + columns: "full" + cards: + - type: custom:button-card + template: status_chip +""" + rc, out, err = self._run_validator(yaml_text) + self.assertEqual(rc, 0, out + err) + self.assertEqual(out.strip(), "") + self.assertEqual(err.strip(), "") + + def test_unknown_include_tag_is_parse_safe(self) -> None: + yaml_text = """ +title: Included Sections +path: included_sections +type: sections +sections: !include ../sections/core.yaml +""" + rc, out, err = self._run_validator(yaml_text) + self.assertEqual(rc, 0, out + err) + self.assertIn("validator cannot inspect included sections content", out) + self.assertEqual(err.strip(), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/codex_skills/homeassistant-yaml-dry-verifier/README.md b/codex_skills/homeassistant-yaml-dry-verifier/README.md new file mode 100644 index 00000000..cef39c0d --- /dev/null +++ b/codex_skills/homeassistant-yaml-dry-verifier/README.md @@ -0,0 +1,88 @@ +

+ Bear Stone Smart Home +
+ Bear Stone Smart Home Documentation +

+

Be sure to :star: my configuration repo so you can keep up to date on any daily progress!

+ +
+ +[![X Follow](https://img.shields.io/static/v1?label=talk&message=3k&color=blue&logo=twitter&style=for-the-badge)](https://x.com/ccostan) +[![YouTube Subscribe](https://img.shields.io/youtube/channel/subscribers/UC301G8JJFzY0BZ_0lshpKpQ?label=VIEW&logo=Youtube&logoColor=%23DF5D44&style=for-the-badge)](https://www.youtube.com/vCloudInfo?sub_confirmation=1) +[![GitHub Stars](https://img.shields.io/github/stars/CCOSTAN/Home-AssistantConfig?label=STARS&logo=Github&style=for-the-badge)](https://github.com/CCOSTAN)
+[![HA Version Badge](https://raw.githubusercontent.com/ccostan/home-assistantconfig/master/ha-version-badge.svg)](https://github.com/CCOSTAN/Home-AssistantConfig/blob/master/config/.HA_VERSION) +[![Last Commit](https://img.shields.io/github/last-commit/CCOSTAN/Home-AssistantConfig.svg?style=plastic)](https://github.com/CCOSTAN/Home-AssistantConfig/commits/master) +[![Commit Activity](https://img.shields.io/github/commit-activity/y/CCOSTAN/Home-AssistantConfig.svg?style=plastic)](https://github.com/CCOSTAN/Home-AssistantConfig/commits/master) + +
+ +# HA YAML DRY Verifier (Codex Skill) + +This directory contains the `homeassistant-yaml-dry-verifier` skill and the CLI used to detect repeated YAML structures in Home Assistant automations/scripts/packages. + +### Quick navigation +- You are here: `codex_skills/homeassistant-yaml-dry-verifier/` +- [Repo overview](../../README.md) | [Codex skills](../README.md) | [Packages](../../config/packages/README.md) | [Scripts](../../config/script/README.md) + +## What This Skill Does + +- Detects repeated `trigger`, `condition`, `action`, and `sequence` blocks. +- Detects repeated entries inside those blocks. +- Detects duplicate entries within a single block (`INTRA`). +- Detects package-defined scripts called from multiple files (`CENTRAL_SCRIPT`). +- Collapses noisy ENTRY reports when they are already fully explained by an identical `FULL_BLOCK` finding. + +## CLI Usage + +Run on one file: + +```bash +python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py config/packages/bearclaw.yaml +``` + +Run on broader scope: + +```bash +python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py config/packages config/script +``` + +Strict mode (non-zero exit if findings exist): + +```bash +python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py config/packages config/script --strict +``` + +## Output Model + +The CLI prints: +- Scan summary counts +- `FULL_BLOCK` findings +- `ENTRY` findings +- `INTRA` findings +- `CENTRAL_SCRIPT` findings + +Exit codes: +- `0`: success (or findings in non-strict mode) +- `1`: findings present in strict mode +- `2`: parse/path errors + +## Notes + +- This verifier intentionally keeps text output and a small CLI surface. +- It does not implement suppression files, severity scoring, JSON output, or diff-only mode. +- Use it as a fast pre-refactor signal and pair with Home Assistant config validation before restart/reload. + +**All of my configuration files are tested against the most stable version of home-assistant.** + + + +**Still have questions on my Config?**
+**Message me on X :** [![Follow CCostan](https://img.shields.io/twitter/follow/CCostan)](https://www.x.com/ccostan) + +

+Buy me a coffeeYou can buy me a coffeeBuy me a coffee +
+
+ +Affiliate Disclosure +

diff --git a/codex_skills/homeassistant-yaml-dry-verifier/SKILL.md b/codex_skills/homeassistant-yaml-dry-verifier/SKILL.md index d43c62e6..28ca6399 100644 --- a/codex_skills/homeassistant-yaml-dry-verifier/SKILL.md +++ b/codex_skills/homeassistant-yaml-dry-verifier/SKILL.md @@ -43,7 +43,7 @@ python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.p 3. Prioritize findings in this order: - `FULL_BLOCK`: repeated full trigger/condition/action/sequence blocks. -- `ENTRY`: repeated individual entries inside those blocks. +- `ENTRY`: repeated individual entries inside those blocks (excluding entries already fully covered by a `FULL_BLOCK` duplicate). - `INTRA`: duplicate entries inside a single block. - `CENTRAL_SCRIPT`: script is defined in `config/packages` but called from 2+ YAML files. @@ -74,6 +74,7 @@ Always report: - Parse errors (if any). - Duplicate groups by kind (`trigger`, `condition`, `action`, `sequence`). - Central script placement findings (`CENTRAL_SCRIPT`) with definition + caller files. +- Script caller detection should include direct `service: script.` and `script.turn_on`-style entity targeting when present. - Concrete refactor recommendation per group. - Resolution status for each finding (`resolved`, `deferred-with-blocker`). diff --git a/codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py b/codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py index 6f501091..b9a2e460 100644 --- a/codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py +++ b/codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.py @@ -261,10 +261,19 @@ def _block_keys_for_candidate(candidate: Candidate) -> dict[str, tuple[str, ...] def _recommendation(block_label: str) -> str: if block_label in {"action", "sequence"}: - return "Move repeated logic to a shared script and call it with variables." + return ( + "Move repeated logic to config/script/.yaml and call it " + "via service: script. with variables." + ) if block_label == "condition": - return "Extract shared condition logic into helpers/template sensors or merge condition blocks." - return "Consolidate repeated trigger patterns where behavior is equivalent." + return ( + "Extract shared condition logic into helper/template entities or " + "merge condition blocks when behavior is equivalent." + ) + return ( + "Consolidate equivalent trigger patterns and keep shared actions in a " + "single reusable script when possible." + ) def _render_occurrences(occurrences: list[Occurrence], max_rows: int = 6) -> str: @@ -282,6 +291,22 @@ def _normalize_path(path: str) -> str: return path.replace("\\", "/").lower() +def _entry_parent_block_path(block_path: str) -> str: + """Return parent block path for entry occurrences (strip trailing [idx]).""" + return re.sub(r"\[\d+\]$", "", block_path) + + +def _occurrence_key( + occurrence: Occurrence, *, treat_as_entry: bool = False +) -> tuple[str, str, str]: + block_path = ( + _entry_parent_block_path(occurrence.block_path) + if treat_as_entry + else occurrence.block_path + ) + return (occurrence.file_path, occurrence.candidate_path, block_path) + + def _infer_script_id(candidate: Candidate) -> str | None: if candidate.kind != "script": return None @@ -298,14 +323,41 @@ def _infer_script_id(candidate: Candidate) -> str | None: def _collect_script_service_calls(node: Any, script_ids: set[str]) -> None: + """Collect called script IDs from common HA service invocation patterns.""" + script_domain_meta_services = {"turn_on", "toggle", "reload", "stop"} + + def _add_script_entity_ids(value: Any) -> None: + if isinstance(value, str): + if value.startswith("script."): + entity_script_id = value.split(".", 1)[1].strip() + if entity_script_id: + script_ids.add(entity_script_id) + return + if isinstance(value, list): + for item in value: + _add_script_entity_ids(item) + if isinstance(node, dict): - for key, value in node.items(): - if key in {"service", "action"} and isinstance(value, str): - service_name = value.strip() - if service_name.startswith("script."): - script_id = service_name.split(".", 1)[1].strip() - if script_id: - script_ids.add(script_id) + service_name_raw = node.get("service") + action_name_raw = node.get("action") + service_name = None + if isinstance(service_name_raw, str): + service_name = service_name_raw.strip() + elif isinstance(action_name_raw, str): + service_name = action_name_raw.strip() + + if service_name and service_name.startswith("script."): + tail = service_name.split(".", 1)[1].strip() + if tail and tail not in script_domain_meta_services: + script_ids.add(tail) + else: + _add_script_entity_ids(node.get("entity_id")) + for key in ("target", "data", "service_data"): + container = node.get(key) + if isinstance(container, dict): + _add_script_entity_ids(container.get("entity_id")) + + for value in node.values(): _collect_script_service_calls(value, script_ids) return if isinstance(node, list): @@ -401,6 +453,26 @@ def main(argv: list[str]) -> int: full_groups = _filter_groups(full_index) entry_groups = _filter_groups(entry_index) + + # Drop ENTRY groups that are fully subsumed by an identical FULL_BLOCK group. + full_group_member_sets: dict[tuple[str, str], list[set[tuple[str, str, str]]]] = defaultdict(list) + for (kind, block_label, _), occurrences in full_groups: + full_group_member_sets[(kind, block_label)].append( + {_occurrence_key(occ) for occ in occurrences} + ) + + filtered_entry_groups: list[tuple[tuple[str, str, str], list[Occurrence]]] = [] + for entry_group_key, entry_occurrences in entry_groups: + kind, block_label, _ = entry_group_key + entry_member_set = { + _occurrence_key(occ, treat_as_entry=True) for occ in entry_occurrences + } + full_sets = full_group_member_sets.get((kind, block_label), []) + is_subsumed = any(entry_member_set.issubset(full_set) for full_set in full_sets) + if not is_subsumed: + filtered_entry_groups.append((entry_group_key, entry_occurrences)) + + entry_groups = filtered_entry_groups intra_duplicate_notes = sorted(set(intra_duplicate_notes)) script_definitions_by_id: dict[str, set[str]] = defaultdict(set) diff --git a/codex_skills/homeassistant-yaml-dry-verifier/tests/test_verify_ha_yaml_dry.py b/codex_skills/homeassistant-yaml-dry-verifier/tests/test_verify_ha_yaml_dry.py new file mode 100644 index 00000000..dfcdb2c9 --- /dev/null +++ b/codex_skills/homeassistant-yaml-dry-verifier/tests/test_verify_ha_yaml_dry.py @@ -0,0 +1,302 @@ +import contextlib +import importlib.util +import io +import re +import sys +import tempfile +import unittest +from pathlib import Path + + +MODULE_PATH = ( + Path(__file__).resolve().parents[1] / "scripts" / "verify_ha_yaml_dry.py" +) +MODULE_NAME = "verify_ha_yaml_dry" +SPEC = importlib.util.spec_from_file_location(MODULE_NAME, MODULE_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"Unable to load module spec from {MODULE_PATH}") +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[MODULE_NAME] = MODULE +SPEC.loader.exec_module(MODULE) + + +class VerifyHaYamlDryTests(unittest.TestCase): + def _run_verifier( + self, + files: dict[str, str], + *extra_args: str, + scan_subpath: str = ".", + ) -> tuple[int, str, str]: + with tempfile.TemporaryDirectory() as tmpdir: + root = Path(tmpdir) + for rel_path, content in files.items(): + file_path = root / rel_path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(content, encoding="utf-8") + + scan_path = root / scan_subpath + stdout = io.StringIO() + stderr = io.StringIO() + with contextlib.redirect_stdout(stdout), contextlib.redirect_stderr(stderr): + rc = MODULE.main([str(scan_path), *extra_args]) + return rc, stdout.getvalue(), stderr.getvalue() + + @staticmethod + def _full_block_group_order(output: str) -> list[str]: + marker = "\nFULL_BLOCK findings:\n" + if marker not in output: + return [] + section = output.split(marker, 1)[1] + for stop in ("\nENTRY findings:\n", "\nINTRA findings:\n", "\nCENTRAL_SCRIPT findings:\n"): + if stop in section: + section = section.split(stop, 1)[0] + break + + groups: list[str] = [] + for line in section.splitlines(): + text = line.strip() + match = re.match(r"^\d+\.\s+([a-z]+\.[a-z_]+)\s+repeated\s+\d+\s+times$", text) + if match: + groups.append(match.group(1)) + return groups + + def test_full_block_detection_with_fixture(self) -> None: + files = { + "automations.yaml": """ +- alias: A1 + trigger: + - platform: state + entity_id: binary_sensor.one + to: "on" + action: + - service: light.turn_on + target: + entity_id: light.kitchen +- alias: A2 + trigger: + - platform: state + entity_id: binary_sensor.two + to: "on" + action: + - service: light.turn_on + target: + entity_id: light.kitchen +""" + } + rc, out, err = self._run_verifier(files) + self.assertEqual(rc, 0, out + err) + self.assertIn("Duplicate full-block groups: 1", out) + self.assertIn("FULL_BLOCK findings:", out) + self.assertIn("automation.action repeated 2 times", out) + + def test_entry_detection_with_fixture(self) -> None: + files = { + "automations.yaml": """ +- alias: A1 + trigger: + - platform: state + entity_id: binary_sensor.one + to: "on" + action: + - service: script.shared_handler +- alias: A2 + trigger: + - platform: state + entity_id: binary_sensor.two + to: "on" + action: + - service: script.shared_handler + - delay: "00:00:01" +""" + } + rc, out, err = self._run_verifier(files) + self.assertEqual(rc, 0, out + err) + self.assertIn("Duplicate entry groups: 1", out) + self.assertIn("ENTRY findings:", out) + self.assertIn("automation.action entry repeated 2 times", out) + + def test_intra_detection_with_fixture(self) -> None: + files = { + "automations.yaml": """ +- alias: Repeat Inside Block + trigger: + - platform: state + entity_id: binary_sensor.one + to: "on" + action: + - service: light.turn_off + target: + entity_id: light.den + - service: light.turn_off + target: + entity_id: light.den +""" + } + rc, out, err = self._run_verifier(files) + self.assertEqual(rc, 0, out + err) + self.assertIn("INTRA findings:", out) + self.assertIn("INTRA automation.action: Repeat Inside Block has 2 duplicated entries", out) + + def test_central_script_detection_with_package_definition_and_multi_caller(self) -> None: + files = { + "config/packages/shared_scripts.yaml": """ +script: + my_shared_script: + alias: Shared Script + sequence: + - service: logbook.log + data: + name: Shared + message: Hello +""", + "config/automations/caller_one.yaml": """ +automation: + - alias: Caller One + trigger: + - platform: state + entity_id: binary_sensor.one + to: "on" + action: + - service: script.my_shared_script +""", + "config/automations/caller_two.yaml": """ +automation: + - alias: Caller Two + trigger: + - platform: state + entity_id: binary_sensor.two + to: "on" + action: + - service: script.turn_on + target: + entity_id: script.my_shared_script +""", + } + rc, out, err = self._run_verifier(files) + self.assertEqual(rc, 0, out + err) + self.assertIn("Central-script findings: 1", out) + self.assertIn("script.my_shared_script is package-defined and called from 2 files", out) + self.assertIn("suggestion: Move definition to config/script/my_shared_script.yaml", out) + + def test_subsumed_entry_groups_are_collapsed(self) -> None: + files = { + "automations.yaml": """ +- alias: A1 + trigger: + - platform: state + entity_id: binary_sensor.one + to: "on" + action: + - service: light.turn_on + target: + entity_id: light.kitchen +- alias: A2 + trigger: + - platform: state + entity_id: binary_sensor.two + to: "on" + action: + - service: light.turn_on + target: + entity_id: light.kitchen +""" + } + rc, out, err = self._run_verifier(files) + self.assertEqual(rc, 0, out + err) + self.assertIn("Duplicate full-block groups: 1", out) + self.assertIn("Duplicate entry groups: 0", out) + + def test_full_block_findings_order_is_deterministic(self) -> None: + files = { + "automations.yaml": """ +- alias: A1 + trigger: + - platform: state + entity_id: binary_sensor.same + to: "on" + action: + - service: light.turn_on + target: + entity_id: light.kitchen +- alias: A2 + trigger: + - platform: state + entity_id: binary_sensor.same + to: "on" + action: + - service: light.turn_on + target: + entity_id: light.kitchen +""", + "scripts.yaml": """ +script: + script_one: + sequence: + - service: light.turn_off + target: + entity_id: light.kitchen + script_two: + sequence: + - service: light.turn_off + target: + entity_id: light.kitchen +""", + } + rc, out, err = self._run_verifier(files) + self.assertEqual(rc, 0, out + err) + order = self._full_block_group_order(out) + self.assertGreaterEqual(len(order), 3, out) + self.assertEqual(order[:3], ["automation.action", "automation.trigger", "script.sequence"]) + + def test_exit_codes_for_strict_and_non_strict_modes(self) -> None: + files = { + "automations.yaml": """ +- alias: A1 + trigger: + - platform: state + entity_id: binary_sensor.one + to: "on" + action: + - service: light.turn_on + target: + entity_id: light.kitchen +- alias: A2 + trigger: + - platform: state + entity_id: binary_sensor.two + to: "on" + action: + - service: light.turn_on + target: + entity_id: light.kitchen +""" + } + rc_non_strict, _, _ = self._run_verifier(files) + rc_strict, _, _ = self._run_verifier(files, "--strict") + self.assertEqual(rc_non_strict, 0) + self.assertEqual(rc_strict, 1) + + def test_parse_error_path_returns_exit_code_two(self) -> None: + files = { + "good.yaml": """ +automation: + - alias: Good + trigger: + - platform: state + entity_id: binary_sensor.one + to: "on" + action: + - service: light.turn_on + target: + entity_id: light.kitchen +""", + "bad.yaml": "automation: [\n", + } + rc, out, err = self._run_verifier(files) + self.assertEqual(rc, 2, out + err) + self.assertIn("Parse errors:", out) + self.assertIn("bad.yaml", out) + + +if __name__ == "__main__": + unittest.main()