test(architecture): freeze direct adapter debt

This commit is contained in:
jxxghp
2026-08-27 06:06:14 +08:00
parent e1483e85dc
commit 2553226f37
12 changed files with 461 additions and 86 deletions
+1
View File
@@ -45,6 +45,7 @@ jobs:
run: |
uv run --locked --no-sync pytest \
tests/test_architecture_dependencies.py \
tests/test_architecture_adapter_imports.py \
tests/test_architecture_contract_baseline.py \
tests/test_architecture_baseline_cli.py -q
uv run --locked --no-sync python \
+4 -4
View File
@@ -30,7 +30,7 @@ For work that changes or reviews repository behavior, identify the domains actua
### External Communication and Interfaces
* **Primary Reference:** `docs/rules/09-external-response.md`
* **Required Constraints:** All third-party HTTP requests must go through `RequestUtils`. Response formats must use the project's standard schemas. Error handling must follow the per-layer conventions.
* **Required Constraints:** Transport implementations for third-party HTTP must go through `RequestUtils`; this rule does not authorize Application/Chain to import the concrete Adapter. Response formats must use the project's standard schemas. Error handling must follow the per-layer conventions.
### Data and Persistence
* **Primary Reference:** `docs/rules/10-data-and-persistent.md`
@@ -66,17 +66,17 @@ The legacy roots have no physical directories in the source tree. Current images
| `app/adapters/cache/` | Redis 与文件缓存等具体持久化实现 | 缓存协议、装饰器和进程内缓存策略 | `backends.py`, `redis.py` |
| `app/adapters/system/` | 操作系统、文件、进程、标准流、包/资源安装、显示和 Rust 加速适配 | 业务规则、进程重启决策 | `host.py`, `display/`, `stdio.py`, `package.py`, `resource.py`, `rust.py`, `fsproxy.py` |
| `app/adapters/external/` | CookieCloud、插件市场、OCR、IP 归属和 MoviePilot Server 等命名外部生态 | 通用 HTTP/DNS/文件机制或可复用领域语义 | `market.py`, `server.py`, `cookiecloud.py`, `ocr.py`, `location.py`, `wechat_crypt.py` |
| `app/application/` | 聚焦应用服务、用例命令,以及由用例拥有的持久化 Port/Protocol | SQLAlchemy、Session、Oper 等具体 DB 实现,多领域 Chain 编排、底层通用机制、通用传输协议 | `recognition.py`, `filter.py`, `outbox.py`, `subscription/write.py`, `workflow.py` |
| `app/application/` | 聚焦应用服务、用例命令,以及由用例拥有的持久化/技术能力 Port/Protocol | SQLAlchemy、Session、Oper 等具体 DB 实现,具体 Adapter 静态依赖,多领域 Chain 编排、底层通用机制、通用传输协议 | `recognition.py`, `filter.py`, `outbox.py`, `subscription/write.py`, `workflow.py` |
| `app/application/messaging/` | 消息渲染/路由、交互和 Agent 到消息桥接:`ingress.py` 统一渠道回环入口;`interaction.py` 通用交互契约和视图工具;`router.py` 统一交互优先级和回调分发;`site.py`/`subscribe.py`/`skill.py` 对应命令的会话、输入解析和视图;`media.py` 媒体交互状态(业务工作流仍由 `MediaInteractionChain` 执行);`plugin.py` 插件输入接管和插件按钮回调;`agent.py` Agent 选择状态、回调协议和 WebAgent 消息桥接;`message.py` 通知渲染、模板和队列。不作为推荐给插件直接使用的公开 SDK | 认证策略、通用 HTTP、服务发现、仅端点使用的 Web Push 行为 | `ingress.py`, `message.py`, `interaction.py`, `router.py`, `agent.py` |
| `app/application/security/` | 认证、授权、Cookie、Passkey、OTP/二次认证、路径/URL 安全、SSRF 和签名策略 | 通用 URL 解析、进程运行策略、普通业务校验 | `access.py`, `auth.py`, `cookie.py`, `passkey.py`, `otp.py`, `twofactor.py`, `url.py` |
| `app/chain/` | Reusable use-case orchestration across modules, Application services, injected ports, events, and caches; chains reach modules only through `run_module` dispatch on method-name contracts | Transport schemas, backend-specific protocol details, generic primitives, direct Oper/DB imports, direct imports of module internals (classes, exceptions, constants) | `media.py`, `download.py`, `subscribe.py`, `transfer.py` |
| `app/chain/` | Reusable use-case orchestration across modules, Application services, injected ports, events, and caches; chains reach modules only through `run_module` dispatch on method-name contracts | Transport schemas, backend-specific protocol details, concrete Adapter imports, generic primitives, direct Oper/DB imports, direct imports of module internals (classes, exceptions, constants) | `media.py`, `download.py`, `subscribe.py`, `transfer.py` |
| `app/db/oper/` | 面向表和持久化值的 SQLAlchemy 数据访问;接收调用方 Session,只查询、暂存或 flush | Application 业务规则、隐式事务所有权、外部副作用 | `subscribe.py`, `site.py`, `workflow.py` |
| `app/db/adapters/` | 实现 Application 持久化 Port,创建短生命周期 Session/UoW,并适配 Oper | 用例规则、启动顺序、进程生命周期 | `subscription.py`, `site.py`, `outbox.py`, `workflow.py` |
| `app/startup/` | Composition root: `composition/` 构造并注入跨层依赖,`initializers/` 按领域初始化,`lifecycle/` 编排启动关闭 | Reusable business rules or adapter implementation details | `composition/context.py`, `composition/database.py`, `initializers/modules.py`, `lifecycle/components.py` |
| `app/sdk/` | Deliberately curated stable imports for new plugins | Canonical implementation logic or host-internal dependencies | `browser.py`, `cache.py`, `logging.py`, `media.py`, `network.py`, `services.py` |
| `app/runtime/compat/` | 仅依赖标准库的精确旧导入路由、资源前置扫描和 DEBUG 诊断 | 业务实现、通配猜测、目标模块的提前导入 | `manifest.py`, `imports.py`, `resource_imports.py`, `diagnostics.py` |
容易误分的三个边界必须按实际职责判断:`application/rss.py` 同时承担 Feed/种子语义、站点规则和浏览器回退,不是单纯 HTTP 传输;`application/site/sites.*``user.sites.v3.bin` 共同构成站点目录、认证和索引应用能力,只有下载安装机制留在 `adapters/system/resource.py``foundation/crypto.py` 只提供无状态 RSA/摘要/AES 算法,认证、签名、令牌和二次验证策略仍属于 `application/security/`
容易误分的三个边界必须按实际职责判断:`application/rss.py` 同时承担 Feed/种子语义、站点规则和浏览器回退,不是单纯 HTTP 传输;规范目标是由它拥有所需 Port、startup 注入 network/system Adapter。当前直接导入是 `S2-L6` 临时债务,不是允许的新模式。`application/site/sites.*``user.sites.v3.bin` 共同构成站点目录、认证和索引应用能力,只有下载安装机制留在 `adapters/system/resource.py``foundation/crypto.py` 只提供无状态 RSA/摘要/AES 算法,认证、签名、令牌和二次验证策略仍属于 `application/security/`
### Placement Decision Order
+5 -5
View File
@@ -154,8 +154,8 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
- 审计时 `docs/rules/04-design-patterns.md` 仍示范 `SubscribeOper()` 无 Session 调用,
与生产路径显式 UoW 规则冲突;该项已由 S0-L2.1 修复并增加文档门禁。
- `docs/rules/05-architecture.md:507` 禁止 `application -> concrete adapter`,但同一文档
`177-183` 又要求 RSS 消费 network adapter;当前测试没有形式化这类例外
- 审计时规则禁止 `application -> concrete adapter`,但 RSS 段落又要求直接消费 network adapter
S0-L2.4 已统一为 Application-owned Port + startup 注入,并将现有直连全部列为临时债务
- 审计时完整 SCC 只进入生成快照,语义测试只覆盖特定根;S0-L2.2 已增加完整宿主 SCC policy 门禁。
- 架构总览此前仍记录 811 模块、6,572 条边和 1 个 SCC,已经落后于当前基线。
- Event consumer 扫描把任意名为 `.register()` 的调用都当成事件注册,存在明确误报。
@@ -164,7 +164,7 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
- [ ] 指定一个机器可读事实源,文档指标由 fixture 生成或只保留不易漂移的语义描述。
- [x] 修正 Oper 示例,分别展示宿主显式 UoW 与插件兼容 Facade,并以文档测试禁止回退。
- [ ] 对 Adapter 规则作出明确决策:允许哪些通用技术机制,哪些命名外部/持久化能力必须注入
- [x] 明确 Application/Chain 不永久直连具体 Adapter;业务层拥有 Portstartup 注入实现
- [x] 让 SCC 规则、精确 policy 和文档声明一致;Chain 临时债务与 TMDB vendor containment 分开治理。
- [ ] Event 扫描只识别 EventManager 实例/别名和事件装饰器。
- [ ] CI 分开报告“快照一致”与“语义规则通过”,禁止把前者表述为架构完全正确。
@@ -420,11 +420,11 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain`
**目标与步骤**
- [ ] 建立 `application_to_adapters``chain_to_adapters` 和 direct-egress 可审查清单,冻结增长
- [x] 建立 Application/Chain 原始 Adapter 直连事实与精确临时 policy,冻结新增、替换和陈旧条目
- [ ] 将 Passkey 原子领取提升为 runtime cache contract,由 Memory/Redis backend 分别实现。
- [ ] 为 Backup 定义 Application-owned artifact store Port,由 startup 注入文件系统实现。
- [ ] 普通外部请求迁移到统一网络能力;SDK transport、streaming 和 vendor code 例外必须精确到路径与原因。
- [ ] 命名外部产品安全敏感能力优先改为注入 Port通用技术 Adapter 是否可直接使用由 ARCH-101 决策
- [ ] 命名外部产品安全敏感能力通用技术 Adapter 均改为注入 Port;不在 Application/Chain 保留直连例外
- [ ] 最终把基线收缩到零或少量书面化例外,而不是一次性禁止后再大量豁免。
```bash
+8 -6
View File
@@ -61,7 +61,7 @@ flowchart LR
MoviePilot v3 以单向依赖的模块化单体为目标。历史包 `app/core``app/helper``app/utils`
已不再存在物理目录,仅作为旧插件的**虚拟兼容导入根**保留。核心边界已有机器门禁,
尚未强制的 Adapter 例外、完整宿主 SCC 等差距记录在本章末的优化清单中。
完整宿主 SCC 与 Adapter 直连已有精确政策门禁;其余尚未强制的边界记录在本章末的优化清单中。
下图同时表达职责关系与运行时调用方向,不等同于 Python 静态 import 图;通过 Port 注入的调用
会与具体 Adapter 的静态依赖方向相反。
@@ -164,9 +164,9 @@ Chain/Agent 的 `Any` factory 和裸 Oper 仍需迁移为类型化 Port/DTO
`app/db/adapters/outbox.py` 实现;`app/runtime/tasks.py` 的 TaskRegistry 只负责进程内任务所有权、
取消和有限等待,不承担 durable queue 语义。
**依赖方向的规范目标**如下。`tests/test_architecture_dependencies.py` 已强制其中的核心子集;
Application/Chain 到具体 Adapter 的例外和完整宿主 SCC 仍需按优化清单补齐门禁,不能把表中每一行
都理解为当前已经被 CI 全量证明:
**依赖方向的规范目标**如下。完整宿主 SCC 与 Application/Chain 具体 Adapter 直连已经使用
生成事实 + 人工 policy 门禁;当前 Adapter 边均为有迁移 owner 的临时债务,不是永久例外。
其余尚未机器化的规则仍按优化清单补齐,不能把表中每一行都理解为当前已经被 CI 全量证明:
| 方向 | 状态 |
|---|---|
@@ -640,8 +640,8 @@ flowchart LR
Caller --> AB
```
- **所有第三方 HTTP 请求必须走 `RequestUtils`**`app/adapters/network/http.py`);
插件使用 `app.sdk.network`
- **第三方 HTTP 的 transport 实现必须走 `RequestUtils`**`app/adapters/network/http.py`);
这不授权 Application/Chain 直接导入具体 Adapter插件使用 `app.sdk.network`
- `app/runtime/log.py` 是依赖叶子(无任何 `app.*` 导入);`foundation` 不输出运行日志,
由上层所有者决定是否记录。
- 缓存契约与内存后端在 `runtime/cache.py`Redis/文件实现在 `adapters/cache/backends.py`
@@ -671,6 +671,8 @@ flowchart LR
- `tests/fixtures/architecture/dependency-baseline.json` 记录生成事实;人工审查的 SCC 分类单独存入
`dependency-policy.json`。完整宿主 SCC 必须精确匹配 policy,新增、扩大、变形和陈旧 policy 都失败;
`--write-host` 不会替代人工决策。
- 同一 baseline 的 `direct_adapter_imports` 记录现存原始直连;policy 将其全部标为有 owner 的
`temporary_debt`,并以初始 28 条冻结上界、目标为空集合。新增、替换、删除后未清理 policy 都会失败。
- 任何所有权迁移必须同步更新:canonical 导入、`app/runtime/compat/manifest.py`
SDK 导出(若公开)、`docs/rules/05-architecture.md` 与上述架构测试。
- 延迟导入不被接受为隐藏循环依赖的手段。
+23 -18
View File
@@ -75,10 +75,11 @@ G-ARCH 只有在以下条件全部满足后才可完成:
| S0-L1 可信基线恢复 | `DELIVERED` | 无 | `5df388719`:交付架构审计/路线图,修复 `ARCH-001` 两个 mypy 增量错误;远端 `0/0` |
| S0-L2.1 Host Oper/UoW 规范 | `DELIVERED` | S0-L1 | `3bf94ffed`:宿主无 Session Oper 规范债务归零,远端 `0/0` |
| S0-L2.2 完整宿主 SCC policy | `DELIVERED` | S0-L2.1 | `a884ab5c2`:完整宿主 SCC 精确 policy 生效,远端 `0/0` |
| S0-L2.3 Adapter 直连事实 | `VERIFIED` | S0-L2.1 | 收集 Application/Chain 的原始 Adapter import,不因父包展开重复计数 |
| S0-L2.4 Adapter zero-growth | `PLANNED` | S0-L2.3 | 当前直连均登记迁移 owner,新增/替换失败,删除后要求清理陈旧 policy |
| S0-L2.3 Adapter 直连事实 | `DELIVERED` | S0-L2.1 | `e1483e85d`:锁定 28 条原始 Adapter import 事实,远端 `0/0` |
| S0-L2.4 Adapter zero-growth | `VERIFIED` | S0-L2.3 | 当前直连均登记迁移 owner,新增/替换失败,删除后要求清理陈旧 policy |
| S0-L2.4b HTTP/Egress 事实与政策 | `PLANNED` | S0-L2.4 | direct HTTP/SDK/stream/vendor egress 分类完整,债务与精确例外分别受不可增长政策约束 |
| S0-L2.5 Event consumer 识别 | `PLANNED` | S0-L2.1 | consumer 只识别可静态证明的 EventManager 注册,动态误报归零 |
| S0-L2.6 事实源与 CI 投影 | `PLANNED` | S0-L2.2,S0-L2.4,S0-L2.5 | fixture/policy/overview 职责固定,CI 分开报告语义 policy 与快照一致性 |
| S0-L2.6 事实源与 CI 投影 | `PLANNED` | S0-L2.2,S0-L2.4b,S0-L2.5 | fixture/policy/overview 职责固定,CI 分开报告语义 policy 与快照一致性 |
### S1:可靠性、事务与数据合同
@@ -153,27 +154,29 @@ G-ARCH 只有在以下条件全部满足后才可完成:
## 4. 当前活动叶子
### S0-L2.3 Adapter 直连事实
### S0-L2.4 Adapter zero-growth
**Status:** `VERIFIED`(本地验收完成,等待提交、推送和远端一致性确认)
**Outcome**
用原始 AST import 记录 Application/Chain 到 Adapter 的稳定直连事实,不把父包初始化边、
导入符号、行号、TYPE_CHECKING 或动态字符串导入混入治理面。
把 S0-L2.3 收集的 Application/Chain 到具体 Adapter 直连全部登记为有迁移 owner 的临时债务。
初始 28 条形成只减不增的冻结上界,政策目标固定为空集合;新增、替换和删除后未同步清理的
陈旧登记都必须失败。
**Ownership**
- `scripts/architecture/baseline.py` 的 direct Adapter import collector
- `tests/test_architecture_adapter_imports.py` AST 语义和当前 28 条事实断言
- `tests/fixtures/architecture/dependency-baseline.json` 的 schema v2 生成字段
- `tests/fixtures/architecture/dependency-policy.json` 的人工 Adapter policy
- `tests/test_architecture_adapter_imports.py`精确集合、owner 和 stale-policy 门禁
- `AGENTS.md``docs/rules/05-architecture.md` 与架构总览中的规范边界
- `.github/workflows/test.yml` 的快速架构 CI 投影。
- 本路线图的叶子状态和交付记录。
**Excluded**
-批准当前直连、不写 owner、不建立豁免;这些属于紧随其后的 S0-L2.4
- 不修改现有完整依赖图、SCC、digest、`app/plugins/**` 或运行时代码。
- 不把 `app.db.adapters`、SDK、插件副本或第三方 HTTP egress 混入本字段
-在本叶迁移 28 条运行时依赖;迁移由 S2-L4 至 S2-L7 按 owner 债务清零
- 不修改生成事实、完整依赖图、SCC、digest、`app/plugins/**` 或运行时代码。
- 不把 direct HTTP/SDK/stream/vendor egress 混入 Adapter policy;这些由 S0-L2.4b 独立治理
**Acceptance**
@@ -182,17 +185,19 @@ G-ARCH 只有在以下条件全部满足后才可完成:
tests/test_architecture_adapter_imports.py \
tests/test_architecture_contract_baseline.py \
tests/test_architecture_dependencies.py \
tests/test_architecture_baseline_cli.py -q
.venv/bin/python scripts/architecture/baseline.py --check-host --diagnostics
tests/test_architecture_ci.py \
tests/test_architecture_documentation.py -q
.venv/bin/python scripts/architecture/baseline.py --check-host
.venv/bin/python scripts/architecture/ruff_ratchet.py
.venv/bin/python scripts/architecture/mypy_ratchet.py
.venv/bin/pylint scripts/architecture/baseline.py \
tests/test_architecture_adapter_imports.py \
tests/test_architecture_contract_baseline.py
.venv/bin/pylint tests/test_architecture_adapter_imports.py \
tests/test_architecture_contract_baseline.py \
tests/test_architecture_dependencies.py \
tests/test_architecture_ci.py
git diff --check
```
**Delivery**
- 单一提交主题:生成并锁定 Adapter 原始直连事实
- 单一提交主题:冻结现有 Adapter 直连债务并阻止增长
- 推送 `origin/v3` 后确认提交祖先关系、远端 SHA 和 ahead/behind `0/0`
+14 -2
View File
@@ -177,7 +177,10 @@ the plugin SDK.
RSS is not classified as a transport adapter merely because it uses HTTP. The
current `RssHelper` combines feed parsing, torrent item semantics, configured
site-specific URL discovery and browser fallback, so it belongs to
`app/application/rss.py` and consumes network adapters. Likewise, the generated
`app/application/rss.py`. The target design gives it ownership of the required
technical Ports and lets startup inject network/system Adapter implementations.
Its current concrete imports are tracked as `S2-L6` temporary debt, not an
approved dependency direction. Likewise, the generated
site extension owns the configured catalog/authentication/index capability and
lives in `app/application/site/`; only its download and file installation
mechanism remains in `app/adapters/system/resource.py`.
@@ -516,12 +519,21 @@ The target remains zero canonical host cycles except the precisely contained
vendor component. A temporary policy entry is an executable migration obligation,
not precedent for approving another cycle.
The same fact/policy split governs direct Adapter imports. The generated
dependency baseline records the original runtime imports from `app.application`
and `app.chain` without parent-package expansion. Every current edge is an exact
`temporary_debt` entry in dependency policy with a removal leaf; the target state
is empty. New or replacement edges and stale policy entries fail. Application owns
the Port required by its use case, startup injects the concrete Adapter, and Chain
consumes the Application capability or an injected Port. A `canonical capability`
never means permission to import a concrete `app.adapters.*` implementation.
## Permitted Call Directions
| Direction | Status |
|---|---|
| `entrypoint -> chain / application / injected persistence Port` | Allowed according to workflow complexity |
| `chain -> module (only via run_module dispatch) / application / injected Port / canonical capability` | Allowed; direct `chain -> module` and `chain -> Oper` imports forbidden |
| `chain -> module (only via run_module dispatch) / application / injected Port / canonical capability` | Allowed; direct `chain -> module`, `chain -> Oper` and `chain -> concrete adapter` imports forbidden |
| `chain -> agent implementation` | Forbidden; chains reach Agent runtime only through `app/application/agent.py`; `app/startup/initializers/agent.py` registers lightweight providers at import time, and implementations are materialized only when the capability is enabled or first used |
| `agent.tools -> api / scheduler / command` | Forbidden; tools use `app/application/plugin/routes.py`, `plugin/folders.py`, `scheduling.py` and `commands.py` application services |
| `api -> factory` | Forbidden; the FastAPI route adapter is injected into `app/application/plugin/routes.py` by the composition root after creation |
+6 -1
View File
@@ -2,7 +2,11 @@
## HTTP Client Conventions
**Rule:** Host outbound HTTP requests must go through `RequestUtils` from `app/adapters/network/http.py`. Plugins import it from `app.sdk.network`. Do not use `requests`, `httpx`, or `aiohttp` directly.
**Rule:** Host outbound HTTP transport implementations must go through `RequestUtils` from
`app/adapters/network/http.py`. This transport rule does not authorize Application or Chain to
import the concrete Adapter; they own/use a Port and receive its implementation from startup.
Plugins import the curated facade from `app.sdk.network`. Do not use `requests`, `httpx`, or
`aiohttp` directly outside a separately reviewed SDK, streaming, or vendored transport boundary.
`RequestUtils` handles:
- Proxy configuration (from `settings.PROXY_*`)
@@ -12,6 +16,7 @@
- Retry logic
```python
# Adapter implementation example; Application/Chain consume an injected Port instead.
from app.adapters.network.http import RequestUtils
res = RequestUtils(
+158 -2
View File
@@ -1,5 +1,5 @@
{
"schema_version": 1,
"schema_version": 2,
"scope": {
"dependency_kind": "static_runtime_imports",
"excluded_roots": [
@@ -57,5 +57,161 @@
"reason": "隔离的移植代码保持精确成员 containment;普通单向包外依赖允许,任何成员扩张或包外反向入环均失败。",
"tracking": "replace-or-upgrade-vendored-package"
}
]
],
"direct_adapter_imports": {
"classification": "temporary_debt",
"scope": {
"source_roots": [
"app.application",
"app.chain"
],
"target_root": "app.adapters",
"runtime_only": true,
"parent_package_expansion": false,
"imported_symbols": false
},
"entries": [
{
"source": "app.application.backup",
"target": "app.adapters.system.backup.files",
"tracking": "S2-L5"
},
{
"source": "app.application.directory",
"target": "app.adapters.system.host",
"tracking": "S2-L6"
},
{
"source": "app.application.image",
"target": "app.adapters.network.http",
"tracking": "S2-L6"
},
{
"source": "app.application.image",
"target": "app.adapters.network.ip",
"tracking": "S2-L6"
},
{
"source": "app.application.messaging.ingress",
"target": "app.adapters.network.http",
"tracking": "S2-L6"
},
{
"source": "app.application.rss",
"target": "app.adapters.network.browser",
"tracking": "S2-L6"
},
{
"source": "app.application.rss",
"target": "app.adapters.network.http",
"tracking": "S2-L6"
},
{
"source": "app.application.rss",
"target": "app.adapters.system",
"tracking": "S2-L6"
},
{
"source": "app.application.rules",
"target": "app.adapters.system",
"tracking": "S2-L6"
},
{
"source": "app.application.security.cookie",
"target": "app.adapters.external.ocr",
"tracking": "S2-L6"
},
{
"source": "app.application.security.cookie",
"target": "app.adapters.network.browser",
"tracking": "S2-L6"
},
{
"source": "app.application.security.cookie",
"target": "app.adapters.network.http",
"tracking": "S2-L6"
},
{
"source": "app.application.security.passkey",
"target": "app.adapters.cache.redis",
"tracking": "S2-L4"
},
{
"source": "app.application.torrent",
"target": "app.adapters.network.http",
"tracking": "S2-L6"
},
{
"source": "app.application.transfer",
"target": "app.adapters.system.host",
"tracking": "S2-L6"
},
{
"source": "app.chain._recognition",
"target": "app.adapters.external.server",
"tracking": "S2-L7"
},
{
"source": "app.chain._transfer",
"target": "app.adapters.system.host",
"tracking": "S2-L7"
},
{
"source": "app.chain.download",
"target": "app.adapters.network.http",
"tracking": "S2-L7"
},
{
"source": "app.chain.download",
"target": "app.adapters.system.host",
"tracking": "S2-L7"
},
{
"source": "app.chain.message",
"target": "app.adapters.network.http",
"tracking": "S2-L7"
},
{
"source": "app.chain.scraping",
"target": "app.adapters.network.http",
"tracking": "S2-L7"
},
{
"source": "app.chain.site",
"target": "app.adapters.external.cookiecloud",
"tracking": "S2-L7"
},
{
"source": "app.chain.site",
"target": "app.adapters.network.browser",
"tracking": "S2-L7"
},
{
"source": "app.chain.site",
"target": "app.adapters.network.cloudflare",
"tracking": "S2-L7"
},
{
"source": "app.chain.site",
"target": "app.adapters.network.http",
"tracking": "S2-L7"
},
{
"source": "app.chain.subscribe",
"target": "app.adapters.external.server",
"tracking": "S2-L7"
},
{
"source": "app.chain.system",
"target": "app.adapters.network.http",
"tracking": "S2-L7"
},
{
"source": "app.chain.system",
"target": "app.adapters.system.host",
"tracking": "S2-L7"
}
],
"target_state": "empty"
}
}
+233 -41
View File
@@ -1,5 +1,7 @@
"""Application/Chain 到 Adapter 原始直连事实的收集契约。"""
import json
from collections import Counter
from pathlib import Path
from scripts.architecture.baseline import (
@@ -7,35 +9,48 @@ from scripts.architecture.baseline import (
collect_direct_adapter_imports,
)
EXPECTED_DIRECT_ADAPTER_IMPORTS = {
("app.application.backup", "app.adapters.system.backup.files"),
("app.application.directory", "app.adapters.system.host"),
("app.application.image", "app.adapters.network.http"),
("app.application.image", "app.adapters.network.ip"),
("app.application.messaging.ingress", "app.adapters.network.http"),
("app.application.rss", "app.adapters.network.browser"),
("app.application.rss", "app.adapters.network.http"),
("app.application.rss", "app.adapters.system"),
("app.application.rules", "app.adapters.system"),
("app.application.security.cookie", "app.adapters.external.ocr"),
("app.application.security.cookie", "app.adapters.network.browser"),
("app.application.security.cookie", "app.adapters.network.http"),
("app.application.security.passkey", "app.adapters.cache.redis"),
("app.application.torrent", "app.adapters.network.http"),
("app.application.transfer", "app.adapters.system.host"),
("app.chain._recognition", "app.adapters.external.server"),
("app.chain._transfer", "app.adapters.system.host"),
("app.chain.download", "app.adapters.network.http"),
("app.chain.download", "app.adapters.system.host"),
("app.chain.message", "app.adapters.network.http"),
("app.chain.scraping", "app.adapters.network.http"),
("app.chain.site", "app.adapters.external.cookiecloud"),
("app.chain.site", "app.adapters.network.browser"),
("app.chain.site", "app.adapters.network.cloudflare"),
("app.chain.site", "app.adapters.network.http"),
("app.chain.subscribe", "app.adapters.external.server"),
("app.chain.system", "app.adapters.network.http"),
("app.chain.system", "app.adapters.system.host"),
PROJECT_ROOT = Path(__file__).parents[1]
DEPENDENCY_POLICY_PATH = (
PROJECT_ROOT / "tests" / "fixtures" / "architecture" / "dependency-policy.json"
)
DIRECT_ADAPTER_SOURCE_ROOTS = ("app.application", "app.chain")
DIRECT_ADAPTER_TARGET_ROOT = "app.adapters"
DIRECT_ADAPTER_SCOPE: dict[str, object] = {
"source_roots": list(DIRECT_ADAPTER_SOURCE_ROOTS),
"target_root": DIRECT_ADAPTER_TARGET_ROOT,
"runtime_only": True,
"parent_package_expansion": False,
"imported_symbols": False,
}
FROZEN_DIRECT_ADAPTER_IMPORTS = {
("app.application.backup", "app.adapters.system.backup.files"): "S2-L5",
("app.application.directory", "app.adapters.system.host"): "S2-L6",
("app.application.image", "app.adapters.network.http"): "S2-L6",
("app.application.image", "app.adapters.network.ip"): "S2-L6",
("app.application.messaging.ingress", "app.adapters.network.http"): "S2-L6",
("app.application.rss", "app.adapters.network.browser"): "S2-L6",
("app.application.rss", "app.adapters.network.http"): "S2-L6",
("app.application.rss", "app.adapters.system"): "S2-L6",
("app.application.rules", "app.adapters.system"): "S2-L6",
("app.application.security.cookie", "app.adapters.external.ocr"): "S2-L6",
("app.application.security.cookie", "app.adapters.network.browser"): "S2-L6",
("app.application.security.cookie", "app.adapters.network.http"): "S2-L6",
("app.application.security.passkey", "app.adapters.cache.redis"): "S2-L4",
("app.application.torrent", "app.adapters.network.http"): "S2-L6",
("app.application.transfer", "app.adapters.system.host"): "S2-L6",
("app.chain._recognition", "app.adapters.external.server"): "S2-L7",
("app.chain._transfer", "app.adapters.system.host"): "S2-L7",
("app.chain.download", "app.adapters.network.http"): "S2-L7",
("app.chain.download", "app.adapters.system.host"): "S2-L7",
("app.chain.message", "app.adapters.network.http"): "S2-L7",
("app.chain.scraping", "app.adapters.network.http"): "S2-L7",
("app.chain.site", "app.adapters.external.cookiecloud"): "S2-L7",
("app.chain.site", "app.adapters.network.browser"): "S2-L7",
("app.chain.site", "app.adapters.network.cloudflare"): "S2-L7",
("app.chain.site", "app.adapters.network.http"): "S2-L7",
("app.chain.subscribe", "app.adapters.external.server"): "S2-L7",
("app.chain.system", "app.adapters.network.http"): "S2-L7",
("app.chain.system", "app.adapters.system.host"): "S2-L7",
}
@@ -46,6 +61,57 @@ def _source(tmp_path: Path, name: str, content: str) -> tuple[str, Path]:
return name, path
def _adapter_policy_violations(
actual: list[dict[str, str]],
reviewed: list[dict[str, str]],
) -> tuple[list[tuple[str, str]], list[tuple[str, str]]]:
"""返回新增直连与已经消失但未清理的陈旧 policy。"""
actual_edges = {(edge["source"], edge["target"]) for edge in actual}
reviewed_edges = {(edge["source"], edge["target"]) for edge in reviewed}
return sorted(actual_edges - reviewed_edges), sorted(reviewed_edges - actual_edges)
def _is_module_or_child(module_name: str, root: str) -> bool:
"""判断模块是否等于指定根或位于其点分子树内。"""
return module_name == root or module_name.startswith(f"{root}.")
def _adapter_policy_entry_errors(entries: list[dict[str, str]]) -> list[str]:
"""校验 policy 条目结构、范围、唯一性和冻结 owner。"""
errors: list[str] = []
seen: set[tuple[str, str]] = set()
for index, entry in enumerate(entries):
if set(entry) != {"source", "target", "tracking"}:
errors.append(f"entry[{index}]: fields")
continue
source = entry["source"]
target = entry["target"]
edge = (source, target)
if edge in seen:
errors.append(f"entry[{index}]: duplicate")
seen.add(edge)
if not any(
_is_module_or_child(source, root)
for root in DIRECT_ADAPTER_SOURCE_ROOTS
):
errors.append(f"entry[{index}]: source scope")
if not _is_module_or_child(target, DIRECT_ADAPTER_TARGET_ROOT):
errors.append(f"entry[{index}]: target scope")
if FROZEN_DIRECT_ADAPTER_IMPORTS.get(edge) != entry["tracking"]:
errors.append(f"entry[{index}]: frozen edge or tracking")
return errors
def _adapter_policy_scope_errors(
policy_scope: dict[str, object],
fact_scope: dict[str, object],
) -> list[str]:
"""要求人工 policy 与事实收集器共同锁定同一精确范围。"""
if policy_scope == fact_scope == DIRECT_ADAPTER_SCOPE:
return []
return ["direct adapter scope drift"]
def test_direct_adapter_collector_preserves_raw_runtime_imports(
tmp_path: Path,
) -> None:
@@ -155,19 +221,145 @@ def test_direct_adapter_collector_handles_relative_imports_and_scope(
def test_current_direct_adapter_imports_are_stable_generated_facts() -> None:
"""当前 28 条直连必须完整进入生成事实,且不保存符号或行号。"""
"""生成事实必须有稳定排序、自洽统计,且不保存符号或行号。"""
contract = collect_dependency_baseline()["direct_adapter_imports"]
edges = {
(edge["source"], edge["target"])
for edge in contract["edges"]
edges = contract["edges"]
sources = sorted({edge["source"] for edge in edges})
targets = sorted({edge["target"] for edge in edges})
counts_by_root = Counter(
root
for edge in edges
for root in DIRECT_ADAPTER_SOURCE_ROOTS
if _is_module_or_child(edge["source"], root)
)
expected_counts = {
root: counts_by_root[root]
for root in DIRECT_ADAPTER_SOURCE_ROOTS
}
assert contract["count"] == 28
assert contract["counts_by_source_root"] == {
"app.application": 15,
"app.chain": 13,
}
assert contract["source_count"] == 18
assert contract["target_count"] == 11
assert edges == EXPECTED_DIRECT_ADAPTER_IMPORTS
assert edges == sorted(edges, key=lambda edge: (edge["source"], edge["target"]))
assert contract["scope"] == DIRECT_ADAPTER_SCOPE
assert contract["count"] == len(edges)
assert contract["counts_by_source_root"] == expected_counts
assert contract["sources"] == sources
assert contract["source_count"] == len(sources)
assert contract["targets"] == targets
assert contract["target_count"] == len(targets)
assert all(set(edge) == {"source", "target"} for edge in contract["edges"])
def test_current_direct_adapter_imports_match_temporary_debt_policy() -> None:
"""现存直连必须逐条绑定冻结 owner,并允许债务集合只减不增。"""
policy = json.loads(DEPENDENCY_POLICY_PATH.read_text(encoding="utf-8"))
contract = collect_dependency_baseline()["direct_adapter_imports"]
adapter_policy = policy["direct_adapter_imports"]
entries = adapter_policy["entries"]
assert policy["schema_version"] == 2
assert adapter_policy["classification"] == "temporary_debt"
assert adapter_policy["target_state"] == "empty"
assert _adapter_policy_scope_errors(adapter_policy["scope"], contract["scope"]) == []
assert entries == sorted(entries, key=lambda item: (item["source"], item["target"]))
assert Counter(FROZEN_DIRECT_ADAPTER_IMPORTS.values()) == {
"S2-L4": 1,
"S2-L5": 1,
"S2-L6": 13,
"S2-L7": 13,
}
assert _adapter_policy_entry_errors(entries) == []
unreviewed, stale = _adapter_policy_violations(contract["edges"], entries)
assert unreviewed == []
assert stale == []
def test_adapter_policy_rejects_add_remove_and_replacement() -> None:
"""新增、删除后未清 policy、以及换成另一条边都不能静默通过。"""
reviewed = [
{
"source": "app.application.security.passkey",
"target": "app.adapters.cache.redis",
"tracking": "S2-L4",
}
]
original = [
{
"source": "app.application.security.passkey",
"target": "app.adapters.cache.redis",
}
]
added = [
*original,
{
"source": "app.chain.sample",
"target": "app.adapters.system.host",
},
]
replacement = [
{
"source": "app.application.sample",
"target": "app.adapters.network.browser",
}
]
assert _adapter_policy_violations(original, reviewed) == ([], [])
assert _adapter_policy_violations(added, reviewed) == (
[("app.chain.sample", "app.adapters.system.host")],
[],
)
assert _adapter_policy_violations([], reviewed) == (
[],
[("app.application.security.passkey", "app.adapters.cache.redis")],
)
assert _adapter_policy_violations(replacement, reviewed) == (
[("app.application.sample", "app.adapters.network.browser")],
[("app.application.security.passkey", "app.adapters.cache.redis")],
)
assert _adapter_policy_violations([], []) == ([], [])
assert _adapter_policy_entry_errors([]) == []
def test_adapter_policy_rejects_manual_policy_bypasses() -> None:
"""手工 policy 也不能接纳新边、错 owner、重复项或越界范围。"""
valid = {
"source": "app.application.security.passkey",
"target": "app.adapters.cache.redis",
"tracking": "S2-L4",
}
invalid_entries = [
{
"source": "app.application.sample",
"target": "app.adapters.network.http",
"tracking": "S2-L6",
},
{
"source": valid["source"],
"target": "app.adapters.network.browser",
"tracking": "S2-L4",
},
{**valid, "tracking": "S2-L5"},
{
"source": "app.api.sample",
"target": valid["target"],
"tracking": "S2-L4",
},
{
"source": valid["source"],
"target": "app.db.adapters.subscription",
"tracking": "S2-L4",
},
{
"source": "app.application.*",
"target": "app.adapters.*",
"tracking": "S2-L6",
},
]
assert _adapter_policy_entry_errors([valid]) == []
assert all(_adapter_policy_entry_errors([entry]) for entry in invalid_entries)
assert any("duplicate" in error for error in _adapter_policy_entry_errors([valid, valid]))
drifted_scope = {**DIRECT_ADAPTER_SCOPE, "runtime_only": False}
assert _adapter_policy_scope_errors(drifted_scope, DIRECT_ADAPTER_SCOPE) == [
"direct adapter scope drift"
]
+1
View File
@@ -31,6 +31,7 @@ def test_unit_test_workflow_has_independent_host_architecture_gate():
assert "pull_request" in workflow["on"]
assert "push" in workflow["on"]
assert "tests/test_architecture_dependencies.py" in commands
assert "tests/test_architecture_adapter_imports.py" in commands
assert "tests/test_architecture_contract_baseline.py" in commands
assert "scripts/architecture/baseline.py --check-host" in commands
assert "scripts/architecture/ruff_ratchet.py" in commands
+7 -6
View File
@@ -70,13 +70,14 @@ def test_dependency_baseline_records_nonempty_host_graph() -> None:
assert baseline["module_count"] > 0
assert baseline["edge_count"] > 0
direct_imports = baseline["direct_adapter_imports"]
assert direct_imports["count"] == len(direct_imports["edges"]) == 28
assert direct_imports["counts_by_source_root"] == {
"app.application": 15,
"app.chain": 13,
assert direct_imports["count"] == len(direct_imports["edges"])
assert sum(direct_imports["counts_by_source_root"].values()) == direct_imports["count"]
assert set(direct_imports["counts_by_source_root"]) <= {
"app.application",
"app.chain",
}
assert direct_imports["source_count"] == len(direct_imports["sources"]) == 18
assert direct_imports["target_count"] == len(direct_imports["targets"]) == 11
assert direct_imports["source_count"] == len(direct_imports["sources"])
assert direct_imports["target_count"] == len(direct_imports["targets"])
def test_official_discovery_plugins_explicitly_keep_host_page_envelope():
+1 -1
View File
@@ -1087,7 +1087,7 @@ def test_complete_host_sccs_match_reviewed_policy() -> None:
policy = _load_dependency_policy()
entries = policy["allowed_sccs"]
assert policy["schema_version"] == 1
assert policy["schema_version"] == 2
assert policy["scope"] == {
"dependency_kind": "static_runtime_imports",
"excluded_roots": ["app/plugins"],