Enhance Home Assistant dashboard designer documentation and validation scripts #1566

- Added workflow notes for direct updates and post-edit validation steps in README.md.
- Updated SKILL.md to clarify the workflow for editing dashboards and added validation order.
- Revised dashboard_rules.md to emphasize direct-update workflow and validation requirements.
- Enhanced validate_lovelace_view.py to enforce new validation rules for card styles and section constraints.
- Improved error handling and validation logic for dashboard sections in validate_lovelace_view.py.
feature/powerwall-live-activity-1598
Carlo Costanzo 2 weeks ago
parent b3e039e509
commit 1d8aed02df

@ -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`. 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 <changed-view.yaml>`
Examples: Examples:
- "Refactor `config/dashboards/infrastructure/partials/mariadb_sections.yaml` to match the existing Infrastructure design language. Preserve existing templates and keep diffs small." - "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)." - "Add a new Infrastructure view for Docker containers using the same layout rules as the other views (4 columns desktop / 2 columns mobile)."

@ -175,21 +175,28 @@ Fallback behavior when Stitch is unavailable:
## Workflow (Do This In Order) ## Workflow (Do This In Order)
1. Read the target dashboard/view/partials/templates to understand existing patterns and avoid drift. 1. Use direct-update mode for this repo:
2. Determine intent from the user's request and existing dashboard context: `infra` (NOC), `home`, `energy`, or `environment`. Keep one intent per view. - Edit production YAML directly (no staged dashboard copies in this skill workflow).
3. Validate entities and services before editing: - 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). - 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. - 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). - 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. - 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. - 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. 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. If fallback cards are necessary, add an inline comment explaining why Tier 1 cannot satisfy the requirement.
7. Validate: 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. - 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. - Run `scripts/validate_lovelace_view.py` from this skill against each changed view file.
8. Failure behavior: 9. Failure behavior:
- If requirements can't be met: state the violated rule and propose a compliant alternative. - 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. - If validation fails: stop, surface the error output, and propose corrected YAML. Do not leave invalid config applied.

