Skip to content

Commit ff30188

Browse files
committed
Measure the complexity limit in CI, and split the one function over it
CLAUDE.md sets cyclomatic complexity at 10 and names radon, but nothing ran it. The whole package had one function over the limit, so the limit is cheap to hold: the Triggers tab's combine handler now delegates to a Qt-free children_to_combine() that has its own tests, and a test runs radon over the package. radon joins dev_requirements and the CI install so the gate runs there instead of skipping.
1 parent 29892be commit ff30188

9 files changed

Lines changed: 149 additions & 17 deletions

File tree

‎.github/workflows/quality.yml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ jobs:
139139
shell: bash
140140
# Quoted: `--only-binary :all:` puts a colon-space inside the
141141
# scalar, which YAML reads as a mapping and refuses.
142-
run: "pip install --only-binary :all: ruff==0.15.22 bandit==1.9.4 pytest==9.1.1 pytest-timeout==2.4.0 pytest-rerunfailures==15.1 coverage==7.15.4 PySide6==6.11.1"
142+
run: "pip install --only-binary :all: ruff==0.15.22 bandit==1.9.4 pytest==9.1.1 pytest-timeout==2.4.0 pytest-rerunfailures==15.1 coverage==7.15.4 PySide6==6.11.1 radon==6.0.1"
143143

144144
# Paths come from `testpaths` in pyproject.toml. Do NOT pass an explicit
145145
# path here: an argument overrides testpaths, which previously meant the

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ Anything agreed but not done — deferred follow-ups, known gaps, half-delivered
123123

124124
Cyclomatic complexity ≤ 10 · cognitive complexity ≤ 15 · function ≤ 75 lines · parameters ≤ 7 · nesting ≤ 4 · file ≤ 750 lines · line ≤ 120 chars · no duplicated block ≥ 10 lines.
125125

126-
**What actually enforces these.** `quality.yml` has five jobs — `lint` (ruff), `security` (bandit), `pytest-headless` (the suite plus the coverage floor), `typing-stable-api` (mypy) and `dependency-review`. Of the limits in this section, line length is rejected by ruff (`[tool.ruff] line-length = 120` with `E501`; it exempts a line ending in a pragma, which cannot wrap), and the file-length limit by `test/unit_test/headless/test_file_length_budget.py`, which reads the exemption list in `Progress.md` and fails on a file over the limit that is not listed, on a listed file that grew past its recorded ceiling, and on a row whose file is now under the limit. Complexity is measured by `radon cc -nc` in the pre-commit list below and read by a human — that part is a review standard, not a gate.
126+
**What actually enforces these.** `quality.yml` has five jobs — `lint` (ruff), `security` (bandit), `pytest-headless` (the suite plus the coverage floor), `typing-stable-api` (mypy) and `dependency-review`. Of the limits in this section, line length is rejected by ruff (`[tool.ruff] line-length = 120` with `E501`; it exempts a line ending in a pragma, which cannot wrap), and the file-length limit by `test/unit_test/headless/test_file_length_budget.py`, which reads the exemption list in `Progress.md` and fails on a file over the limit that is not listed, on a listed file that grew past its recorded ceiling, and on a row whose file is now under the limit. Cyclomatic complexity is measured with the same `radon` the pre-commit list below names, by `test/unit_test/headless/test_complexity_budget.py`; the whole package was one function over the limit when that gate went in. Cognitive complexity, function length, parameter count and nesting depth are still review standards rather than gates.
127127

128128
**Scope of the file-length limit.** It applies to:
129129

‎architecture_explore.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、
2020
| 指標 | 數值 |
2121
| --- | ---: |
2222
| Python 模組總數(含周邊子專案) | 1,043 |
23-
| 程式碼總行數 | 142,003 |
23+
| 程式碼總行數 | 142,022 |
2424
| `je_auto_control/utils/` 子套件數 | 310 |
2525
| `AC_*` 動作指令數(`known_commands()` 實測) | 773 |
2626
| 套件門面 `__all__` 公開名稱數 | 1,238 |
@@ -1056,7 +1056,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。
10561056

