From f669b79f4d7cdb900471aad795ae04942e85d93b Mon Sep 17 00:00:00 2001 From: Carlo Costanzo Date: Tue, 28 Jul 2026 17:13:38 -0400 Subject: [PATCH] Add deterministic Home Assistant validation gate --- .github/workflows/config-validation.yml | 59 +++++ .gitignore | 1 + .travis.yml | 28 -- requirements-dev.txt | 1 + tests/test_holiday_lighting_coverage.py | 104 ++++++++ tools/holiday_lighting_coverage.ps1 | 161 ++---------- tools/holiday_lighting_coverage.py | 332 ++++++++++++++++++++++++ 7 files changed, 513 insertions(+), 173 deletions(-) create mode 100644 .github/workflows/config-validation.yml delete mode 100755 .travis.yml create mode 100644 requirements-dev.txt create mode 100644 tests/test_holiday_lighting_coverage.py create mode 100644 tools/holiday_lighting_coverage.py diff --git a/.github/workflows/config-validation.yml b/.github/workflows/config-validation.yml new file mode 100644 index 00000000..5fabd4f2 --- /dev/null +++ b/.github/workflows/config-validation.yml @@ -0,0 +1,59 @@ +###################################################################### +# @CCOSTAN - Follow Me on X +# For more info visit https://www.vcloudinfo.com/click-here +# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig +# ------------------------------------------------------------------- +# Config Validation - Deterministic repository validation gate. +# Runs mutation-sensitive tests plus strict dashboard and holiday checks. +# ------------------------------------------------------------------- +###################################################################### +name: Config Validation + +on: + pull_request: + push: + branches: ["master"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: config-validation-${{ github.ref }} + cancel-in-progress: true + +jobs: + deterministic-validation: + name: Deterministic validation + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + + - name: Install validation dependencies + run: python -m pip install --requirement requirements-dev.txt + + - name: Run mutation-sensitive validator tests + run: python -m unittest discover -s tests -v + + - name: Validate dashboard YAML and include graph + shell: pwsh + run: ./tools/validate_dashboards.ps1 + + - name: Validate holiday calendars, scenes, and routing + shell: pwsh + run: ./tools/holiday_lighting_coverage.ps1 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Validate browser smoke script syntax + run: node --check tools/ha_ui_smoke.mjs diff --git a/.gitignore b/.gitignore index c2fb628e..23474fd2 100755 --- a/.gitignore +++ b/.gitignore @@ -69,6 +69,7 @@ __pycache__ hacs alexa_media custom_components +output/playwright/ config/www/community config/.cache/ community diff --git a/.travis.yml b/.travis.yml deleted file mode 100755 index 33449cb0..00000000 --- a/.travis.yml +++ /dev/null @@ -1,28 +0,0 @@ -###################################################################### -# @CCOSTAN - Follow Me on X -# For more info visit https://www.vcloudinfo.com/click-here -# Original Repo : https://github.com/CCOSTAN/Home-AssistantConfig -# ------------------------------------------------------------------- -# Travis CI - legacy Home Assistant configuration validation. -# Defines the Travis build used to validate this repository. -# ------------------------------------------------------------------- -###################################################################### -language: python - -python: "3.8" - -before_install: - - touch fake_key.pem - - mv config/travis_secrets.yaml config/secrets.yaml - -install: - - pip3 install homeassistant - - pip3 install colorlog - - pip3 install python-dateutil - - git clone https://github.com/custom-components/youtube.git youtube_component/ - - mv youtube_component/custom_components config/custom_components - - git clone https://github.com/mrk-its/homeassistant-blitzortung.git blitzortung_component/ - - mv blitzortung_component/custom_components config/custom_components - -script: - - hass -c config/ --script check_config --info all diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 00000000..f62ce0c5 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +PyYAML==6.0.3 diff --git a/tests/test_holiday_lighting_coverage.py b/tests/test_holiday_lighting_coverage.py new file mode 100644 index 00000000..56ea9e98 --- /dev/null +++ b/tests/test_holiday_lighting_coverage.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import shutil +import sys +import tempfile +import unittest +from datetime import date +from pathlib import Path + + +REPO = Path(__file__).resolve().parents[1] +TOOLS = REPO / "tools" +sys.path.insert(0, str(TOOLS)) + +from holiday_lighting_coverage import CoverageError, validate_coverage # noqa: E402 + + +class HolidayLightingCoverageTests(unittest.TestCase): + def setUp(self) -> None: + self.tempdir = tempfile.TemporaryDirectory(prefix="holiday-coverage-") + self.root = Path(self.tempdir.name) + files = ( + "config/www/json_data/holidays.json", + "config/www/json_data/flag_days.json", + "config/scene/monthly_colors.yaml", + "config/packages/holiday.yaml", + "config/script/monthly_color_scene.yaml", + ) + for relative in files: + source = REPO / relative + target = self.root / relative + target.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, target) + + def tearDown(self) -> None: + self.tempdir.cleanup() + + def _replace(self, relative: str, old: str, new: str, count: int = -1) -> None: + path = self.root / relative + original = path.read_text(encoding="utf-8") + changed = original.replace(old, new, count) + self.assertNotEqual(original, changed, f"mutation target not found: {old}") + path.write_text(changed, encoding="utf-8") + + def test_current_contract_passes(self) -> None: + results = validate_coverage(self.root, date(2026, 7, 22), 24) + self.assertEqual( + results["Easter Sunday"], + (date(2027, 3, 28), date(2028, 4, 16)), + ) + + def test_inverted_scene_state_fails(self) -> None: + self._replace( + "config/scene/monthly_colors.yaml", + "state: 'on'", + "state: 'off'", + 1, + ) + with self.assertRaisesRegex(CoverageError, "must have state: on"): + validate_coverage(self.root, date(2026, 7, 22), 24) + + def test_missing_required_year_fails(self) -> None: + self._replace( + "config/www/json_data/holidays.json", + ' "03/28/2027": "Easter Sunday",\n', + "", + 1, + ) + with self.assertRaisesRegex(CoverageError, "missing 2027-03-28"): + validate_coverage(self.root, date(2026, 7, 22), 24) + + def test_wrong_static_holiday_name_fails(self) -> None: + self._replace( + "config/www/json_data/holidays.json", + '"1/1": "New Years Day"', + '"1/1": "Wrong Holiday"', + 1, + ) + with self.assertRaisesRegex(CoverageError, "New Years Day"): + validate_coverage(self.root, date(2026, 7, 22), 24) + + def test_invalid_unrelated_dynamic_date_fails(self) -> None: + self._replace( + "config/www/json_data/holidays.json", + '"01/19/2026": "MLK Day"', + '"2026-01-19": "MLK Day"', + 1, + ) + with self.assertRaisesRegex(CoverageError, "invalid date key"): + validate_coverage(self.root, date(2026, 7, 22), 24) + + def test_scene_routing_typo_fails(self) -> None: + self._replace( + "config/packages/holiday.yaml", + "scene.month_{{ mode }}_colors", + "scene.month_{{ mode }}_colour", + 1, + ) + with self.assertRaisesRegex(CoverageError, "package routing"): + validate_coverage(self.root, date(2026, 7, 22), 24) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/holiday_lighting_coverage.ps1 b/tools/holiday_lighting_coverage.ps1 index 929af757..a25273a5 100644 --- a/tools/holiday_lighting_coverage.ps1 +++ b/tools/holiday_lighting_coverage.ps1 @@ -7,155 +7,26 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $repoRoot = Split-Path -Parent $PSScriptRoot -$holidayPath = Join-Path $repoRoot 'config/www/json_data/holidays.json' -$flagPath = Join-Path $repoRoot 'config/www/json_data/flag_days.json' -$scenePath = Join-Path $repoRoot 'config/scene/monthly_colors.yaml' -$endDate = $StartDate.Date.AddMonths($Months) -$errors = @() +$python = Get-Command py -ErrorAction SilentlyContinue +$arguments = @() -function Read-JsonFile { - param( - [Parameter(Mandatory = $true)] - [string]$Path - ) - - if (-not (Test-Path -LiteralPath $Path)) { - throw "Missing JSON file: $Path" - } - - try { - return Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json -AsHashtable - } catch { - throw "Invalid JSON in $Path`: $($_.Exception.Message)" - } -} - -function Parse-DateKey { - param( - [Parameter(Mandatory = $true)] - [string]$DateText - ) - - return [datetime]::Parse( - $DateText, - [Globalization.CultureInfo]::InvariantCulture - ).Date -} - -function Get-DynamicEvents { - param( - [Parameter(Mandatory = $true)] - [object]$DynamicMap, - [Parameter(Mandatory = $true)] - [string[]]$Names - ) - - $events = @() - foreach ($dateText in $DynamicMap.Keys) { - $eventDate = Parse-DateKey -DateText ([string]$dateText) - $eventName = [string]$DynamicMap[$dateText] - if ($eventDate -ge $StartDate.Date -and $eventDate -lt $endDate -and $Names -contains $eventName) { - $events += [pscustomobject]@{ - Name = $eventName - Date = $eventDate - } - } - } - return $events | Sort-Object Date, Name -} - -$holidayJson = Read-JsonFile -Path $holidayPath -$flagJson = Read-JsonFile -Path $flagPath -$holidayData = $holidayJson['MAJOR_US'] -$flagData = $flagJson['Flag_Days_US'] -$holidayStatic = $holidayData['static'] -$holidayDynamic = $holidayData['dynamic'] -$flagDynamic = $flagData['dynamic'] - -$sceneText = Get-Content -Raw -LiteralPath $scenePath -$sceneNames = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) -foreach ($match in [regex]::Matches($sceneText, '(?m)^\s*-\s+name:\s+(month_[A-Za-z0-9_]+_colors)\s*$')) { - [void]$sceneNames.Add($match.Groups[1].Value) -} - -$lightingModes = @( - 'standard', - 'RWB', - 'new_years_day', - 'valentine', - 'mardi_gras', - 'pi', - 'st_patty', - 'easter', - 'starwars', - 'cinco_de_mayo', - 'mothers_day', - 'fathers_day', - 'halloween', - 'veterans', - 'thanksgiving', - 'hanukkah', - 'christmas' -) - -foreach ($mode in $lightingModes) { - $sceneName = "month_${mode}_colors" - if (-not $sceneNames.Contains($sceneName)) { - $errors += "Missing scene for lighting mode '$mode': expected $sceneName in $scenePath" - } +if ($python) { + $arguments += '-3' +} else { + $python = Get-Command python -ErrorAction SilentlyContinue } -$staticRequirements = @( - @{ Key = '1/1'; Name = 'New Years Day' }, - @{ Key = '2/14'; Name = 'Valentines Day' }, - @{ Key = '3/14'; Name = 'Pi Day' }, - @{ Key = '3/17'; Name = 'St. Patricks Day' }, - @{ Key = '5/4'; Name = 'Star Wars Day' }, - @{ Key = '5/5'; Name = 'Cinco de Mayo' }, - @{ Key = '7/4'; Name = 'Independence Day' }, - @{ Key = '10/31'; Name = 'Halloween' }, - @{ Key = '11/11'; Name = 'Veterans Day' }, - @{ Key = '12/25'; Name = 'Christmas Day' }, - @{ Key = '12/31'; Name = 'New Years Eve' } -) - -foreach ($requirement in $staticRequirements) { - if (-not $holidayStatic.ContainsKey($requirement.Key)) { - $errors += "Missing static holiday key $($requirement.Key) for $($requirement.Name)" - } +if (-not $python) { + throw "Python 3 not found. Install Python 3 and the pinned requirements in requirements-dev.txt." } -$dynamicRequirements = @( - @{ Name = 'Easter Sunday'; Source = 'holidays'; Map = $holidayDynamic }, - @{ Name = 'Mothers Day'; Source = 'holidays'; Map = $holidayDynamic }, - @{ Name = 'Fathers Day'; Source = 'holidays'; Map = $holidayDynamic }, - @{ Name = 'Thanksgiving Day'; Source = 'holidays'; Map = $holidayDynamic }, - @{ Name = 'Memorial Day'; Source = 'flag_days'; Map = $flagDynamic }, - @{ Name = 'Labor Day'; Source = 'flag_days'; Map = $flagDynamic } +$arguments += @( + "$repoRoot/tools/holiday_lighting_coverage.py" + '--months' + $Months + '--start-date' + $StartDate.ToString('yyyy-MM-dd') ) -foreach ($requirement in $dynamicRequirements) { - $events = @(Get-DynamicEvents -DynamicMap $requirement.Map -Names @($requirement.Name)) - if ($events.Count -eq 0) { - $errors += "No $($requirement.Source) calendar coverage for '$($requirement.Name)' between $($StartDate.ToString('yyyy-MM-dd')) and $($endDate.ToString('yyyy-MM-dd'))" - } -} - -Write-Host "Holiday lighting coverage window: $($StartDate.ToString('yyyy-MM-dd')) to $($endDate.ToString('yyyy-MM-dd'))" -Write-Host "JSON: OK - parsed holidays.json and flag_days.json" -Write-Host "Scenes: $($sceneNames.Count) monthly scenes found" - -if ($errors.Count -gt 0) { - foreach ($err in $errors) { - Write-Host "ERROR: $err" - } - exit 1 -} - -foreach ($requirement in $dynamicRequirements) { - $events = @(Get-DynamicEvents -DynamicMap $requirement.Map -Names @($requirement.Name)) - $dates = ($events | ForEach-Object { $_.Date.ToString('yyyy-MM-dd') }) -join ', ' - Write-Host "$($requirement.Name): $dates" -} - -Write-Host "Coverage OK: required lighting scenes and dynamic holiday dates are present for the requested window." +& $python.Source @arguments +exit $LASTEXITCODE diff --git a/tools/holiday_lighting_coverage.py b/tools/holiday_lighting_coverage.py new file mode 100644 index 00000000..7567a7e5 --- /dev/null +++ b/tools/holiday_lighting_coverage.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Validate holiday calendars, lighting scenes, and scene-routing contracts.""" + +from __future__ import annotations + +import argparse +import calendar +import json +import sys +from dataclasses import dataclass +from datetime import date, timedelta +from pathlib import Path +from typing import Callable, Iterable + +from validate_dashboards import ValidationError as YamlValidationError +from validate_dashboards import parse_yaml + + +class CoverageError(RuntimeError): + """Holiday lighting coverage is incomplete or internally inconsistent.""" + + +LIGHTING_MODES = ( + "standard", + "RWB", + "new_years_day", + "valentine", + "mardi_gras", + "pi", + "st_patty", + "easter", + "starwars", + "cinco_de_mayo", + "mothers_day", + "fathers_day", + "halloween", + "veterans", + "thanksgiving", + "hanukkah", + "christmas", +) + +STATIC_REQUIREMENTS = { + "1/1": "New Years Day", + "2/14": "Valentines Day", + "3/14": "Pi Day", + "3/17": "St. Patricks Day", + "5/4": "Star Wars Day", + "5/5": "Cinco de Mayo", + "7/4": "Independence Day", + "10/31": "Halloween", + "11/11": "Veterans Day", + "12/25": "Christmas Day", + "12/31": "New Years Eve", +} + + +@dataclass(frozen=True) +class DynamicRule: + name: str + source: str + expected_date: Callable[[int], date] + + +def gregorian_easter(year: int) -> date: + """Meeus/Jones/Butcher Gregorian Easter algorithm.""" + a = year % 19 + b = year // 100 + c = year % 100 + d = b // 4 + e = b % 4 + f = (b + 8) // 25 + g = (b - f + 1) // 3 + h = (19 * a + b - d - g + 15) % 30 + i = c // 4 + k = c % 4 + ell = (32 + 2 * e + 2 * i - h - k) % 7 + m = (a + 11 * h + 22 * ell) // 451 + month = (h + ell - 7 * m + 114) // 31 + day = ((h + ell - 7 * m + 114) % 31) + 1 + return date(year, month, day) + + +def nth_weekday(year: int, month: int, weekday: int, occurrence: int) -> date: + first = date(year, month, 1) + offset = (weekday - first.weekday()) % 7 + return first + timedelta(days=offset + 7 * (occurrence - 1)) + + +def last_weekday(year: int, month: int, weekday: int) -> date: + last = date(year, month, calendar.monthrange(year, month)[1]) + return last - timedelta(days=(last.weekday() - weekday) % 7) + + +DYNAMIC_RULES = ( + DynamicRule("Easter Sunday", "holidays", gregorian_easter), + DynamicRule("Mothers Day", "holidays", lambda year: nth_weekday(year, 5, 6, 2)), + DynamicRule("Fathers Day", "holidays", lambda year: nth_weekday(year, 6, 6, 3)), + DynamicRule("Thanksgiving Day", "holidays", lambda year: nth_weekday(year, 11, 3, 4)), + DynamicRule("Memorial Day", "flag_days", lambda year: last_weekday(year, 5, 0)), + DynamicRule("Labor Day", "flag_days", lambda year: nth_weekday(year, 9, 0, 1)), +) + + +def repo_root() -> Path: + return Path(__file__).resolve().parents[1] + + +def add_months(start: date, months: int) -> date: + if months < 1: + raise CoverageError("months must be at least 1") + month_index = start.month - 1 + months + year = start.year + month_index // 12 + month = month_index % 12 + 1 + day = min(start.day, calendar.monthrange(year, month)[1]) + return date(year, month, day) + + +def _reject_duplicate_json_keys(pairs: list[tuple[str, object]]) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise CoverageError(f"duplicate JSON key {key!r}") + result[key] = value + return result + + +def read_json(path: Path) -> dict[str, object]: + try: + value = json.loads( + path.read_text(encoding="utf-8"), + object_pairs_hook=_reject_duplicate_json_keys, + ) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise CoverageError(f"invalid JSON in {path}: {exc}") from exc + if not isinstance(value, dict): + raise CoverageError(f"JSON root must be a mapping: {path}") + return value + + +def _require_mapping(value: object, label: str) -> dict[str, object]: + if not isinstance(value, dict): + raise CoverageError(f"{label} must be a mapping") + return value + + +def parse_calendar_date(value: str, source: str) -> date: + try: + month_text, day_text, year_text = value.split("/") + parsed = date(int(year_text), int(month_text), int(day_text)) + except (TypeError, ValueError) as exc: + raise CoverageError(f"invalid date key {value!r} in {source}") from exc + if value != parsed.strftime("%m/%d/%Y"): + raise CoverageError( + f"dynamic date key must use MM/DD/YYYY format in {source}: {value!r}" + ) + return parsed + + +def validate_dynamic_map(dynamic_map: dict[str, object], source: str) -> None: + for key, value in dynamic_map.items(): + parse_calendar_date(key, source) + if not isinstance(value, str) or not value.strip(): + raise CoverageError(f"dynamic event {key!r} in {source} has no name") + + +def validate_dynamic_rule( + rule: DynamicRule, + dynamic_map: dict[str, object], + start: date, + end: date, +) -> tuple[date, ...]: + expected = { + expected_date + for year in range(start.year, end.year + 1) + if start <= (expected_date := rule.expected_date(year)) < end + } + actual = { + parse_calendar_date(key, rule.source) + for key, value in dynamic_map.items() + if value == rule.name and start <= parse_calendar_date(key, rule.source) < end + } + if actual != expected: + missing = sorted(expected - actual) + unexpected = sorted(actual - expected) + details: list[str] = [] + if missing: + details.append("missing " + ", ".join(day.isoformat() for day in missing)) + if unexpected: + details.append("unexpected " + ", ".join(day.isoformat() for day in unexpected)) + raise CoverageError(f"{rule.name} coverage mismatch: {'; '.join(details)}") + return tuple(sorted(actual)) + + +def validate_scenes(path: Path) -> tuple[str, ...]: + try: + document = parse_yaml(path) + except YamlValidationError as exc: + raise CoverageError(str(exc)) from exc + if not isinstance(document, list): + raise CoverageError(f"scene file must contain a list: {path}") + + scenes: dict[str, dict[str, object]] = {} + for index, scene in enumerate(document): + if not isinstance(scene, dict): + raise CoverageError(f"scene {index} must be a mapping") + name = scene.get("name") + if not isinstance(name, str) or not name: + raise CoverageError(f"scene {index} is missing name") + if name in scenes: + raise CoverageError(f"duplicate scene name {name!r}") + scenes[name] = scene + + for mode in LIGHTING_MODES: + scene_name = f"month_{mode}_colors" + scene = scenes.get(scene_name) + if scene is None: + raise CoverageError(f"missing required scene {scene_name}") + entities = scene.get("entities") + if not isinstance(entities, dict) or not entities: + raise CoverageError(f"scene {scene_name} must contain entities") + for entity_id, state_config in entities.items(): + if not isinstance(state_config, dict): + raise CoverageError( + f"scene {scene_name} entity {entity_id} must use a state mapping" + ) + if state_config.get("state") != "on": + raise CoverageError( + f"scene {scene_name} entity {entity_id} must have state: on" + ) + return tuple(sorted(scenes)) + + +def validate_routing_contract(package_path: Path, script_path: Path) -> None: + try: + package_text = package_path.read_text(encoding="utf-8") + script_text = script_path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + raise CoverageError(f"cannot read holiday routing configuration: {exc}") from exc + + required_package_fragments = ( + "resource: http://localhost:8123/local/json_data/holidays.json", + "resource: http://localhost:8123/local/json_data/flag_days.json", + "unique_id: holiday_lighting_mode", + "unique_id: holiday_lighting_scene", + "scene.month_{{ mode }}_colors", + "scene.month_standard_colors", + ) + for fragment in required_package_fragments: + if fragment not in package_text: + raise CoverageError(f"holiday package routing is missing {fragment!r}") + + for mode in LIGHTING_MODES: + if mode == "standard": + continue + assignment = f"mode.value = '{mode}'" + if assignment not in package_text: + raise CoverageError(f"holiday lighting mode is never assigned: {mode}") + + required_script_fragments = ( + "service: scene.turn_on", + "sensor.holiday_lighting_scene", + "scene.month_standard_colors", + ) + for fragment in required_script_fragments: + if fragment not in script_text: + raise CoverageError(f"monthly scene script is missing {fragment!r}") + + +def validate_coverage(root: Path, start: date, months: int) -> dict[str, tuple[date, ...]]: + root = root.resolve() + end = add_months(start, months) + holiday_path = root / "config" / "www" / "json_data" / "holidays.json" + flag_path = root / "config" / "www" / "json_data" / "flag_days.json" + scene_path = root / "config" / "scene" / "monthly_colors.yaml" + + holidays = _require_mapping(read_json(holiday_path).get("MAJOR_US"), "MAJOR_US") + flags = _require_mapping(read_json(flag_path).get("Flag_Days_US"), "Flag_Days_US") + holiday_static = _require_mapping(holidays.get("static"), "MAJOR_US.static") + holiday_dynamic = _require_mapping(holidays.get("dynamic"), "MAJOR_US.dynamic") + flag_dynamic = _require_mapping(flags.get("dynamic"), "Flag_Days_US.dynamic") + validate_dynamic_map(holiday_dynamic, "holidays") + validate_dynamic_map(flag_dynamic, "flag_days") + + for key, expected_name in STATIC_REQUIREMENTS.items(): + actual_name = holiday_static.get(key) + if actual_name != expected_name: + raise CoverageError( + f"static holiday {key} must be {expected_name!r}, got {actual_name!r}" + ) + + dates: dict[str, tuple[date, ...]] = {} + for rule in DYNAMIC_RULES: + source_map = holiday_dynamic if rule.source == "holidays" else flag_dynamic + dates[rule.name] = validate_dynamic_rule(rule, source_map, start, end) + + validate_scenes(scene_path) + validate_routing_contract( + root / "config" / "packages" / "holiday.yaml", + root / "config" / "script" / "monthly_color_scene.yaml", + ) + return dates + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--root", type=Path, default=repo_root()) + parser.add_argument("--months", type=int, default=24) + parser.add_argument("--start-date", type=date.fromisoformat, default=date.today()) + return parser + + +def main(argv: Iterable[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + results = validate_coverage(args.root, args.start_date, args.months) + end = add_months(args.start_date, args.months) + except CoverageError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + print(f"Holiday lighting coverage window: {args.start_date} to {end}") + for name, dates in results.items(): + print(f"{name}: {', '.join(day.isoformat() for day in dates)}") + print( + "Coverage OK: exact per-year calendar dates, scene behavior, and routing contracts validated." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())