@ -149,6 +149,7 @@ Anti-drift checklist:
If working in this repo's `config/dashboards/` tree: If working in this repo's `config/dashboards/` tree:
- Do not edit `config/.storage` (runtime state). - 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/`. - Includes must use absolute container paths starting with `/config/`.
- Views are one file per view, and the dashboard file uses `!include_dir_list`. - 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. - 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.). - 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. 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.

@ -58,6 +58,8 @@ ALLOWED_CARD_TYPES = {
"grid", "grid",
"vertical-stack", "vertical-stack",
"custom:button-card", "custom:button-card",
"custom:layout-card",
"custom:vertical-stack-in-card",
"custom:flex-horseshoe-card", "custom:flex-horseshoe-card",
"custom:mini-graph-card", "custom:mini-graph-card",
# Tier-2 fallbacks (validator does not enforce justification comments). # 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.", 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: if "card_mod" in node:
findings.append( findings.append(
Finding( 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.", 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") cards = node.get("cards")
if _is_sequence(cards): if _is_sequence(cards):
@ -181,6 +210,82 @@ def _load_yaml(path: Path) -> Any:
return yaml.load(txt, Loader=_Loader) 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: def main(argv: list[str]) -> int:
ap = argparse.ArgumentParser() ap = argparse.ArgumentParser()
ap.add_argument("view_yaml", type=Path, help="Path to a Lovelace view YAML file") 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): elif _is_sequence(sections):
_validate_sections_wrapper(sections, findings)
for sidx, section in enumerate(sections): for sidx, section in enumerate(sections):
spath = f"$.sections[{sidx}]" spath = f"$.sections[{sidx}]"
if not _is_mapping(section): if not _is_mapping(section):

@ -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()

@ -0,0 +1,88 @@
<h1 align="center">
<a name="logo" href="https://www.vCloudInfo.com/tag/iot"><img src="https://raw.githubusercontent.com/CCOSTAN/Home-AssistantConfig/master/x_profile.png" alt="Bear Stone Smart Home" width="200"></a>
<br>
Bear Stone Smart Home Documentation
</h1>
<h4 align="center">Be sure to :star: my configuration repo so you can keep up to date on any daily progress!</h4>
<div align="center">
[![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) <br>
[![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)
</div>
# 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.**
<a name="bottom" href="https://github.com/CCOSTAN/Home-AssistantConfig#logo"><img align="right" border="0" src="https://raw.githubusercontent.com/CCOSTAN/Home-AssistantConfig/master/config/www/custom_ui/floorplan/images/branding/up_arrow.png" width="25" ></a>
**Still have questions on my Config?** <br>
**Message me on X :** [![Follow CCostan](https://img.shields.io/twitter/follow/CCostan)](https://www.x.com/ccostan)
<p align="center">
<a target="_blank" href="https://www.buymeacoffee.com/vCloudInfo"><img src="https://www.buymeacoffee.com/assets/img/BMC-btn-logo.svg" alt="Buy me a coffee"><span style="margin-left:5px">You can buy me a coffee</span></a><a target="_blank" href="https://www.buymeacoffee.com/vCloudInfo"><img src="https://www.buymeacoffee.com/assets/img/BMC-btn-logo.svg" alt="Buy me a coffee"></a>
<br>
<a href="https://eepurl.com/dmXFYz"><img align="center" border="0" src="https://raw.githubusercontent.com/CCOSTAN/Home-AssistantConfig/master/config/www/custom_ui/floorplan/images/branding/email_link.png" height="50" ></a><br>
<a href="https://www.vcloudinfo.com/p/affiliate-disclosure.html">
Affiliate Disclosure
</a></p>

@ -43,7 +43,7 @@ python codex_skills/homeassistant-yaml-dry-verifier/scripts/verify_ha_yaml_dry.p
3. Prioritize findings in this order: 3. Prioritize findings in this order:
- `FULL_BLOCK`: repeated full trigger/condition/action/sequence blocks. - `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. - `INTRA`: duplicate entries inside a single block.
- `CENTRAL_SCRIPT`: script is defined in `config/packages` but called from 2+ YAML files. - `CENTRAL_SCRIPT`: script is defined in `config/packages` but called from 2+ YAML files.
@ -74,6 +74,7 @@ Always report:
- Parse errors (if any). - Parse errors (if any).
- Duplicate groups by kind (`trigger`, `condition`, `action`, `sequence`). - Duplicate groups by kind (`trigger`, `condition`, `action`, `sequence`).
- Central script placement findings (`CENTRAL_SCRIPT`) with definition + caller files. - Central script placement findings (`CENTRAL_SCRIPT`) with definition + caller files.
- Script caller detection should include direct `service: script.<id>` and `script.turn_on`-style entity targeting when present.
- Concrete refactor recommendation per group. - Concrete refactor recommendation per group.
- Resolution status for each finding (`resolved`, `deferred-with-blocker`). - Resolution status for each finding (`resolved`, `deferred-with-blocker`).

@ -261,10 +261,19 @@ def _block_keys_for_candidate(candidate: Candidate) -> dict[str, tuple[str, ...]
def _recommendation(block_label: str) -> str: def _recommendation(block_label: str) -> str:
if block_label in {"action", "sequence"}: 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/<script_id>.yaml and call it "
"via service: script.<script_id> with variables."
)
if block_label == "condition": if block_label == "condition":
return "Extract shared condition logic into helpers/template sensors or merge condition blocks." return (
return "Consolidate repeated trigger patterns where behavior is equivalent." "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: 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() 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: def _infer_script_id(candidate: Candidate) -> str | None:
if candidate.kind != "script": if candidate.kind != "script":
return None 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: 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): if isinstance(node, dict):
for key, value in node.items(): service_name_raw = node.get("service")
if key in {"service", "action"} and isinstance(value, str): action_name_raw = node.get("action")
service_name = value.strip() service_name = None
if service_name.startswith("script."): if isinstance(service_name_raw, str):
script_id = service_name.split(".", 1)[1].strip() service_name = service_name_raw.strip()
if script_id: elif isinstance(action_name_raw, str):
script_ids.add(script_id) 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) _collect_script_service_calls(value, script_ids)
return return
if isinstance(node, list): if isinstance(node, list):
@ -401,6 +453,26 @@ def main(argv: list[str]) -> int:
full_groups = _filter_groups(full_index) full_groups = _filter_groups(full_index)
entry_groups = _filter_groups(entry_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)) intra_duplicate_notes = sorted(set(intra_duplicate_notes))
script_definitions_by_id: dict[str, set[str]] = defaultdict(set) script_definitions_by_id: dict[str, set[str]] = defaultdict(set)

@ -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()
Loading…
Cancel
Save

Powered by TurnKey Linux.