10571057
| 層/子系統 | 檔案數 | 行數 |
10581058
| --- | ---: | ---: |
1059-
| `gui/` | 91 | 26,772 |
1059+
| `gui/` | 91 | 26,791 |
10601060
| `utils/mcp_server/` | 31 | 17,473 |
10611061
| `utils/remote_desktop/` | 56 | 12,039 |
10621062
| `utils/executor/` | 6 | 9,081 |
@@ -1077,5 +1077,5 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。
10771077
| `autocontrol-lsp/` | 8 | 744 |
10781078
| `utils/hotkey/` | 7 | 738 |
10791079
| 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 673 | 47,957 |
1080-
| **總計** | **1,037** | **141,938** |
1080+
| **總計** | **1,037** | **141,957** |
10811081

‎dev_requirements.txt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,7 @@ pytest-rerunfailures==15.1
3131
# `pytest --cov` under-reports by ~24 points. Measure with
3232
# `coverage run -m pytest`; see the comment in .github/workflows/quality.yml.
3333
coverage==7.15.4
34+
# The complexity limit in CLAUDE.md is measured by this, both in the
35+
# pre-commit list and by test/unit_test/headless/test_complexity_budget.py.
36+
radon==6.0.1
3437
mypy>=1.15

‎docs/updates/2026-09.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,3 +220,11 @@ Index and query commands: [README.md](README.md). New entries go at the end.
220220
- **Result / numbers**: 4 tests; `-k "file_length or webrtc or remote_desktop_gui or r3_gui"`: 496 passed, 4 skipped. `CLAUDE.md`'s "what actually enforces these" paragraph now names both gates (ruff for line length, this test for file length) and keeps complexity as the one that is still a review standard.
221221
- **Files**: `test/unit_test/headless/test_file_length_budget.py` (new), `je_auto_control/gui/remote_desktop/trusted_group.py` (new), `je_auto_control/gui/remote_desktop/webrtc_panel.py`, `CLAUDE.md`, `Progress.md`, `architecture_explore.md`.
222222
- **Open items**: none.
223+
224+
## U-20260923-12 · 2026-09-23 · Complexity limit measured in CI; the one function over it split · #done #quality
225+
226+
- **What**: `CLAUDE.md` set cyclomatic complexity at 10 and named `radon cc -nc` as the tool, but nothing ran it — it sat in the pre-commit list, "read by a human", which is how the file-length list had drifted too (U-20260923-11). Measured across the package: exactly **one** function was over the limit, `gui/triggers_tab.py::TriggersTab._on_combine`. So the limit was cheap to hold, and `test/unit_test/headless/test_complexity_budget.py` now measures it with the same `radon` a developer runs (added to `dev_requirements.txt` and to `quality.yml`'s install line, so it is a gate in CI rather than a skip).
227+
- **The one function**: `_on_combine` read the selection, validated it, resolved ids to triggers, removed the originals and built the composite in one body. The selection is `_selected_ids`, the resolution is the module-level `children_to_combine(ids, triggers)` — no Qt, so the part worth testing is testable — and the mode-to-class mapping is the module constant `COMPOSITE_BY_MODE`. Behaviour is unchanged, including that ids the table still shows but the engine no longer has are dropped rather than raising.
228+
- **Result / numbers**: `test_triggers_tab_combine.py` (7 tests) covers the combining logic, which had none — the tab's only tests were of the trigger engine behind it. `test_complexity_budget.py` 2 tests, one a positive control on synthetic code so an empty result cannot mean the measurement stopped working.
229+
- **Files**: `je_auto_control/gui/triggers_tab.py`, `test/unit_test/headless/{test_complexity_budget,test_triggers_tab_combine}.py` (new), `dev_requirements.txt`, `.github/workflows/quality.yml`, `CLAUDE.md`, `architecture_explore.md`.
230+
- **Open items**: none. Cognitive complexity, function length, parameter count and nesting depth are still review standards; `radon` measures the first of those four only.

‎docs/updates/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here
5858

5959
| ID | Date | Title | Tags | Batch |
6060
|---|---|---|---|---|
61+
| U-20260923-12 | 2026-09-23 | Complexity limit measured in CI; the one function over it split | #done #quality | [2026-09](2026-09.md) |
6162
| U-20260923-11 | 2026-09-23 | The 750-line limit is now a gate, and it caught one | #done #quality | [2026-09](2026-09.md) |
6263
| U-20260923-10 | 2026-09-23 | Review follow-ups: priming race, stub width, key-table contract | #done #review | [2026-09](2026-09.md) |
6364
| U-20260923-09 | 2026-09-23 | _handlers.py under the line limit: nine themed modules | #done #mcp | [2026-09](2026-09.md) |
@@ -116,7 +117,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here
116117

117118
| File | Period | Entries |
118119
|---|---|---:|
119-
| [2026-09.md](2026-09.md) | 2026-09 | 27 |
120+
| [2026-09.md](2026-09.md) | 2026-09 | 28 |
120121
| [2026-08-f.md](2026-08-f.md) | 2026-08 | 1 |
121122
| [2026-08-e.md](2026-08-e.md) | 2026-08 | 2 |
122123
| [2026-08-d.md](2026-08-d.md) | 2026-08 | 2 |

‎je_auto_control/gui/triggers_tab.py‎

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""Triggers tab: image / window / pixel / file event watchers."""
2-
from typing import Optional
2+
from typing import Any, Dict, Iterable, List, Optional, Sequence
33

44
from PySide6.QtCore import QTimer
55
from PySide6.QtWidgets import (
@@ -29,6 +29,24 @@ def _t(key: str) -> str:
2929
)
3030

3131

32+
#: Which composite trigger each "combine" button builds.
33+
COMPOSITE_BY_MODE: Dict[str, type] = {
34+
"all": AllOfTrigger, "any": AnyOfTrigger, "sequence": SequenceTrigger,
35+
}
36+
37+
38+
def children_to_combine(ids: Sequence[str],
39+
triggers: Iterable[Any]) -> List[Any]:
40+
"""The triggers named by ``ids``, or ``[]`` when fewer than two resolve.
41+
42+
Ids that no longer name a trigger are dropped: the table can outlive a
43+
removal, and combining one trigger with nothing is not a composite.
44+
"""
45+
by_id = {trigger.trigger_id: trigger for trigger in triggers}
46+
children = [by_id[tid] for tid in ids if tid in by_id]
47+
return children if len(children) >= 2 else []
48+
49+
3250
class TriggersTab(TranslatableMixin, QWidget):
3351
"""Build triggers, run the engine, inspect the table."""
3452

@@ -256,27 +274,28 @@ def _on_remove(self) -> None:
256274
default_trigger_engine.remove(tid)
257275
self._refresh()
258276

259-
def _on_combine(self, mode: str) -> None:
277+
def _selected_ids(self) -> List[str]:
278+
"""Trigger ids of the selected rows, in row order, without duplicates."""
260279
rows = sorted({idx.row() for idx in self._table.selectedIndexes()})
261-
ids = [self._table.item(row, 0).text() for row in rows
262-
if self._table.item(row, 0) is not None]
280+
return [self._table.item(row, 0).text() for row in rows
281+
if self._table.item(row, 0) is not None]
282+
283+
def _on_combine(self, mode: str) -> None:
284+
ids = self._selected_ids()
263285
if len(ids) < 2:
264286
QMessageBox.warning(self, "Error", _t("tr_combine_need_two"))
265287
return
266288
script = self._script_input.text().strip()
267289
if not script:
268290
QMessageBox.warning(self, "Error", "Script path is required")
269291
return
270-
by_id = {t.trigger_id: t
271-
for t in default_trigger_engine.list_triggers()}
272-
children = [by_id[tid] for tid in ids if tid in by_id]
273-
if len(children) < 2:
292+
children = children_to_combine(
293+
ids, default_trigger_engine.list_triggers())
294+
if not children:
274295
return
275296
for tid in ids:
276297
default_trigger_engine.remove(tid)
277-
composite_cls = {"all": AllOfTrigger, "any": AnyOfTrigger,
278-
"sequence": SequenceTrigger}[mode]
279-
default_trigger_engine.add(composite_cls(
298+
default_trigger_engine.add(COMPOSITE_BY_MODE[mode](
280299
trigger_id="", script_path=script,
281300
repeat=self._repeat_check.isChecked(), children=children,
282301
))
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""No function in the package is over the documented complexity limit.
2+
3+
``CLAUDE.md`` sets cyclomatic complexity at 10 and names ``radon cc -nc`` as
4+
the tool, but nothing ran it: it sat in the pre-commit list, "read by a human".
5+
Measured on 2026-09-23 the whole package had exactly **one** function above the
6+
limit, so the limit is cheap to hold — and a limit nobody measures is the one
7+
that drifts.
8+
9+
``radon`` is the same tool the pre-commit list names, so the number here and
10+
the number a developer sees are the same number.
11+
"""
12+
from pathlib import Path
13+
14+
import pytest
15+
16+
radon_complexity = pytest.importorskip(
17+
"radon.complexity", reason="pip install radon (see CLAUDE.md)")
18+
19+
_PACKAGE = Path(__file__).resolve().parents[3] / "je_auto_control"
20+
21+
#: ``CLAUDE.md`` § Size and complexity limits.
22+
LIMIT = 10
23+
24+
25+
def over_limit(source: str, limit: int = LIMIT):
26+
"""``(name, complexity)`` for every block above ``limit``."""
27+
return [(block.fullname, block.complexity)
28+
for block in radon_complexity.cc_visit(source)
29+
if block.complexity > limit]
30+
31+
32+
def test_no_function_in_the_package_is_over_the_limit():
33+
offenders = []
34+
for path in sorted(_PACKAGE.rglob("*.py")):
35+
if "__pycache__" in path.parts:
36+
continue
37+
for name, score in over_limit(path.read_text(encoding="utf-8")):
38+
offenders.append(
39+
f"{path.relative_to(_PACKAGE.parent).as_posix()}: {name} = {score}")
40+
assert not offenders, (
41+
f"cyclomatic complexity over {LIMIT} (split the function):\n"
42+
+ "\n".join(offenders))
43+
44+
45+
def test_the_measurement_sees_a_complex_function():
46+
"""Positive control: an empty result above must mean clean, not blind."""
47+
branches = "\n".join(f" if value == {index}:\n return {index}"
48+
for index in range(12))
49+
source = f"def busy(value):\n{branches}\n return None\n"
50+
found = over_limit(source)
51+
assert [name for name, _ in found] == ["busy"]
52+
assert found[0][1] > LIMIT
53+
assert over_limit("def calm(value):\n return value\n") == []
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""The combining logic behind the Triggers tab's three combine buttons.
2+
3+
``_on_combine`` was the one function in the package over the complexity limit,
4+
and the part worth testing — which selected ids become a composite's children —
5+
needed no Qt at all. It is a module function now, so this test does not build a
6+
widget; only the import needs PySide6.
7+
"""
8+
from dataclasses import dataclass
9+
10+
import pytest
11+
12+
pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError)
13+
14+
from je_auto_control.gui.triggers_tab import ( # noqa: E402
15+
COMPOSITE_BY_MODE, children_to_combine,
16+
)
17+
from je_auto_control.utils.triggers.trigger_engine import ( # noqa: E402
18+
AllOfTrigger, AnyOfTrigger, SequenceTrigger,
19+
)
20+
21+
22+
@dataclass
23+
class _Trigger:
24+
trigger_id: str
25+
26+
27+
def test_selected_ids_become_children_in_the_order_given():
28+
triggers = [_Trigger("a"), _Trigger("b"), _Trigger("c")]
29+
children = children_to_combine(["c", "a"], triggers)
30+
assert [child.trigger_id for child in children] == ["c", "a"]
31+
32+
33+
def test_an_id_that_no_longer_exists_is_dropped():
34+
"""The table can outlive a removal; the stale row must not raise."""
35+
triggers = [_Trigger("a"), _Trigger("b")]
36+
children = children_to_combine(["a", "gone", "b"], triggers)
37+
assert [child.trigger_id for child in children] == ["a", "b"]
38+
39+
40+
@pytest.mark.parametrize("ids", [[], ["a"], ["a", "gone"], ["gone", "other"]])
41+
def test_fewer_than_two_resolved_triggers_combine_into_nothing(ids):
42+
assert children_to_combine(ids, [_Trigger("a")]) == []
43+
44+
45+
def test_every_button_mode_maps_to_its_composite():
46+
assert COMPOSITE_BY_MODE == {
47+
"all": AllOfTrigger, "any": AnyOfTrigger, "sequence": SequenceTrigger,
48+
}

0 commit comments

Comments
 (0)