Files
PolyLoop/tests/unit/test_import_purity.py
iomgaa e017160c45 build(repo): 落成工具链、依赖契约与十个模块的空骨架
第 ③ 阶段剩下的那半:架构文档之外,import-linter 契约也落地了。

pyproject.toml 把九条依赖规则里的七条写成五条 import-linter 契约。
分层用一条 layers 契约表达规则 1、2、3、8,`|` 表示同层互不 import;
另外四条 forbidden 分别管下游反向 import、polygateway 的唯一入口、
以及三个纯逻辑模块不碰 asyncio 与 pathlib。
契约不是平凡的绿:故意注入两处违规验证过,都被点名到行号。

先建十个模块的空包,是为了避开 PolyGateway bootstrap 期那段 Makefile 门控——
它当时没有包,lint-imports 报 module not found 而红,只好加一段跳过逻辑。
空包让契约从第一天就真的在跑。

剩下两条规则落不进契约,写成了 tests/unit 下的测试:
规则 6「types 与 ports 不许 import 任何第三方」判据要反过来写(只许标准库和自己),
规则 9「import polyloop 之后 sys.modules 里没有 polygateway」是运行时事实。
另加硬约束 §1.1 零业务假设的黑名单扫描——它第一次跑就抓到 _assembly 的 docstring
里写了 dissect 的业务词,已改。三个扫描类测试都带 fail-closed 守卫,
防止目录搬走之后扫到空列表安静地绿。

工程约定取自实验室已有项目:setuptools + src layout、ruff 十一项 select、
line-length 100 来自 PolyGateway;dev 工具链版本钉死、--strict-markers 与
--import-mode=importlib 来自 CHSAnalyzer 与 dissect 踩过的坑,各自的理由写在配置注释里。
e2e 默认不跑,它打真实网关要花钱。

CLAUDE.md §0 那句「契约还没写,要等 src/ 落地」已过期,改掉;
README 勾掉第 ②③ 阶段,补上本地检查命令与 GovDoc-Editor 那一行。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:28:59 -04:00

72 lines
3.3 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.
"""依赖规则 6`types` 与 `ports` 不许 import 任何第三方包。
写不成 import-linter 契约,因为「任何第三方」不是一份可枚举的清单——契约要求你把禁止
的包名列出来,而这条规则要禁的是**清单之外的一切**。所以判据反过来写:允许的只有标准库
和 `polyloop` 自己,其余一律违规。
**这个文件只读源码文本,不执行被测代码。** 它仍然属于 unit,因为分层判据是「依赖什么」
`CLAUDE.md` §1.9),而它不连任何外部服务。
规则本身与它的理由在 `research-wiki/design/0003-public-api-shape.md` 决策八:公共类型与
接缝签名上一旦出现第三方类型,那个包的 major 就是我们的 major。
`TYPE_CHECKING` 分支里的 import 同样算数。那种 import 在运行时不发生,但它会出现在签名
的类型注解里,下游的类型检查器要解析它——于是那个包照样成了我们对外承诺的一部分。
"""
import ast
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
PACKAGE_ROOT = REPO_ROOT / "src" / "polyloop"
#: 这两个模块受本规则约束。其余模块允许用第三方包,由 import-linter 的分层契约管。
PURE_MODULES = ("types", "ports")
pytestmark = pytest.mark.unit
def _imported_root_packages(source: str) -> set[str]:
"""收集一段源码里所有 import 的顶层包名,含 `TYPE_CHECKING` 分支里的。
相对 import`from . import x`)不产生顶层包名,直接跳过——它指向的一定是本包内部。
"""
roots: set[str] = set()
for node in ast.walk(ast.parse(source)):
if isinstance(node, ast.Import):
for alias in node.names:
roots.add(alias.name.split(".")[0])
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
roots.add(node.module.split(".")[0])
return roots
def _python_files(module_name: str) -> list[Path]:
return sorted((PACKAGE_ROOT / module_name).rglob("*.py"))
@pytest.mark.parametrize("module_name", PURE_MODULES)
def test_module_directory_is_scannable(module_name: str) -> None:
"""守卫自身的 fail-closed 检查:扫描目标必须真的存在且有 `.py` 文件。
没有这一条,哪天目录被改名或搬走,上面那条断言会扫到一个空列表然后安静地绿——
而绿的含义从「没有违规」变成了「没有检查」,两者在输出上分不出来。
"""
directory = PACKAGE_ROOT / module_name
assert directory.is_dir(), f"扫描目标不存在:{directory}"
assert _python_files(module_name), f"扫描目标里没有任何 .py 文件:{directory}"
@pytest.mark.parametrize("module_name", PURE_MODULES)
def test_pure_module_imports_only_stdlib_or_polyloop(module_name: str) -> None:
"""`types` 与 `ports` 的每一处 import 都必须落在标准库或 `polyloop` 内。"""
allowed = set(sys.stdlib_module_names) | {"polyloop"}
violations: list[str] = []
for path in _python_files(module_name):
for root in sorted(_imported_root_packages(path.read_text(encoding="utf-8")) - allowed):
violations.append(f"{path.relative_to(REPO_ROOT)}: {root}")
assert violations == [], f"{module_name} 里出现了第三方 import{violations}"