Skip to content

Commit 1e08f73

Browse files
SealKanKan-nguyenkingpanther13
authored
feat: detect last_changed/last_updated duration math and suggest for: field (#1157) (#1264)
* feat: detect last_changed/last_updated duration math and suggest for: field (#1157) Adds _RE_DURATION_MATH to best_practice_checker.py to detect template conditions and triggers using now() - X.last_changed/last_updated arithmetic. These patterns should use the native `for:` field on state triggers instead (event-driven, no template re-evaluation overhead). Fires in both condition templates (_check_template_string) and template trigger value_templates (_check_triggers). Includes 6 new tests covering conditions, triggers, the positive clean case, skill ref presence, and no-generic-double-flag. * fix: tighten _RE_DURATION_MATH to require dotted qualifier on both alternations The second alternation previously matched bare Jinja variables named `last_changed` (e.g. `{{ last_changed < now() }}`). Require at least one dotted qualifier on both patterns so only entity attribute access is flagged. Add two new tests: - test_trigger_warning_uses_trigger_types_anchor: verifies trigger-path warning links to #trigger-types (not #native-conditions) - test_no_false_positive_bare_last_changed_variable: verifies bare Jinja variable `last_changed < now()` does not trigger the detector * feat(internal): extend duration math detector to numeric_state value_template numeric_state triggers can carry a value_template field; duration/recency math inside that field was previously not flagged. Add a dedicated check block for platform: numeric_state so the detector covers both template and numeric_state trigger platforms. Add test_numeric_state_trigger_value_template_duration_math to verify. * style: ruff format best_practice_checker and test file * test: add reversed-comparison duration-math regression tests (#1157) Cover the `X.last_changed/last_updated < now()` alternation of _RE_DURATION_MATH in both condition and trigger value_template contexts. Each test asserts exactly one warning fires, which also guards against the two alternations double-flagging the same template. Addresses review feedback on PR #1264. --------- Co-authored-by: khoanguyentester-lgtm <nguyenanhkhoa301199@gmail.com> Co-authored-by: kingpanther13 <25392815+kingpanther13@users.noreply.github.com>
1 parent ea16661 commit 1e08f73

2 files changed

Lines changed: 884 additions & 366 deletions

File tree

src/ha_mcp/tools/best_practice_checker.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,13 @@
7777
_RE_STATE_IN = re.compile(r"states\s*\([^)]+\)\s+in\s+[\[(]")
7878
# Unsafe direct state access: states.sensor.x.state
7979
_RE_DIRECT_STATE = re.compile(r"\bstates\.\w+\.\w+\.state\b")
80+
# Duration/recency checks via last_changed or last_updated arithmetic.
81+
# Both alternations require at least one dotted qualifier (e.g. ``states.sensor.x.``)
82+
# so bare Jinja variables named ``last_changed`` are not falsely flagged.
83+
_RE_DURATION_MATH = re.compile(
84+
r"\bnow\(\)\s*-\s*(?:\w+\.)+last_(?:changed|updated)\b"
85+
r"|\b(?:\w+\.)+last_(?:changed|updated)\s*<\s*now\(\)"
86+
)
8087
# Motion entity pattern
8188
_RE_MOTION = re.compile(r"binary_sensor\.\w*motion", re.IGNORECASE)
8289
# Any Jinja template marker — catch-all and target-field scan.
@@ -273,6 +280,16 @@ def _check_template_string(
273280
"function instead (returns 'unknown' if missing rather than raising)."
274281
+ _ref(skill_prefix, "template-guidelines.md#common-patterns")
275282
)
283+
if _RE_DURATION_MATH.search(template):
284+
warnings.append(
285+
f"{label} uses template for duration/recency check "
286+
"(`now() - X.last_changed/last_updated`) — use the native `for:` field "
287+
"on a `state` trigger or condition instead "
288+
"(e.g., `platform: state, entity_id: binary_sensor.motion, to: 'off', "
289+
"for: {minutes: 5}`). Native `for:` is event-driven and avoids repeated "
290+
"template evaluation on every state change."
291+
+ _ref(skill_prefix, "automation-patterns.md#native-conditions")
292+
)
276293

277294
# Generic fallback: any Jinja in this logic position that didn't match
278295
# a specific detector. Catches new anti-patterns (issue #1011) and
@@ -505,6 +522,19 @@ def _check_triggers(
505522
"automation-patterns.md#trigger-types",
506523
)
507524
)
525+
if _RE_DURATION_MATH.search(vt):
526+
warnings.append(
527+
"Trigger uses template for duration/recency check "
528+
"(`now() - X.last_changed/last_updated`) — use the native "
529+
"`for:` field on a `state` trigger instead "
530+
"(e.g., `platform: state, entity_id: binary_sensor.motion, "
531+
"to: 'off', for: {minutes: 5}`). Native `for:` is event-driven "
532+
"and doesn't re-evaluate on every state change."
533+
+ _ref(
534+
skill_prefix,
535+
"automation-patterns.md#trigger-types",
536+
)
537+
)
508538
# Generic fallback for unmatched template triggers.
509539
if len(warnings) == initial and _RE_ANY_TEMPLATE.search(vt):
510540
warnings.append(
@@ -518,6 +548,21 @@ def _check_triggers(
518548
)
519549
)
520550

551+
# numeric_state trigger: value_template can also contain duration math
552+
# (e.g. transforming last_changed into a seconds value for the threshold).
553+
if platform == "numeric_state":
554+
vt = trigger.get("value_template", "")
555+
if isinstance(vt, str) and _RE_DURATION_MATH.search(vt):
556+
warnings.append(
557+
"`numeric_state` trigger uses `value_template` for duration/recency "
558+
"check (`now() - X.last_changed/last_updated`) — use the native "
559+
"`for:` field on a `state` trigger instead "
560+
"(e.g., `platform: state, entity_id: binary_sensor.motion, "
561+
"to: 'off', for: {minutes: 5}`). Native `for:` is event-driven "
562+
"and doesn't re-evaluate on every state change."
563+
+ _ref(skill_prefix, "automation-patterns.md#trigger-types")
564+
)
565+
521566

522567
# ---------------------------------------------------------------------------
523568
# Mode + motion check

0 commit comments

Comments
 (0)