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>
This commit is contained in:
2026-08-09 21:28:59 -04:00
parent 766f7a0290
commit e017160c45
20 changed files with 497 additions and 13 deletions
+71
View File
@@ -0,0 +1,71 @@
"""依赖规则 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}"
+67
View File
@@ -0,0 +1,67 @@
"""硬约束 §1.1 零业务假设:库内不许出现下游的业务词汇。
三个下游的领域互不相交(公文审查、超声诊断、agent 自我进化实验),一个业务词进来就等于
替其中一个项目做了另外两个不需要的假设。这类假设很难删——它会长出配套的字段、分支和测试,
删的时候要一起动。
**这个文件只读源码文本,不执行被测代码。** 它仍然属于 unit,因为分层判据是「依赖什么」
`CLAUDE.md` §1.9),而它不连任何外部服务。
**黑名单挡不住没被列出来的词。** 它拦得住最可能发生的那种——从下游搬代码时把词一起搬
过来;拦不住一个新造的业务词。真正守住这条的是评审,这里只是把最常见的入口堵上。
"""
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
PACKAGE_ROOT = REPO_ROOT / "src" / "polyloop"
#: 词来自三个下游各自的领域。加词的时机是「从某个下游搬了一段代码进来」,那时把它领域里
#: 最扎眼的几个词补进来。
BANNED_TERMS = (
# GovDoc:公文审查
"公文",
"审核点",
"招标",
"投标",
"标书",
"checkpoint_id",
"tender",
# CHSAnalyzer:超声诊断
"超声",
"切面",
"病灶",
"ultrasound",
# dissectagent 自我进化实验
"实验轮次",
"因子",
"预注册",
"benchmark",
"rollout",
"appworld",
)
pytestmark = pytest.mark.unit
def test_source_tree_is_scannable() -> None:
"""守卫自身的 fail-closed 检查:包目录必须存在且有 `.py` 文件。
没有这一条,包被改名或搬走之后这条断言会扫到空列表然后安静地绿,而绿的含义从
「没有业务词」变成了「没有检查」。
"""
assert PACKAGE_ROOT.is_dir(), f"扫描目标不存在:{PACKAGE_ROOT}"
assert list(PACKAGE_ROOT.rglob("*.py")), f"扫描目标里没有任何 .py 文件:{PACKAGE_ROOT}"
def test_no_business_terms_in_source() -> None:
"""库源码(含 docstring 与注释)里不许出现下游业务词汇。"""
violations: list[str] = []
for path in sorted(PACKAGE_ROOT.rglob("*.py")):
text = path.read_text(encoding="utf-8").casefold()
for term in BANNED_TERMS:
if term.casefold() in text:
violations.append(f"{path.relative_to(REPO_ROOT)}: {term}")
assert violations == [], f"库内出现业务词汇:{violations}"
+58
View File
@@ -0,0 +1,58 @@
"""包基线:版本号双写一致,以及 import 本库不会把网关拉起来。
**这个文件读源码文本和 `pyproject.toml`,不是只跑被测代码。** 它仍然属于 unit,因为
分层判据是「依赖什么」(`CLAUDE.md` §1.9),而它不连任何外部服务。
"""
import subprocess
import sys
import tomllib
from pathlib import Path
import pytest
import polyloop
REPO_ROOT = Path(__file__).resolve().parents[2]
pytestmark = pytest.mark.unit
def test_version_matches_pyproject() -> None:
"""`__version__` 与 `pyproject.toml` 的 version 必须一致。
两处双写是刻意的(见 `polyloop/__init__.py`),代价就是会漂移,所以用这条断言钉住。
漂移的表现是下游报 bug 时说的版本号和实际装的不是一个,而那种错查起来要绕很远。
"""
declared = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
assert polyloop.__version__ == declared["project"]["version"]
def test_importing_polyloop_does_not_import_polygateway() -> None:
"""依赖规则 9`import polyloop` 之后 `sys.modules` 里不许出现 `polygateway`。
这条写不成 import-linter 契约——它是运行时事实,不是静态图上的边。适配器模块允许
import 网关(规则 5),所以静态图上那条边合法;这里要禁的是**顶层 import 时就把它
拉起来**。一个顺手提供的默认模型客户端会让每个进程在 import 本库时,把网关连同它的
provider 目录一起加载。
在子进程里跑,因为本进程早就 import 过别的东西了,`sys.modules` 不干净。
"""
code = "import polyloop, sys; print('polygateway' in sys.modules)"
result = subprocess.run( # noqa: S603
[sys.executable, "-c", code],
capture_output=True,
text=True,
check=True,
cwd=REPO_ROOT,
)
assert result.stdout.strip() == "False", result.stdout
def test_py_typed_marker_ships_with_the_package() -> None:
"""`py.typed` 必须在包根里。
少了它,下游的类型检查器把整个包当成无注解的黑盒——而这个库对下游的承诺大半写在
签名里。这个文件是空的,很容易在某次移动目录时丢掉且不报错。
"""
assert (REPO_ROOT / "src" / "polyloop" / "py.typed").is_file()