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
+161
View File
@@ -0,0 +1,161 @@
[build-system]
requires = ["setuptools>=68.0"]
build-backend = "setuptools.build_meta"
[project]
name = "polyloop"
version = "0.0.0"
description = "PolyLoop:实验室共用的 Agent 执行内核——一次运行的预算、停止语义、取消、逐步轨迹与 Skill 注入"
requires-python = ">=3.11"
# 3.11 是被下游钉死的:dissect 与 GovDoc-SaaS 都跑在 3.11,一个库不能要求比它的消费者更高的版本。
dependencies = []
# 核心没有运行时依赖,这是刻意的。公共类型与接缝签名上不许出现第三方类型(依赖规则 6),
# 否则那个包的 major 就是我们的 major。
[project.optional-dependencies]
# 模型适配器单独成一个 extra:不用它的人不该被迫装上网关,也不该在 import 时把网关连同
# 它的 provider 目录一起拉起来(依赖规则 9)。PolyGateway 不在公共 PyPI 上,装它要配私有源。
gateway = ["polygateway>=1.1,<2"]
# dev 工具链的版本必须钉死,不用下界。教训来自 CHSAnalyzer:本地 conda 是 ruff 0.15.1、
# CI 装到刚发布的 0.16.0,新版本多了几条规则,于是「本地全绿、CI 全红」。不钉的话 CI 会在
# 没有任何人改代码的情况下随上游发版随机变红,而随机的红叉很快就会训练所有人忽略红叉。
# 升级流程:改这里的版本号 → 本地重装 → 修掉新规则的告警 → 一起提交。
dev = [
"pytest==9.1.1",
"pytest-asyncio==1.4.0",
"ruff==0.16.2",
"import-linter==2.13",
]
[tool.setuptools.packages.find]
where = ["src"]
# py.typed 让下游装上之后拿得到类型信息。一个库不带它,下游的类型检查器会把整个包当成
# 无注解的黑盒,签名和公共类型上写的东西一条都用不上——而这个库对下游的承诺大半就写在
# 签名里(CLAUDE.md §6:下游看不到实现,只能靠签名和类型判断该传什么)。
[tool.setuptools.package-data]
polyloop = ["py.typed"]
[tool.ruff]
target-version = "py311"
line-length = 100
[tool.ruff.lint]
# S110try-except-pass)与 E722(裸 except)断言 CLAUDE.md §1.7「禁止吞掉错误」。
# 这两条是硬约束的机器形式,不许在本文件或行内 noqa 里放开。
select = ["E", "F", "W", "I", "N", "UP", "B", "A", "C4", "SIM", "TCH", "S110"]
ignore = ["E501"]
[tool.ruff.lint.isort]
known-first-party = ["polyloop"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
asyncio_mode = "auto"
# 四层的判据是「依赖什么」,不是「叫什么」(CLAUDE.md §1.9)。按名字分层的话,改个函数名
# 就要挪测试文件;按依赖分,只要这个测试还是不连外部服务,它就一直待在原地。
#
# unit 里会出现少数只读源码文本、根本不执行被测代码的测试(依赖规则 6 那条「types 与 ports
# 禁止 import 任何第三方包」就是这种,它写不成 import-linter 契约)。它们不连任何外部服务,
# 按上面那条判据就是 unit,不为它们单开一层。哪天这类测试多到想单独跑,再拆不迟。
markers = [
"unit: 用测试替身,不连任何外部服务",
"integration: 连真 PolyGateway",
"e2e: 打真实模型网关,会产生真实调用与费用",
"contract: 公共 Protocol 的行为一致性套件,也是任何新适配器的准入标准",
]
# --strict-markers:拼错的标记直接报错,不给警告。默认配置下 @pytest.mark.uint 只是一条
# 警告,而那个文件会从此不属于任何一层、被所有筛选漏掉,静默消失。
# --import-mode=importlib:按文件路径建模块名,不按 basename。默认的 prepend 模式下
# tests/unit/test_stopping.py 与 tests/integration/test_stopping.py 会被当成同一个模块,
# 收集时报 import file mismatch——而且挂掉的是**整次收集**,连没被 -m 选中的层也一起挂。
# 另一种解法是给每个层目录补 __init__.py,选这个是因为它不要求以后新建目录的人记得补。
# -m 'not e2e'e2e 打真实网关、要花钱,默认不跑。显式 `pytest -m e2e` 覆盖(CLI 的 -m
# 后到优先)。这一条是刻意的默认排除,不是静默降级——它写在这里,且 markers 里注明了原因。
addopts = "--strict-markers --import-mode=importlib -m 'not e2e'"
# ---------------------------------------------------------------------------
# 依赖规则的机器形式。九条规则本身与它们各自的理由在
# research-wiki/design/0003-public-api-shape.md 决策八,当前形状在
# research-wiki/explanation/architecture.md 第七节。这里只写契约,不复述理由。
#
# 九条里有两条写不成 import-linter 契约,它们是 tests/ 里的测试:
# 规则 6types 与 ports 禁止 import 任何第三方包)——「任何第三方」不是一份可枚举的清单。
# 规则 9import polyloop 之后 sys.modules 里没有 polygateway)——那是运行时事实,不是静态图。
# ---------------------------------------------------------------------------
[tool.importlinter]
root_packages = ["polyloop"]
# 有几条契约要禁止 import 外部包(下游项目、polygateway、asyncio / pathlib),
# 而外部包默认不进 import 图,所以必须在顶层打开这个开关。
include_external_packages = true
# 规则 1、2、3、8。`|` 表示同层且互不 import`:` 表示同层可以互相 import。
# 装配层那三个用 `|`session 一旦 import 了某个存储实现,那个实现就成了隐式默认。
# 逻辑层那五个也用 `|`:它们之间的编织只能发生在 session 里。
[[tool.importlinter.contracts]]
name = "分层:装配层 > 逻辑层 > ports > types,且同层互不 import"
type = "layers"
layers = [
"polyloop.session | polyloop.stores | polyloop.adapters",
"polyloop.tools | polyloop._assembly | polyloop._stopping | polyloop._recovery | polyloop.serialization",
"polyloop.ports",
"polyloop.types",
]
# 规则 8 单列。上一条已经覆盖它,重复一遍是为了让违规信息直接指向
# 「接缝定义模块被污染了」,而不是一条读起来要绕一圈的分层报错。
[[tool.importlinter.contracts]]
name = "ports 只依赖 types,不依赖任何其他 polyloop 模块"
type = "forbidden"
source_modules = ["polyloop.ports"]
forbidden_modules = [
"polyloop.session",
"polyloop.stores",
"polyloop.adapters",
"polyloop.tools",
"polyloop._assembly",
"polyloop._stopping",
"polyloop._recovery",
"polyloop.serialization",
]
# 规则 4。三个下游的顶层包名:dissect 是 harnessGovDoc-SaaS 是 docagent_core
# CHSAnalyzer 还没写到 agent 那一步,等它有了包名再加进来。
[[tool.importlinter.contracts]]
name = "不反向 import 任何下游项目"
type = "forbidden"
source_modules = ["polyloop"]
forbidden_modules = ["harness", "docagent_core"]
# 规则 5。适配器是唯一允许碰网关的地方。
[[tool.importlinter.contracts]]
name = "除 adapters 外一切禁止 import polygateway"
type = "forbidden"
source_modules = [
"polyloop.session",
"polyloop.stores",
"polyloop.tools",
"polyloop._assembly",
"polyloop._stopping",
"polyloop._recovery",
"polyloop.serialization",
"polyloop.ports",
"polyloop.types",
]
forbidden_modules = ["polygateway"]
# 规则 7。这一条是烟雾报警,不是纯度契约——os、subprocess、sqlite3 都能绕过它,
# 而 open() 是内置函数,import-linter 根本看不见。它拦得住最常见的那种偷懒
# (写着写着顺手 await 一下存储、顺手读个文件),拦不住存心的。
[[tool.importlinter.contracts]]
name = "三个纯逻辑模块不碰 I/O 与事件循环"
type = "forbidden"
source_modules = [
"polyloop._assembly",
"polyloop._stopping",
"polyloop._recovery",
]
forbidden_modules = ["asyncio", "pathlib"]