From 17a23c47df66968eeb4a8924f5577d438711c422 Mon Sep 17 00:00:00 2001 From: jxxghp Date: Mon, 24 Aug 2026 21:10:59 +0800 Subject: [PATCH] feat(ci): add mypy error ratchet gate --- .github/workflows/test.yml | 3 + docs/development-setup.md | 6 + scripts/architecture/mypy_ratchet.py | 104 ++ .../fixtures/architecture/mypy-baseline.json | 943 ++++++++++++++++++ tests/test_mypy_gate.py | 59 ++ 5 files changed, 1115 insertions(+) create mode 100644 scripts/architecture/mypy_ratchet.py create mode 100644 tests/fixtures/architecture/mypy-baseline.json create mode 100644 tests/test_mypy_gate.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6b3ef018a..0c01a6c04 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -65,6 +65,9 @@ jobs: - name: Check process runtime service locators run: uv run --locked --no-sync python scripts/architecture/service_locator.py + - name: Check mypy error ratchet + run: uv run --locked --no-sync python scripts/architecture/mypy_ratchet.py + - name: Check startup performance contract run: >- uv run --locked --no-sync python diff --git a/docs/development-setup.md b/docs/development-setup.md index f65689438..961ffafe9 100644 --- a/docs/development-setup.md +++ b/docs/development-setup.md @@ -210,12 +210,18 @@ uvx --from pip-audit==2.10.1 pip-audit \ --check-plugins --plugin-repo ../MoviePilot-Plugins \ --report official-plugin-architecture-report.json uv run --locked --no-sync pylint app/ + uv run --locked --no-sync python scripts/architecture/mypy_ratchet.py ``` GitHub Actions 会在 `v3` 的 PR/push 中独立执行宿主架构门禁,并对本次改动的 Python 文件执行 Pylint 硬门禁;`app/` 全量结果作为建议性报告上传。最新官方插件仓通过每周 或手工观察工作流检查,只上传语义差异报告,不会自动更新已提交基线。 + mypy 类型错误基线(`tests/fixtures/architecture/mypy-baseline.json`)只降不增: + 新增文件、错误码或既有计数增长都会被 `scripts/architecture/mypy_ratchet.py` 拒绝; + 修复存量错误后用同脚本 `--write` 收紧基线,禁止为绕过门禁放宽基线。受治文件清单 + 仍由 `mypy.ini` 的 `files=` 维护并保持零错误。 + ### 7. 参考资源 - [uv 官方文档](https://docs.astral.sh/uv/) diff --git a/scripts/architecture/mypy_ratchet.py b/scripts/architecture/mypy_ratchet.py new file mode 100644 index 000000000..da00f30cf --- /dev/null +++ b/scripts/architecture/mypy_ratchet.py @@ -0,0 +1,104 @@ +"""为宿主源码维护 mypy 类型错误只降不增的基线。 + +与 ``complexity.py`` 同一模式:AST/subprocess 收集当前计数,与 JSON 基线对比, +删除和减少均合法,新增文件或错误码、既有计数增长都会被拒绝。 +基线按 文件 -> 错误码 -> 数量 三级组织,修复单个文件后可用 ``--write`` 收紧基线, +不影响其他文件的存量豁免。 +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from collections import defaultdict +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_BASELINE = PROJECT_ROOT / "tests/fixtures/architecture/mypy-baseline.json" + +# 只扫描宿主源码;app/plugins 是运行时插件副本,质量由插件市场链路自行管理。 +MYPY_TARGETS = ("app",) +MYPY_EXCLUDES = ("app/plugins",) + +# 形如 app/foo.py:12: error: 消息说明 [error-code];个别错误可能缺代码。 +_ERROR_LINE = re.compile(r"^(?P.+?):\d+(?::\d+)?: error: .+?(?:\s+\[(?P[a-z0-9-]+)\])?$") + + +def run_mypy() -> str: + """以当前解释器运行全量 mypy 并返回 stdout(非零退出码属于预期结果)。""" + command = [sys.executable, "-m", "mypy", *MYPY_TARGETS] + for pattern in MYPY_EXCLUDES: + command += ["--exclude", pattern] + result = subprocess.run( + command, + cwd=PROJECT_ROOT, + capture_output=True, + text=True, + check=False, + ) + return result.stdout + + +def parse_errors(output: str) -> dict[str, dict[str, int]]: + """把 mypy 输出聚合为 文件 -> 错误码 -> 数量 的精确计数。""" + report: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + for raw_line in output.splitlines(): + line = raw_line.strip() + # 排除摘要行与源码上下文片段,只匹配真实错误行。 + if ": error:" not in line: + continue + match = _ERROR_LINE.match(line) + if not match: + continue + path = match.group("path").replace("\\", "/") + code = match.group("code") or "unknown" + report[path][code] += 1 + return {path: dict(codes) for path, codes in sorted(report.items())} + + +def compare_counts( + baseline: dict[str, dict[str, int]], current: dict[str, dict[str, int]] +) -> list[str]: + """返回新增文件/错误码或既有计数增长问题;删除与减少均合法。""" + problems = [] + for path, codes in current.items(): + previous = baseline.get(path, {}) + for code, count in codes.items(): + if code not in previous: + problems.append(f"{path}: 新增类型错误 [{code}] x{count}") + elif count > previous[code]: + problems.append(f"{path}: 既有错误增长 [{code}] {previous[code]}->{count}") + return problems + + +def main() -> int: + """执行 mypy 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 = parse_errors(run_mypy()) + 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_counts(baseline, current) + if problems: + print("\n".join(problems)) + print("提示:修复后可用 --write 收紧基线;禁止为绕过门禁放宽基线。") + return 1 + total = sum(count for codes in current.values() for count in codes.values()) + print(f"mypy ratchet 通过(存量 {total} 个类型错误,只降不增)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json new file mode 100644 index 000000000..b460fac5a --- /dev/null +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -0,0 +1,943 @@ +{ + "app/adapters/cache/backends.py": { + "import-untyped": 1 + }, + "app/adapters/external/cookiecloud.py": { + "unknown": 9 + }, + "app/adapters/external/location.py": { + "unknown": 3 + }, + "app/adapters/external/market.py": { + "import-untyped": 1 + }, + "app/adapters/external/ocr.py": { + "unknown": 2 + }, + "app/adapters/external/wechat_crypt.py": { + "unknown": 29 + }, + "app/adapters/network/browser.py": { + "unknown": 24 + }, + "app/adapters/network/cloudflare.py": { + "unknown": 1 + }, + "app/adapters/network/http.py": { + "unknown": 142 + }, + "app/adapters/network/ip.py": { + "unknown": 10 + }, + "app/adapters/system/backup/database.py": { + "unknown": 2 + }, + "app/adapters/system/fsproxy.py": { + "unknown": 33 + }, + "app/adapters/system/fsworker.py": { + "unknown": 13 + }, + "app/adapters/system/host.py": { + "misc": 1, + "operator": 1, + "unknown": 23 + }, + "app/adapters/system/resource.py": { + "unknown": 4 + }, + "app/adapters/system/rust.py": { + "unknown": 28 + }, + "app/adapters/system/stdio.py": { + "unknown": 1 + }, + "app/adapters/system/update.py": { + "unknown": 6 + }, + "app/adapters/web/correlation.py": { + "unknown": 2 + }, + "app/adapters/web/plugin/routes.py": { + "unknown": 2 + }, + "app/adapters/web/security/access.py": { + "unknown": 3 + }, + "app/agent/contracts.py": { + "unknown": 1 + }, + "app/agent/llm/capability.py": { + "unknown": 12 + }, + "app/agent/llm/helper.py": { + "unknown": 32 + }, + "app/agent/llm/provider.py": { + "import-untyped": 1 + }, + "app/agent/middleware/activity_log.py": { + "unknown": 9 + }, + "app/agent/middleware/jobs.py": { + "unknown": 4 + }, + "app/agent/middleware/memory.py": { + "unknown": 4 + }, + "app/agent/middleware/patch_tool_calls.py": { + "unknown": 1 + }, + "app/agent/middleware/skills.py": { + "unknown": 5 + }, + "app/agent/middleware/summarization.py": { + "unknown": 10 + }, + "app/agent/middleware/tool_selection.py": { + "unknown": 4 + }, + "app/agent/middleware/usage.py": { + "unknown": 3 + }, + "app/agent/policy/orchestrator.py": { + "unknown": 1 + }, + "app/agent/policy/sanitizer.py": { + "unknown": 1 + }, + "app/agent/prompt/__init__.py": { + "import-untyped": 1 + }, + "app/agent/runtime.py": { + "import-untyped": 1 + }, + "app/agent/skills/metadata.py": { + "unknown": 1 + }, + "app/agent/skills/registry.py": { + "unknown": 44 + }, + "app/agent/tools/catalog.py": { + "unknown": 1 + }, + "app/agent/tools/impl/_terminal_session.py": { + "unknown": 5 + }, + "app/agent/tools/impl/_torrent_search_utils.py": { + "unknown": 10 + }, + "app/agent/tools/impl/create_agent_task.py": { + "import-untyped": 1 + }, + "app/agent/tools/impl/update_agent_task.py": { + "import-untyped": 1 + }, + "app/api/endpoints/agent.py": { + "import-untyped": 1 + }, + "app/api/endpoints/plugin.py": { + "import-untyped": 1 + }, + "app/api/endpoints/system.py": { + "import-untyped": 1 + }, + "app/api/response.py": { + "unknown": 4 + }, + "app/api/servcookie.py": { + "import-untyped": 1 + }, + "app/application/agent.py": { + "unknown": 4 + }, + "app/application/agentdata.py": { + "unknown": 10 + }, + "app/application/agenttask.py": { + "unknown": 1 + }, + "app/application/commands.py": { + "unknown": 1 + }, + "app/application/configuration.py": { + "unknown": 1 + }, + "app/application/dashboard.py": { + "unknown": 5 + }, + "app/application/directory.py": { + "unknown": 13 + }, + "app/application/download/tasks.py": { + "unknown": 2 + }, + "app/application/downloader.py": { + "unknown": 2 + }, + "app/application/formatting.py": { + "unknown": 26 + }, + "app/application/history.py": { + "unknown": 9 + }, + "app/application/maintenance.py": { + "unknown": 1 + }, + "app/application/mediaserver.py": { + "unknown": 3 + }, + "app/application/messaging/agent.py": { + "unknown": 20 + }, + "app/application/messaging/ingress.py": { + "unknown": 2 + }, + "app/application/messaging/interaction.py": { + "unknown": 6 + }, + "app/application/messaging/media.py": { + "unknown": 2 + }, + "app/application/messaging/message.py": { + "arg-type": 5, + "assignment": 3, + "dict-item": 1, + "misc": 1, + "no-any-return": 4, + "no-untyped-call": 2, + "no-untyped-def": 11, + "type-arg": 18, + "union-attr": 2, + "var-annotated": 1 + }, + "app/application/messaging/plugin.py": { + "unknown": 9 + }, + "app/application/messaging/site.py": { + "unknown": 7 + }, + "app/application/messaging/skill.py": { + "unknown": 14 + }, + "app/application/messaging/subscribe.py": { + "unknown": 6 + }, + "app/application/module.py": { + "unknown": 1 + }, + "app/application/music/catalog.py": { + "unknown": 2 + }, + "app/application/notification.py": { + "unknown": 3 + }, + "app/application/plugin/catalog.py": { + "unknown": 11 + }, + "app/application/plugin/config.py": { + "unknown": 3 + }, + "app/application/plugin/install.py": { + "unknown": 2 + }, + "app/application/plugin/lifecycle.py": { + "unknown": 2 + }, + "app/application/rss.py": { + "unknown": 21 + }, + "app/application/rules.py": { + "unknown": 10 + }, + "app/application/security/cookie.py": { + "unknown": 25 + }, + "app/application/security/otp.py": { + "unknown": 3 + }, + "app/application/security/passkeys.py": { + "unknown": 2 + }, + "app/application/security/token.py": { + "unknown": 4 + }, + "app/application/security/twofactor.py": { + "unknown": 4 + }, + "app/application/security/url.py": { + "unknown": 15 + }, + "app/application/servarr.py": { + "unknown": 2 + }, + "app/application/server/report.py": { + "unknown": 5 + }, + "app/application/server/share.py": { + "unknown": 7 + }, + "app/application/service.py": { + "unknown": 6 + }, + "app/application/site/mutation.py": { + "unknown": 4 + }, + "app/application/site/query.py": { + "unknown": 7 + }, + "app/application/site/sites.pyi": { + "unknown": 7 + }, + "app/application/storage.py": { + "unknown": 6 + }, + "app/application/subscription/complete.py": { + "unknown": 3 + }, + "app/application/subscription/contract.py": { + "unknown": 5 + }, + "app/application/subscription/query.py": { + "unknown": 2 + }, + "app/application/subscription/write.py": { + "unknown": 22 + }, + "app/application/torrent.py": { + "unknown": 39 + }, + "app/application/torrent_cache.py": { + "unknown": 1 + }, + "app/application/transfer.py": { + "unknown": 104 + }, + "app/chain/_interaction.py": { + "unknown": 12 + }, + "app/db/models/agentchat.py": { + "unknown": 2 + }, + "app/db/models/agenttask.py": { + "unknown": 5 + }, + "app/db/models/downloadfailure.py": { + "unknown": 1 + }, + "app/db/models/downloadhistory.py": { + "unknown": 19 + }, + "app/db/models/mediaserver.py": { + "unknown": 10 + }, + "app/db/models/message.py": { + "unknown": 1 + }, + "app/db/models/passkey.py": { + "unknown": 12 + }, + "app/db/models/plugindata.py": { + "unknown": 8 + }, + "app/db/models/site.py": { + "unknown": 10 + }, + "app/db/models/siteicon.py": { + "unknown": 2 + }, + "app/db/models/sitestatistic.py": { + "unknown": 3 + }, + "app/db/models/siteuserdata.py": { + "unknown": 5 + }, + "app/db/models/subscribe.py": { + "unknown": 26 + }, + "app/db/models/subscribehistory.py": { + "unknown": 6 + }, + "app/db/models/systemconfig.py": { + "unknown": 3 + }, + "app/db/models/transferhistory.py": { + "unknown": 24 + }, + "app/db/models/transferpending.py": { + "unknown": 1 + }, + "app/db/models/user.py": { + "unknown": 10 + }, + "app/db/models/userconfig.py": { + "unknown": 2 + }, + "app/db/models/workflow.py": { + "unknown": 31 + }, + "app/doctor/checks.py": { + "unknown": 7 + }, + "app/doctor/runner.py": { + "unknown": 1 + }, + "app/domain/context.py": { + "unknown": 225 + }, + "app/domain/meta/customization.py": { + "unknown": 8 + }, + "app/domain/meta/metaanime.py": { + "unknown": 11 + }, + "app/domain/meta/metabase.py": { + "unknown": 19 + }, + "app/domain/meta/metamusic.py": { + "unknown": 16 + }, + "app/domain/meta/metavideo.py": { + "unknown": 33 + }, + "app/domain/meta/releasegroup.py": { + "unknown": 7 + }, + "app/domain/meta/runtime.py": { + "unknown": 6 + }, + "app/domain/meta/streamingplatform.py": { + "unknown": 2 + }, + "app/domain/meta/words.py": { + "unknown": 6 + }, + "app/domain/metainfo.py": { + "unknown": 24 + }, + "app/domain/scraper.py": { + "unknown": 5 + }, + "app/domain/title.py": { + "unknown": 1 + }, + "app/domain/tokens.py": { + "unknown": 9 + }, + "app/foundation/crypto.py": { + "unknown": 4 + }, + "app/foundation/dom.py": { + "unknown": 7 + }, + "app/foundation/environment.py": { + "unknown": 1 + }, + "app/foundation/reflection.py": { + "unknown": 12 + }, + "app/foundation/singleton.py": { + "unknown": 14 + }, + "app/foundation/temporal.py": { + "unknown": 6 + }, + "app/foundation/text.py": { + "unknown": 13 + }, + "app/foundation/url.py": { + "unknown": 1 + }, + "app/modules/anilist/anilist.py": { + "unknown": 60 + }, + "app/modules/bangumi/bangumi.py": { + "unknown": 26 + }, + "app/modules/dingtalk/dingtalk.py": { + "unknown": 1 + }, + "app/modules/discord/discord.py": { + "unknown": 48 + }, + "app/modules/douban/apiv2.py": { + "unknown": 170 + }, + "app/modules/douban/scraper.py": { + "unknown": 7 + }, + "app/modules/emby/emby.py": { + "unknown": 37 + }, + "app/modules/feishu/feishu.py": { + "unknown": 47 + }, + "app/modules/imdb/api.py": { + "unknown": 27 + }, + "app/modules/indexer/parser/__init__.py": { + "unknown": 53 + }, + "app/modules/indexer/parser/bitpt.py": { + "unknown": 45 + }, + "app/modules/indexer/parser/discuz.py": { + "unknown": 10 + }, + "app/modules/indexer/parser/file_list.py": { + "unknown": 14 + }, + "app/modules/indexer/parser/gazelle.py": { + "unknown": 13 + }, + "app/modules/indexer/parser/ipt_project.py": { + "unknown": 13 + }, + "app/modules/indexer/parser/mtorrent.py": { + "unknown": 21 + }, + "app/modules/indexer/parser/nexus_php.py": { + "unknown": 31 + }, + "app/modules/indexer/parser/nexus_rabbit.py": { + "unknown": 19 + }, + "app/modules/indexer/parser/rousi.py": { + "unknown": 23 + }, + "app/modules/indexer/parser/small_horse.py": { + "unknown": 16 + }, + "app/modules/indexer/parser/sunnypt.py": { + "unknown": 18 + }, + "app/modules/indexer/parser/tnode.py": { + "unknown": 12 + }, + "app/modules/indexer/parser/torrent_leech.py": { + "unknown": 11 + }, + "app/modules/indexer/parser/unit3d.py": { + "unknown": 12 + }, + "app/modules/indexer/parser/yema.py": { + "unknown": 18 + }, + "app/modules/indexer/parser/zhixing.py": { + "unknown": 47 + }, + "app/modules/indexer/spider/__init__.py": { + "unknown": 93 + }, + "app/modules/indexer/spider/haidan.py": { + "unknown": 19 + }, + "app/modules/indexer/spider/hddolby.py": { + "unknown": 21 + }, + "app/modules/indexer/spider/mtorrent.py": { + "unknown": 20 + }, + "app/modules/indexer/spider/rousi.py": { + "unknown": 24 + }, + "app/modules/indexer/spider/sunnypt.py": { + "unknown": 31 + }, + "app/modules/indexer/spider/tnode.py": { + "unknown": 23 + }, + "app/modules/indexer/spider/torrentleech.py": { + "unknown": 31 + }, + "app/modules/indexer/spider/yema.py": { + "unknown": 21 + }, + "app/modules/jellyfin/jellyfin.py": { + "return": 1, + "unknown": 47 + }, + "app/modules/musicbrainz/music_cache.py": { + "unknown": 10 + }, + "app/modules/navidrome/navidrome.py": { + "unknown": 12 + }, + "app/modules/plex/plex.py": { + "unknown": 34 + }, + "app/modules/qbittorrent/qbittorrent.py": { + "unknown": 42 + }, + "app/modules/qqbot/api.py": { + "unknown": 17 + }, + "app/modules/qqbot/gateway.py": { + "unknown": 6 + }, + "app/modules/qqbot/qqbot.py": { + "unknown": 16 + }, + "app/modules/rtorrent/rtorrent.py": { + "unknown": 182 + }, + "app/modules/slack/slack.py": { + "unknown": 31 + }, + "app/modules/synologychat/synologychat.py": { + "unknown": 14 + }, + "app/modules/themoviedb/category.py": { + "unknown": 13 + }, + "app/modules/themoviedb/tmdb_cache.py": { + "unknown": 19 + }, + "app/modules/themoviedb/tmdbv3api/as_obj.py": { + "unknown": 41 + }, + "app/modules/themoviedb/tmdbv3api/objs/account.py": { + "unknown": 59 + }, + "app/modules/themoviedb/tmdbv3api/objs/auth.py": { + "unknown": 13 + }, + "app/modules/themoviedb/tmdbv3api/objs/certification.py": { + "unknown": 8 + }, + "app/modules/themoviedb/tmdbv3api/objs/change.py": { + "unknown": 16 + }, + "app/modules/themoviedb/tmdbv3api/objs/collection.py": { + "unknown": 12 + }, + "app/modules/themoviedb/tmdbv3api/objs/company.py": { + "unknown": 16 + }, + "app/modules/themoviedb/tmdbv3api/objs/configuration.py": { + "unknown": 26 + }, + "app/modules/themoviedb/tmdbv3api/objs/credit.py": { + "unknown": 4 + }, + "app/modules/themoviedb/tmdbv3api/objs/discover.py": { + "unknown": 9 + }, + "app/modules/themoviedb/tmdbv3api/objs/episode.py": { + "unknown": 40 + }, + "app/modules/themoviedb/tmdbv3api/objs/find.py": { + "unknown": 36 + }, + "app/modules/themoviedb/tmdbv3api/objs/genre.py": { + "unknown": 8 + }, + "app/modules/themoviedb/tmdbv3api/objs/group.py": { + "unknown": 4 + }, + "app/modules/themoviedb/tmdbv3api/objs/keyword.py": { + "unknown": 8 + }, + "app/modules/themoviedb/tmdbv3api/objs/list.py": { + "unknown": 28 + }, + "app/modules/themoviedb/tmdbv3api/objs/movie.py": { + "unknown": 92 + }, + "app/modules/themoviedb/tmdbv3api/objs/network.py": { + "unknown": 12 + }, + "app/modules/themoviedb/tmdbv3api/objs/person.py": { + "unknown": 44 + }, + "app/modules/themoviedb/tmdbv3api/objs/provider.py": { + "unknown": 12 + }, + "app/modules/themoviedb/tmdbv3api/objs/review.py": { + "unknown": 4 + }, + "app/modules/themoviedb/tmdbv3api/objs/search.py": { + "unknown": 29 + }, + "app/modules/themoviedb/tmdbv3api/objs/season.py": { + "unknown": 36 + }, + "app/modules/themoviedb/tmdbv3api/objs/trending.py": { + "unknown": 36 + }, + "app/modules/themoviedb/tmdbv3api/objs/tv.py": { + "unknown": 104 + }, + "app/modules/themoviedb/tmdbv3api/tmdb.py": { + "unknown": 87 + }, + "app/modules/thetvdb/tvdb_v4_official.py": { + "unknown": 274 + }, + "app/modules/transmission/transmission.py": { + "unknown": 41 + }, + "app/modules/trimemedia/api.py": { + "unknown": 21 + }, + "app/modules/ugreen/api.py": { + "unknown": 31 + }, + "app/modules/ugreen/crypto.py": { + "unknown": 1 + }, + "app/modules/ugreen/ugreen.py": { + "unknown": 45 + }, + "app/modules/vocechat/vocechat.py": { + "unknown": 8 + }, + "app/modules/wechat/wechat.py": { + "unknown": 26 + }, + "app/modules/wechat/wechatbot.py": { + "unknown": 22 + }, + "app/modules/wechatclawbot/wechatclawbot.py": { + "unknown": 15 + }, + "app/modules/zspace/zspace.py": { + "unknown": 44 + }, + "app/monitor/snapshot.py": { + "unknown": 7 + }, + "app/monitor/syslimits.py": { + "unknown": 1 + }, + "app/monitor/watcher.py": { + "unknown": 18 + }, + "app/runtime/cache.py": { + "unknown": 89 + }, + "app/runtime/capabilities/runtime.py": { + "unknown": 10 + }, + "app/runtime/compat/imports.py": { + "unknown": 20 + }, + "app/runtime/config.py": { + "unknown": 52 + }, + "app/runtime/debounce.py": { + "unknown": 37 + }, + "app/runtime/deprecation/policy.py": { + "unknown": 2 + }, + "app/runtime/event/binding.py": { + "unknown": 7 + }, + "app/runtime/event/contracts.py": { + "unknown": 2 + }, + "app/runtime/event/dispatch.py": { + "unknown": 5 + }, + "app/runtime/event/registry.py": { + "unknown": 14 + }, + "app/runtime/execution.py": { + "unknown": 9 + }, + "app/runtime/extensions/host_module_adapter.py": { + "unknown": 3 + }, + "app/runtime/extensions/module/dispatcher.py": { + "unknown": 1 + }, + "app/runtime/extensions/plugin/access.py": { + "unknown": 1 + }, + "app/runtime/extensions/plugin/admission.py": { + "unknown": 3 + }, + "app/runtime/extensions/plugin/catalog.py": { + "unknown": 5 + }, + "app/runtime/extensions/plugin/clone.py": { + "unknown": 2 + }, + "app/runtime/extensions/plugin/lifecycle.py": { + "unknown": 2 + }, + "app/runtime/extensions/plugin/metadata.py": { + "unknown": 4 + }, + "app/runtime/extensions/plugin/monitor.py": { + "unknown": 4 + }, + "app/runtime/extensions/plugin/paths.py": { + "unknown": 3 + }, + "app/runtime/extensions/plugin/projection.py": { + "unknown": 10 + }, + "app/runtime/extensions/plugin/storage.py": { + "unknown": 3 + }, + "app/runtime/extensions/plugin/sync.py": { + "unknown": 3 + }, + "app/runtime/extensions/plugin/system.py": { + "unknown": 10 + }, + "app/runtime/extensions/service_config.py": { + "unknown": 2 + }, + "app/runtime/gc.py": { + "unknown": 7 + }, + "app/runtime/localization.py": { + "unknown": 1 + }, + "app/runtime/managed_resources.py": { + "unknown": 5 + }, + "app/runtime/progress.py": { + "unknown": 10 + }, + "app/runtime/rate.py": { + "unknown": 29 + }, + "app/runtime/scheduling.py": { + "unknown": 6 + }, + "app/runtime/settings.py": { + "unknown": 6 + }, + "app/runtime/thread.py": { + "unused-ignore": 1 + }, + "app/scheduler.py": { + "import-untyped": 1 + }, + "app/schemas/agent.py": { + "unknown": 26 + }, + "app/schemas/cache.py": { + "unknown": 3 + }, + "app/schemas/category.py": { + "unknown": 3 + }, + "app/schemas/common.py": { + "unknown": 13 + }, + "app/schemas/context.py": { + "unknown": 18 + }, + "app/schemas/dashboard.py": { + "unknown": 10 + }, + "app/schemas/download.py": { + "unknown": 4 + }, + "app/schemas/event.py": { + "unknown": 17 + }, + "app/schemas/file.py": { + "unknown": 4 + }, + "app/schemas/history.py": { + "unknown": 4 + }, + "app/schemas/llm.py": { + "unknown": 9 + }, + "app/schemas/mcp.py": { + "misc": 1, + "unknown": 22 + }, + "app/schemas/media.py": { + "unknown": 9 + }, + "app/schemas/mediaserver.py": { + "unknown": 14 + }, + "app/schemas/message.py": { + "unknown": 21 + }, + "app/schemas/mfa.py": { + "misc": 1, + "unknown": 4 + }, + "app/schemas/monitoring.py": { + "unknown": 6 + }, + "app/schemas/music.py": { + "unknown": 10 + }, + "app/schemas/notification.py": { + "unknown": 2 + }, + "app/schemas/openai.py": { + "unknown": 23 + }, + "app/schemas/plugin.py": { + "unknown": 18 + }, + "app/schemas/response.py": { + "unknown": 3 + }, + "app/schemas/rule.py": { + "unknown": 2 + }, + "app/schemas/search.py": { + "unknown": 2 + }, + "app/schemas/servarr.py": { + "unknown": 18 + }, + "app/schemas/servcookie.py": { + "unknown": 5 + }, + "app/schemas/site.py": { + "unknown": 8 + }, + "app/schemas/storage.py": { + "unknown": 3 + }, + "app/schemas/subscribe.py": { + "unknown": 11 + }, + "app/schemas/system.py": { + "unknown": 22 + }, + "app/schemas/tmdb.py": { + "unknown": 5 + }, + "app/schemas/token.py": { + "unknown": 3 + }, + "app/schemas/transfer.py": { + "unknown": 21 + }, + "app/schemas/types.py": { + "unknown": 6 + }, + "app/schemas/user.py": { + "unknown": 3 + }, + "app/schemas/workflow.py": { + "unknown": 19 + }, + "app/sdk/_legacy/transfer.py": { + "unknown": 1 + }, + "app/sdk/string.py": { + "unknown": 3 + }, + "app/testing/network_guard.py": { + "unknown": 6 + }, + "app/testing/stub.py": { + "unknown": 2 + } +} diff --git a/tests/test_mypy_gate.py b/tests/test_mypy_gate.py new file mode 100644 index 000000000..3122e37c3 --- /dev/null +++ b/tests/test_mypy_gate.py @@ -0,0 +1,59 @@ +"""mypy 错误数只降不增 ratchet 的解析与对比逻辑测试。""" + +from scripts.architecture.mypy_ratchet import compare_counts, parse_errors + + +MYPY_SAMPLE = """ +app/application/a.py:12: error: Function is missing a type annotation [no-untyped-def] +app/application/a.py:15: error: Function is missing a type annotation [no-untyped-def] + def run(self): + ^ +app/application/a.py:20: error: Missing type parameters for generic type "dict" [type-arg] +Found 3 errors in 1 file (checked 500 source files) +""" + + +def test_parse_errors_aggregates_per_file_and_code() -> None: + """错误行按文件与错误码聚合,源码上下文与摘要行不计入。""" + report = parse_errors(MYPY_SAMPLE) + + assert report == { + "app/application/a.py": { + "no-untyped-def": 2, + "type-arg": 1, + } + } + + +def test_parse_errors_buckets_missing_code_as_unknown() -> None: + """缺少错误码的错误行归入 unknown 桶,不丢失计数。""" + report = parse_errors("app/b.py:3: error: Something went wrong\n") + + assert report == {"app/b.py": {"unknown": 1}} + + +def test_ratchet_allows_removal_and_reduction() -> None: + """文件删除或错误数减少必须允许通过。""" + baseline = {"app/a.py": {"arg-type": 5, "misc": 2}} + current = {"app/a.py": {"arg-type": 3}} + + assert compare_counts(baseline, current) == [] + + +def test_ratchet_rejects_growth_and_new_errors() -> None: + """既有错误码增长和新增文件/错误码必须同时给出精确诊断。""" + baseline = { + "app/a.py": {"arg-type": 5}, + "app/c.py": {"misc": 1}, + } + current = { + "app/a.py": {"arg-type": 6, "type-arg": 2}, + "app/b.py": {"no-untyped-def": 10}, + "app/c.py": {}, + } + + problems = compare_counts(baseline, current) + + assert any("app/a.py" in p and "arg-type" in p and "既有错误增长" in p for p in problems) + assert any("app/a.py" in p and "type-arg" in p and "新增类型错误" in p for p in problems) + assert any("app/b.py" in p and "新增类型错误" in p for p in problems)