Skip to content

Commit 6ebeeb1

Browse files
authored
fix: suppress purpose-specific condition intents as false positives (#306) (#309)
- Add 'condition' to IGNORED_VALUE_KEYS to suppress 'condition: person.is_not_home' - Add is_template() guard so Jinja2 templates under condition:/trigger: keys are still scanned - Bump CURRENT_DB_SCHEMA_VERSION to 9 to force cache reset for updated heuristic - Update heuristics.md with revised entry and new heuristic 24 for purpose-specific conditions - Add regression tests covering all four scenarios
1 parent 3b9e44a commit 6ebeeb1

7 files changed

Lines changed: 125 additions & 8 deletions

File tree

custom_components/watchman/const.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
DEFAULT_REPORT_FILENAME = f"{DOMAIN}_report.txt"
2020
DB_FILENAME = f"{DOMAIN}_v2.db"
2121
LEGACY_DB_FILENAME = f"{DOMAIN}.db"
22-
CURRENT_DB_SCHEMA_VERSION = 8
22+
CURRENT_DB_SCHEMA_VERSION = 9
2323
STORAGE_KEY = f"{DOMAIN}.stats"
2424
STORAGE_VERSION = 1
2525
LOCK_FILENAME = f"{DOMAIN}.lock"

custom_components/watchman/utils/parser_const.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,11 @@
5858
ACTION_KEYS = {'service', 'action', 'service_template', 'perform_action'}
5959

6060
# Keys where the parser ignores the immediate string value (to avoid false positives)
61-
# but continues recursion if the value is a complex structure
62-
IGNORED_VALUE_KEYS = {'trigger', 'triggers'}
61+
# but continues recursion if the value is a complex structure.
62+
# 'condition' (singular) covers HA 2025.12+ Purpose-specific condition intents
63+
# (e.g. `condition: person.is_not_home`). 'conditions' (plural) is NOT listed here
64+
# because its value is always a list — recursion handles it without hitting the str branch.
65+
IGNORED_VALUE_KEYS = {'trigger', 'triggers', 'condition'}
6366

6467
# Domains to parse in core.config_entries
6568
CONFIG_ENTRY_DOMAINS = {'group', 'template'}

custom_components/watchman/utils/parser_core.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -475,10 +475,11 @@ def _recursive_search(
475475
line_no = getattr(data, "line", None)
476476
key_name = parent_key
477477

478-
# ignore _values_ of the key from IGNORED_VALUE_KEYS
479-
# this does not prevent parser to traverse in if there are nested keys
478+
# Ignore direct string values of IGNORED_VALUE_KEYS (e.g. trigger, condition intents)
479+
# EXCEPT when the value is a Jinja2 template — those must be scanned for entities (Heuristic 21).
480480
if key_name and str(key_name).lower() in IGNORED_VALUE_KEYS:
481-
return
481+
if not is_template(data):
482+
return
482483

483484
# Sanitize Jinja comments to avoid false positives inside comments
484485
# We process a copy so we don't modify the original 'data' object which might have attributes

docs/dev/heuristics.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
5. **List vs Single:** The parser handles `entity_id` fields that contain either a single string or a list of strings (common in triggers, conditions, and targets).
88
6. **String Concatenation:** Entity IDs that appear to be part of a string concatenation (detected by surrounding `+`, `~`, `%`, or `.format`) are ignored, assuming they are dynamic templates.
99
7. **Wildcards:** Any entity ID followed immediately by a wildcard (`*`) is ignored (e.g., in glob patterns).
10-
8. **Ignored Keys:** The parser explicitly ignores content under specific keys: `url`, `example`, and `description`, to avoid false positives in documentation or metadata fields.
10+
8. **Ignored Keys:** The parser explicitly ignores content under specific keys: `url`, `example`, and `description`, to avoid false positives in documentation or metadata fields. Additionally, the immediate string value of `trigger:`, `triggers:`, and `condition:` keys is suppressed (see Heuristic 24), while recursion into nested child structures continues normally. This suppression is bypassed when the value is a Jinja2 template (see Heuristic 21).
1111
9. **ESPHOME Context:** When parsing ESPHome configuration files (detected by path), entities and services are *only* extracted if they are values of `service`, `action`, or `entity_id` keys.
1212
10. **Automation Context:** The parser analyzes the parent hierarchy of an item to determine if it resides within an "automation" (has `trigger`+`action`) or "script" (has `sequence`).
1313
11. **Embedded Services:** Service calls embedded within string values are detected using a regex pattern (e.g., matching `service: domain.service` inside a template).
@@ -23,3 +23,4 @@
2323
21. **Inline Template Detection:** The parser detects template markers (`{{`, `{%`, `{#`, `[[[`) anywhere within a string value, allowing it to correctly identify entities embedded in dynamic strings (e.g., `action: domain.service_{{ id }}`) and avoid misidentifying dynamic service calls as missing items.
2424
22. **Block Scalar Line Alignment:** When identifying items within YAML block scalars (multi-line strings denoted by `|` or `>`), the parser applies a line offset correction to ensure reported line numbers correspond to the actual content rather than the block definition header.
2525
23. **Template Prefix Detection:** To prevent extracting partial or incorrect entity IDs from Jinja2 templates or custom frontend components (e.g., `decluttering-card`), the parser ignores matches that are immediately followed by an opening curly brace `{` or an opening square bracket `[`.
26+
24. **Purpose-Specific Condition Intents:** String values appearing directly under a `condition:` key (e.g. `condition: person.is_not_home`) are treated as HA 2025.12+ Purpose-specific condition intents rather than entity references and are suppressed to prevent false positives. This suppression is bypassed when the value contains a Jinja2 template marker (see Heuristic 21), ensuring that `condition: "{{ is_state('light.x', 'on') }}"` still yields `light.x` as a candidate. Recursion into nested child structures (e.g. `target.entity_id` within the same condition block) is unaffected.

tests/__snapshots__/test_db_integrity.ambr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# serializer version: 1
22
# name: test_db_schema_integrity
33
tuple(
4-
8,
4+
9,
55
'''
66
Table: found_items
77
CREATE TABLE found_items (
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Fixture: purpose-specific condition intents (HA 2025.12+)
2+
# Covers four scenarios tested in test_parser_purpose_specific_conditions.py
3+
4+
automation:
5+
- id: "test_purpose_specific_automation"
6+
alias: "Purpose-Specific Conditions Test"
7+
trigger:
8+
- platform: state
9+
entity_id: binary_sensor.motion_sensor
10+
condition:
11+
# Scenario 1: purpose-specific condition intent — must NOT be extracted as entity
12+
- condition: person.is_not_home
13+
target:
14+
# Scenario 2: nested entity_id inside the same block — MUST be extracted
15+
entity_id: person.xxxxxxxxxx
16+
options:
17+
behavior: any
18+
# Scenario 3: Jinja2 template under condition: — light.kitchen MUST be extracted
19+
- condition: "{{ is_state('light.kitchen', 'on') }}"
20+
action:
21+
- service: light.turn_on
22+
target:
23+
entity_id: light.living_room
24+
25+
# Scenario 4: Jinja2 template under trigger: — sensor.outdoor_temp MUST be extracted
26+
- id: "test_trigger_template_automation"
27+
alias: "Trigger Template Test"
28+
trigger: "{{ states('sensor.outdoor_temp') | float > 30 }}"
29+
action:
30+
- service: fan.turn_on
31+
target:
32+
entity_id: fan.bedroom
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Regression tests for HA 2025.12+ Purpose-Specific Condition Intents (#306).
2+
3+
String values directly under a `condition:` key that look like entity IDs
4+
(e.g. `condition: person.is_not_home`) are Purpose-specific condition intents,
5+
NOT entity references, and must not be reported as false positives.
6+
"""
7+
import asyncio
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
from custom_components.watchman.utils.parser_core import WatchmanParser
13+
14+
15+
@pytest.fixture
16+
def parser_client(tmp_path):
17+
"""Create a WatchmanParser instance with a temporary database."""
18+
db_path = tmp_path / "watchman.db"
19+
return WatchmanParser(str(db_path))
20+
21+
22+
def _parse_and_get_entities(parser_client, yaml_dir):
23+
"""Helper: parse a directory and return list of extracted entity IDs."""
24+
asyncio.run(parser_client.async_parse(yaml_dir, []))
25+
items = parser_client.get_found_items(item_type="all")
26+
return [item[0] for item in items if item[3] == "entity"]
27+
28+
29+
def test_purpose_specific_condition_not_extracted(parser_client, new_test_data_dir):
30+
"""Test 1: condition: person.is_not_home must NOT appear in extracted entities.
31+
32+
'person.is_not_home' is a Purpose-specific condition intent (HA 2025.12+),
33+
not an entity reference. IGNORED_VALUE_KEYS must suppress it.
34+
"""
35+
yaml_dir = str(Path(new_test_data_dir) / "yaml_config")
36+
entities = _parse_and_get_entities(parser_client, yaml_dir)
37+
assert "person.is_not_home" not in entities, (
38+
"person.is_not_home is a condition intent and must not be reported as an entity"
39+
)
40+
41+
42+
def test_nested_entity_id_still_extracted(parser_client, new_test_data_dir):
43+
"""Test 2: entity_id nested inside a condition block must still be extracted.
44+
45+
IGNORED_VALUE_KEYS suppresses only the immediate string value of 'condition:'.
46+
Recursion into child structures must continue so that
47+
target.entity_id: person.xxxxxxxxxx is still found.
48+
"""
49+
yaml_dir = str(Path(new_test_data_dir) / "yaml_config")
50+
entities = _parse_and_get_entities(parser_client, yaml_dir)
51+
assert "person.xxxxxxxxxx" in entities, (
52+
"person.xxxxxxxxxx is a real entity under target.entity_id and must be extracted"
53+
)
54+
55+
56+
def test_jinja2_template_in_condition_extracted(parser_client, new_test_data_dir):
57+
"""Test 3: Jinja2 template under condition: must still yield entities (Heuristic 21).
58+
59+
condition: "{{ is_state('light.kitchen', 'on') }}"
60+
light.kitchen must be extracted — is_template() guard must bypass suppression.
61+
"""
62+
yaml_dir = str(Path(new_test_data_dir) / "yaml_config")
63+
entities = _parse_and_get_entities(parser_client, yaml_dir)
64+
assert "light.kitchen" in entities, (
65+
"light.kitchen inside a Jinja2 template under condition: must be extracted"
66+
)
67+
68+
69+
def test_jinja2_template_in_trigger_extracted(parser_client, new_test_data_dir):
70+
"""Test 4: Jinja2 template under trigger: must still yield entities.
71+
72+
trigger: "{{ states('sensor.outdoor_temp') | float > 30 }}"
73+
sensor.outdoor_temp must be extracted — verifies that the is_template()
74+
exception that fixes condition: also works correctly for trigger:.
75+
"""
76+
yaml_dir = str(Path(new_test_data_dir) / "yaml_config")
77+
entities = _parse_and_get_entities(parser_client, yaml_dir)
78+
assert "sensor.outdoor_temp" in entities, (
79+
"sensor.outdoor_temp inside a Jinja2 template under trigger: must be extracted"
80+
)

0 commit comments

Comments
 (0)