mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-07 00:16:57 +08:00
ci: enforce complexity reduction ratchet
This commit is contained in:
@@ -53,6 +53,9 @@ jobs:
|
|||||||
- name: Check governed Python types
|
- name: Check governed Python types
|
||||||
run: uv run --locked --no-sync mypy --config-file mypy.ini
|
run: uv run --locked --no-sync mypy --config-file mypy.ini
|
||||||
|
|
||||||
|
- name: Check complexity ratchet
|
||||||
|
run: uv run --locked --no-sync python scripts/architecture/complexity.py
|
||||||
|
|
||||||
pytest:
|
pytest:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
name: Unit Tests (${{ matrix.shard }})
|
name: Unit Tests (${{ matrix.shard }})
|
||||||
|
|||||||
@@ -811,6 +811,16 @@ OTel 初始化只能位于 Startup/Adapter;Domain/Application 只依赖 no-op-
|
|||||||
|
|
||||||
优先拆分对象:`do_transfer`、`batch_download`、`SubscribeChain.match`、`web_agent_stream`、`Scheduler.init`。先提取 phase object/DTO/port,再缩短入口;不创建一批互相读写同一个大 dict 的私有函数来“达标”。
|
优先拆分对象:`do_transfer`、`batch_download`、`SubscribeChain.match`、`web_agent_stream`、`Scheduler.init`。先提取 phase object/DTO/port,再缩短入口;不创建一批互相读写同一个大 dict 的私有函数来“达标”。
|
||||||
|
|
||||||
|
**实施记录(2026-08-21)**:
|
||||||
|
|
||||||
|
- 新增 `scripts/architecture/complexity.py`,通过 AST 只统计 API HTTP endpoint、Application public method
|
||||||
|
和 Chain public use-case,预算分别为 80/150/150 行;嵌套 helper 不会被机械重复计数。
|
||||||
|
- `complexity-baseline.json` 只保存当前超限入口和行数,不把达标方法写成永久快照。check 允许缩短、达标或删除,
|
||||||
|
精确拒绝既有超限增长和任何新增超限;CI architecture job 每次执行。
|
||||||
|
- 当前债务清单明确包含 `web_agent_stream`、`batch_download`、`SubscribeChain.match`、`do_transfer`;
|
||||||
|
`Scheduler.init` 已在 ARCH-252 通过 JobSpec/catalog 拆分退出超限清单,调度专项测试是该代表性拆分的回归证据。
|
||||||
|
- 单元测试覆盖删除/缩短放行和增长/新增拒绝,当前仓库 baseline check 通过。
|
||||||
|
|
||||||
#### ARCH-272:异步阻塞检测
|
#### ARCH-272:异步阻塞检测
|
||||||
|
|
||||||
**目标**:对新 API/Agent/Application async 路径检测 `open`、文件遍历、同步 HTTP、阻塞 sleep 和重 CPU 解析。
|
**目标**:对新 API/Agent/Application async 路径检测 `open`、文件遍历、同步 HTTP、阻塞 sleep 和重 CPU 解析。
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"""为 API/Application/Chain 公共入口维护只降不增的行数预算。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
DEFAULT_BASELINE = PROJECT_ROOT / "tests/fixtures/architecture/complexity-baseline.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ComplexityRule:
|
||||||
|
"""描述目录、入口选择器与最大源代码行数。"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
root: str
|
||||||
|
budget: int
|
||||||
|
endpoint_only: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
RULES = (
|
||||||
|
ComplexityRule("api_endpoint", "app/api/endpoints", 80, endpoint_only=True),
|
||||||
|
ComplexityRule("application_public", "app/application", 150),
|
||||||
|
ComplexityRule("chain_public", "app/chain", 150),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_endpoint(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
||||||
|
"""识别带常见 HTTP method decorator 的 API endpoint。"""
|
||||||
|
methods = {"get", "post", "put", "patch", "delete", "options", "head"}
|
||||||
|
for decorator in node.decorator_list:
|
||||||
|
target = decorator.func if isinstance(decorator, ast.Call) else decorator
|
||||||
|
if isinstance(target, ast.Attribute) and target.attr.lower() in methods:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _public_entries(
|
||||||
|
tree: ast.Module, endpoint_only: bool
|
||||||
|
) -> Iterable[tuple[str, ast.FunctionDef | ast.AsyncFunctionDef]]:
|
||||||
|
"""产出顶层函数和类直接拥有的公共方法,不把嵌套 helper 重复计数。"""
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
if not node.name.startswith("_") and (
|
||||||
|
not endpoint_only or _is_endpoint(node)
|
||||||
|
):
|
||||||
|
yield node.name, node
|
||||||
|
elif isinstance(node, ast.ClassDef):
|
||||||
|
for method in node.body:
|
||||||
|
if isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
if not method.name.startswith("_") and (
|
||||||
|
not endpoint_only or _is_endpoint(method)
|
||||||
|
):
|
||||||
|
yield f"{node.name}.{method.name}", method
|
||||||
|
|
||||||
|
|
||||||
|
def collect_complexity(root: Path = PROJECT_ROOT) -> dict[str, dict[str, int]]:
|
||||||
|
"""收集每条规则下当前超过预算的入口及其精确源代码行数。"""
|
||||||
|
report: dict[str, dict[str, int]] = {}
|
||||||
|
for rule in RULES:
|
||||||
|
debt: dict[str, int] = {}
|
||||||
|
for path in sorted((root / rule.root).rglob("*.py")):
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
relative = path.relative_to(root).as_posix()
|
||||||
|
for qualname, node in _public_entries(tree, rule.endpoint_only):
|
||||||
|
line_count = (node.end_lineno or node.lineno) - node.lineno + 1
|
||||||
|
if line_count > rule.budget:
|
||||||
|
debt[f"{relative}:{qualname}"] = line_count
|
||||||
|
report[rule.name] = debt
|
||||||
|
return report
|
||||||
|
|
||||||
|
|
||||||
|
def compare_complexity(
|
||||||
|
baseline: dict[str, dict[str, int]], current: dict[str, dict[str, int]]
|
||||||
|
) -> list[str]:
|
||||||
|
"""返回新增超限或既有超限增长问题;删除和缩短均合法。"""
|
||||||
|
problems = []
|
||||||
|
for rule in RULES:
|
||||||
|
previous = baseline.get(rule.name, {})
|
||||||
|
for entry, line_count in current.get(rule.name, {}).items():
|
||||||
|
if entry not in previous:
|
||||||
|
problems.append(f"{rule.name}: 新增超限 {entry}={line_count}>{rule.budget}")
|
||||||
|
elif line_count > previous[entry]:
|
||||||
|
problems.append(
|
||||||
|
f"{rule.name}: 既有超限增长 {entry}={line_count}>{previous[entry]}"
|
||||||
|
)
|
||||||
|
return problems
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
"""执行复杂度 baseline check 或显式 write。"""
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--write", action="store_true", help="写入当前超限基线")
|
||||||
|
parser.add_argument("--baseline", type=Path, default=DEFAULT_BASELINE)
|
||||||
|
args = parser.parse_args()
|
||||||
|
current = collect_complexity()
|
||||||
|
if args.write:
|
||||||
|
args.baseline.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
args.baseline.write_text(
|
||||||
|
json.dumps(current, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
print(f"已写入 {args.baseline.relative_to(PROJECT_ROOT)}")
|
||||||
|
return 0
|
||||||
|
baseline = json.loads(args.baseline.read_text(encoding="utf-8"))
|
||||||
|
problems = compare_complexity(baseline, current)
|
||||||
|
if problems:
|
||||||
|
print("\n".join(problems))
|
||||||
|
return 1
|
||||||
|
print("复杂度 ratchet 通过")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"api_endpoint": {
|
||||||
|
"app/api/endpoints/agent.py:web_agent_stream": 346,
|
||||||
|
"app/api/endpoints/download.py:add": 92,
|
||||||
|
"app/api/endpoints/mcp.py:mcp_jsonrpc": 83,
|
||||||
|
"app/api/endpoints/media.py:scrape": 105,
|
||||||
|
"app/api/endpoints/openai.py:chat_completions": 107,
|
||||||
|
"app/api/endpoints/openai.py:responses": 105,
|
||||||
|
"app/api/endpoints/system.py:get_logging": 111,
|
||||||
|
"app/api/endpoints/system.py:nettest": 87,
|
||||||
|
"app/api/endpoints/torrent.py:reidentify_cache": 147
|
||||||
|
},
|
||||||
|
"application_public": {
|
||||||
|
"app/application/messaging/site.py:SiteInteractionHandler.handle_text_interaction": 227,
|
||||||
|
"app/application/messaging/skill.py:SkillInteractionHandler.handle_callback_interaction": 158,
|
||||||
|
"app/application/messaging/skill.py:SkillInteractionHandler.handle_text_interaction": 296,
|
||||||
|
"app/application/messaging/subscribe.py:SubscribeInteractionHandler.handle_text_interaction": 205,
|
||||||
|
"app/application/rss.py:RssHelper.parse": 206,
|
||||||
|
"app/application/security/cookie.py:CookieHelper.get_site_cookie_ua": 221
|
||||||
|
},
|
||||||
|
"chain_public": {
|
||||||
|
"app/chain/download.py:DownloadChain.batch_download": 572,
|
||||||
|
"app/chain/download.py:DownloadChain.download_single": 276,
|
||||||
|
"app/chain/download.py:DownloadChain.get_no_exists_info": 152,
|
||||||
|
"app/chain/mediaserver.py:MediaServerChain.sync": 292,
|
||||||
|
"app/chain/site.py:SiteChain.sync_cookies": 180,
|
||||||
|
"app/chain/subscribe.py:SubscribeChain.add": 183,
|
||||||
|
"app/chain/subscribe.py:SubscribeChain.async_add": 186,
|
||||||
|
"app/chain/subscribe.py:SubscribeChain.match": 417,
|
||||||
|
"app/chain/subscribe.py:SubscribeChain.search": 249,
|
||||||
|
"app/chain/subscribe.py:SubscribeChain.subscribe_files_info": 199,
|
||||||
|
"app/chain/torrents.py:TorrentsChain.refresh": 235,
|
||||||
|
"app/chain/transfer.py:TransferChain.do_transfer": 885,
|
||||||
|
"app/chain/transfer.py:TransferChain.process": 154
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""复杂度只降不增 ratchet 测试。"""
|
||||||
|
|
||||||
|
from scripts.architecture.complexity import compare_complexity
|
||||||
|
|
||||||
|
|
||||||
|
def test_complexity_ratchet_allows_removal_and_reduction() -> None:
|
||||||
|
"""既有超限入口被删除或缩短必须允许合并。"""
|
||||||
|
baseline = {
|
||||||
|
"api_endpoint": {"app/api/endpoints/a.py:large": 100},
|
||||||
|
"application_public": {"app/application/a.py:Service.run": 180},
|
||||||
|
"chain_public": {},
|
||||||
|
}
|
||||||
|
current = {
|
||||||
|
"api_endpoint": {"app/api/endpoints/a.py:large": 90},
|
||||||
|
"application_public": {},
|
||||||
|
"chain_public": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert compare_complexity(baseline, current) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_complexity_ratchet_rejects_growth_and_new_oversize() -> None:
|
||||||
|
"""既有入口增长和新增超预算入口必须同时给出精确诊断。"""
|
||||||
|
baseline = {
|
||||||
|
"api_endpoint": {"app/api/endpoints/a.py:large": 100},
|
||||||
|
"application_public": {},
|
||||||
|
"chain_public": {},
|
||||||
|
}
|
||||||
|
current = {
|
||||||
|
"api_endpoint": {
|
||||||
|
"app/api/endpoints/a.py:large": 101,
|
||||||
|
"app/api/endpoints/b.py:new_endpoint": 81,
|
||||||
|
},
|
||||||
|
"application_public": {},
|
||||||
|
"chain_public": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
problems = compare_complexity(baseline, current)
|
||||||
|
|
||||||
|
assert any("既有超限增长" in problem for problem in problems)
|
||||||
|
assert any("新增超限" in problem for problem in problems)
|
||||||
Reference in New Issue
Block a user