Files
MoviePilot/tests/test_mypy_gate.py
jxxghp b12c112fe1 chore(types): gate mypy debt as exceptions and require zero new files
mypy 门禁此前只表达“只降不增”,没有把存量债务和新增文件分开:拆包迁移按目录前缀
继承额度,同包后来新增的文件可以吸收旧文件剩余额度;本地缺 types-* 桩包时,
import-untyped 与派生的 no-any-return 会被计成债务增长。

调整后口径分三层:mypy.ini 严格清单必须零错误;基线登记文件是存量例外,按文件 +
错误码设上限;基线未登记的新文件必须零诊断。

- 迁移登记改为 Migration(sources, targets),额度只转给逐一登记的接收文件。
- classify_counts 返回 RatchetDiff,新增文件、存量增长、待固化低水位分组输出。
- 缺少 types-* 时直接报环境错误并要求 uv sync --locked,不写入基线。
- 删除 7 个已消耗的迁移组,其旧路径已不在基线中。

验证:tests/test_mypy_gate.py 与 tests/test_architecture_ci.py 26 passed;
changed-file Pylint 10.00/10;Ruff ratchet、结构复杂度、mypy 严格清单与
mypy ratchet(2881 项存量)通过。
2026-10-18 13:46:37 +08:00

355 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
mypy 错误低水位 ratchet 的执行、解析与对比逻辑测试。
"""
import json
import subprocess
import sys
from unittest.mock import patch
import pytest
from scripts.architecture.mypy_ratchet import (
Migration,
compare_counts,
main,
parse_errors,
run_mypy,
)
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_run_mypy_uses_stable_full_analysis() -> None:
"""
全量门禁不得复用前序检查缓存,也不得输出换行错误码。
"""
with patch("scripts.architecture.mypy_ratchet.subprocess.run") as run:
run.return_value = subprocess.CompletedProcess(
args=[],
returncode=0,
stdout="Success: no issues found in 500 source files\n",
stderr="",
)
run_mypy()
command = run.call_args.args[0]
assert "--no-incremental" in command
assert "--no-pretty" in command
assert command[command.index("--platform") + 1] == "linux"
def test_run_mypy_rejects_tool_failure() -> None:
"""
内部错误等退出码 2 必须让门禁失败,不能拿截断输出生成基线。
"""
result = subprocess.CompletedProcess(
args=[],
returncode=2,
stdout=MYPY_SAMPLE,
stderr="mypy: INTERNAL ERROR",
)
with patch("scripts.architecture.mypy_ratchet.subprocess.run", return_value=result):
with pytest.raises(RuntimeError, match="异常退出.*INTERNAL ERROR"):
run_mypy()
def test_run_mypy_rejects_unexpected_stderr() -> None:
"""
正常退出码携带 stderr 时仍须 fail-closed。
"""
result = subprocess.CompletedProcess(
args=[],
returncode=1,
stdout=MYPY_SAMPLE,
stderr="analysis incomplete",
)
with patch("scripts.architecture.mypy_ratchet.subprocess.run", return_value=result):
with pytest.raises(RuntimeError, match="stderr"):
run_mypy()
def test_run_mypy_rejects_missing_type_stubs() -> None:
"""
缺失类型桩会把带类型的第三方库降级为 Any,必须报环境错误而不是计成债务。
"""
output = (
'app/foundation/temporal.py:9: error: Library stubs not installed for "yaml" '
"[import-untyped]\n"
"Found 1 error in 1 file (checked 500 source files)\n"
)
result = subprocess.CompletedProcess(
args=[],
returncode=1,
stdout=output,
stderr="",
)
with patch("scripts.architecture.mypy_ratchet.subprocess.run", return_value=result):
with pytest.raises(RuntimeError, match="缺失的类型桩包.*yaml"):
run_mypy()
def test_run_mypy_rejects_truncated_error_report() -> None:
"""
摘要错误数与解析数量不同时必须拒绝不完整报告。
"""
result = subprocess.CompletedProcess(
args=[],
returncode=1,
stdout=MYPY_SAMPLE.replace("Found 3 errors", "Found 4 errors"),
stderr="",
)
with patch("scripts.architecture.mypy_ratchet.subprocess.run", return_value=result):
with pytest.raises(RuntimeError, match="摘要与已解析错误数不一致"):
run_mypy()
def test_run_mypy_accepts_complete_error_report() -> None:
"""
退出码 1 的完整诊断应交给 ratchet 比较,而不是被当作工具失败。
"""
result = subprocess.CompletedProcess(
args=[],
returncode=1,
stdout=MYPY_SAMPLE,
stderr="",
)
with patch("scripts.architecture.mypy_ratchet.subprocess.run", return_value=result):
assert run_mypy() == MYPY_SAMPLE
def test_run_mypy_rejects_missing_summary() -> None:
"""
没有完成摘要的错误输出可能被截断,必须拒绝。
"""
result = subprocess.CompletedProcess(
args=[],
returncode=1,
stdout=MYPY_SAMPLE.replace(
"Found 3 errors in 1 file (checked 500 source files)\n",
"",
),
stderr="",
)
with patch("scripts.architecture.mypy_ratchet.subprocess.run", return_value=result):
with pytest.raises(RuntimeError, match="缺少唯一的完整摘要"):
run_mypy()
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_requires_reduced_errors_to_be_persisted() -> None:
"""
错误减少后必须刷新 fixture,避免旧额度允许回退。
"""
baseline = {"app/a.py": {"arg-type": 5, "misc": 2}}
current = {"app/a.py": {"arg-type": 3}}
assert compare_counts(baseline, current) == [
"app/a.py: 类型错误低水位未固化 [arg-type] 5->3",
"app/a.py: 类型错误低水位未固化 [misc] 2->0",
]
def test_ratchet_requires_deleted_file_to_be_persisted() -> None:
"""
整文件债务消失也必须写回 fixture。
"""
baseline = {"app/a.py": {"arg-type": 1}}
assert compare_counts(baseline, {}) == [
"app/a.py: 类型错误低水位未固化 [arg-type] 1->0"
]
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)
def test_new_file_inside_registered_package_cannot_inherit_allowance() -> None:
"""
迁移登记只覆盖逐一登记的接收文件;同包内后来新增的文件不得吸收旧文件额度。
"""
baseline = {"app/chain/subscribe.py": {"arg-type": 5}}
current = {
"app/chain/subscribe/create.py": {"arg-type": 2},
"app/chain/subscribe/extra.py": {"arg-type": 3},
}
problems = compare_counts(baseline, current)
assert any(
"app/chain/subscribe/extra.py" in item and "新增文件必须清零" in item
for item in problems
)
assert not any("app/chain/subscribe/create.py" in item for item in problems)
def test_ratchet_allows_same_package_path_migration_only_when_each_code_drops() -> None:
"""
单文件拆成同名包时允许债务换 owner,但每个错误码仍必须只降不增。
"""
baseline = {
"app/chain/transfer.py": {"arg-type": 3, "attr-defined": 2},
"app/chain/_transfer.py": {"arg-type": 2},
}
current = {
"app/chain/transfer/plan.py": {"arg-type": 2},
"app/chain/transfer/workflow.py": {
"arg-type": 2,
"attr-defined": 1,
},
}
migrations = {
"transfer-package": Migration(
sources=("app/chain/transfer.py", "app/chain/_transfer.py"),
targets=(
"app/chain/transfer/plan.py",
"app/chain/transfer/workflow.py",
),
)
}
assert compare_counts(baseline, current, migrations) == [
"<path-migration:transfer-package>: 类型错误低水位未固化 "
"[arg-type] 5->4",
"<path-migration:transfer-package>: 类型错误低水位未固化 "
"[attr-defined] 2->1",
]
def test_ratchet_rejects_error_code_growth_inside_path_migration() -> None:
"""
迁移组总量下降也不得掩盖某个错误码增长。
"""
baseline = {
"app/chain/subscribe.py": {"arg-type": 5, "attr-defined": 1},
}
current = {
"app/chain/subscribe/create.py": {
"arg-type": 1,
"attr-defined": 2,
},
}
problems = compare_counts(baseline, current)
assert problems == [
"<path-migration:subscribe-package>: 既有错误增长 "
"[attr-defined] 1->2",
"<path-migration:subscribe-package>: 类型错误低水位未固化 "
"[arg-type] 5->1",
]
def test_ratchet_path_migration_does_not_absorb_unrelated_new_file() -> None:
"""
同名包迁移不得为包外新增类型错误提供额度。
"""
baseline = {"app/chain/subscribe.py": {"arg-type": 2}}
current = {
"app/chain/subscribe/create.py": {"arg-type": 1},
"app/api/new.py": {"arg-type": 1},
}
problems = compare_counts(baseline, current)
assert any("<path-migration:subscribe-package>" in item for item in problems)
assert any(
"app/api/new.py" in item and "新增文件必须清零" in item for item in problems
)
def test_write_refuses_to_legalize_type_regression(tmp_path, monkeypatch) -> None:
"""
已有基线出现增长时,--write 不得覆盖原文件。
"""
baseline_path = tmp_path / "mypy-baseline.json"
baseline_path.write_text("{}\n", encoding="utf-8")
monkeypatch.setattr(
sys,
"argv",
["mypy_ratchet.py", "--write", "--baseline", str(baseline_path)],
)
with patch("scripts.architecture.mypy_ratchet.run_mypy", return_value=MYPY_SAMPLE):
assert main() == 1
assert json.loads(baseline_path.read_text(encoding="utf-8")) == {}
def test_write_persists_reduced_type_low_watermark(tmp_path, monkeypatch) -> None:
"""
没有增长时,--write 应把下降后的完整快照固化。
"""
baseline_path = tmp_path / "mypy-baseline.json"
baseline_path.write_text(
json.dumps(
{
"app/application/a.py": {
"no-untyped-def": 3,
"type-arg": 1,
}
}
),
encoding="utf-8",
)
monkeypatch.setattr(
sys,
"argv",
["mypy_ratchet.py", "--write", "--baseline", str(baseline_path)],
)
with patch("scripts.architecture.mypy_ratchet.run_mypy", return_value=MYPY_SAMPLE):
assert main() == 0
assert json.loads(baseline_path.read_text(encoding="utf-8")) == {
"app/application/a.py": {"no-untyped-def": 2, "type-arg": 1}
}