diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b2881a..39a69a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Root-cause suppression (external-review noise).** A GA with an **empty name** has an unreliable + classification, so it no longer cascades into a `missing_status_address` warning and a + `policy_taxonomy_outlier` — the single root cause (`empty_name` from `check_naming`) is kept and the + dependent findings are suppressed. On the demo house the empty `2/5/2` now yields one finding instead + of three. `tests/test_root_cause.py`. + - **Noise & unsafe repairs surfaced by an external expert review on the demo house.** (1) Scene-control GAs (DPT 17/18) are no longer flagged as `missing_status` — they recall a preset and have no single state to read back (now `scene_no_status`, INFO), and `suggest_repairs` no longer synthesises a bogus diff --git a/nickol_knx_mcp/analyze.py b/nickol_knx_mcp/analyze.py index 125367e..b44288e 100644 --- a/nickol_knx_mcp/analyze.py +++ b/nickol_knx_mcp/analyze.py @@ -249,6 +249,8 @@ def detect_missing_status(project: LoadedProject) -> list[dict[str, Any]]: continue if ga.intent != INTENT_FUNCTIONAL: continue # reserve / logic / scratch GAs need no status by design + if not (ga.name or "").strip(): + continue # root cause is empty_name (check_naming); classification unreliable if ga.kind != "command": continue if ga.dpt_main is None: diff --git a/nickol_knx_mcp/policy.py b/nickol_knx_mcp/policy.py index c675900..0ff70fd 100644 --- a/nickol_knx_mcp/policy.py +++ b/nickol_knx_mcp/policy.py @@ -131,6 +131,8 @@ def check_policy(project: LoadedProject, policy: dict[str, Any]) -> dict[str, An if ga.intent != INTENT_FUNCTIONAL or ga.main is None: continue seen_mains.add(ga.main) + if not (ga.name or "").strip(): + continue # empty name -> unreliable classification; root cause is empty_name # 1. main-group taxonomy conformance (only when the domain is known) allowed = mg.get(ga.main) diff --git a/tests/test_root_cause.py b/tests/test_root_cause.py new file mode 100644 index 0000000..474dbc0 --- /dev/null +++ b/tests/test_root_cause.py @@ -0,0 +1,53 @@ +"""Root-cause suppression (external-review noise): a GA with an empty name has an +unreliable classification, so it must NOT cascade into missing_status and policy +taxonomy findings — the single root cause is `empty_name` (check_naming). +""" +from nickol_knx_mcp.project import build_loaded_from_raw +from nickol_knx_mcp.analyze import validate_naming, detect_missing_status +from nickol_knx_mcp.policy import check_policy, load_policy + + +def _ga(addr, name, dmain, dsub): + return {"name": name, "identifier": f"GA-{addr}", "raw_address": 0, "address": addr, + "project_uid": None, "dpt": {"main": dmain, "sub": dsub}, "data_secure": False, + "communication_object_ids": [], "description": "", "comment": ""} + + +def _proj(gas): + raw = {"info": {"group_address_style": "ThreeLevel", "schema_version": "21"}, + "group_addresses": gas, "communication_objects": {}, "devices": {}, + "functions": {}, "topology": {}, "group_ranges": {}} + return build_loaded_from_raw(raw, "t.knxproj") + + +def main(): + # main 2 is a clear shutter zone; an empty-name 5.001 command sits in it + gas = { + "2/0/1": _ga("2/0/1", "Blind A up/down", 1, 8), + "2/0/2": _ga("2/0/2", "Blind B up/down", 1, 8), + "2/0/3": _ga("2/0/3", "Blind C up/down", 1, 8), + "2/5/2": _ga("2/5/2", "", 5, 1), # EMPTY name, 5.001 command + } + p = _proj(gas) + + naming = {f["code"] for f in validate_naming(p) if "2/5/2" in str(f.get("address", ""))} + status = [f for f in detect_missing_status(p) if "2/5/2" in str(f.get("address", ""))] + policy = [f for f in check_policy(p, load_policy())["findings"] + if "2/5/2" in str(f.get("address", ""))] + + assert "empty_name" in naming, naming # the single root cause is kept + assert status == [], f"empty-name GA must not cascade to missing_status: {status}" + assert policy == [], f"empty-name GA must not cascade to policy taxonomy: {policy}" + + # sanity: a NAMED command in the same setup is still checked + gas["2/0/9"] = _ga("2/0/9", "Blind D up/down", 1, 8) + p2 = _proj(gas) + assert any(f["address"] == "2/0/9" for f in detect_missing_status(p2)), \ + "named command must still be flagged for missing status" + + print("test_root_cause: OK — empty-name GA yields only empty_name; no missing_status / " + "policy cascade; named GAs still checked.") + + +if __name__ == "__main__": + main()