diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 13c7000d6..bca108f3d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 \ diff --git a/AGENTS.md b/AGENTS.md index 9965933f2..fffb0799e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index a93d61338..ad7c098e7 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -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;业务层拥有 Port,startup 注入实现。 - [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 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 152db6299..d96e266d7 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -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` 与上述架构测试。 - 延迟导入不被接受为隐藏循环依赖的手段。 diff --git a/docs/architecture-refactor-roadmap.md b/docs/architecture-refactor-roadmap.md index d50e9ee04..5846444d0 100644 --- a/docs/architecture-refactor-roadmap.md +++ b/docs/architecture-refactor-roadmap.md @@ -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`。 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 944a48fff..5f2d16065 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -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 | diff --git a/docs/rules/09-external-response.md b/docs/rules/09-external-response.md index 0ffe42a9a..ec68e9089 100644 --- a/docs/rules/09-external-response.md +++ b/docs/rules/09-external-response.md @@ -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( diff --git a/tests/fixtures/architecture/dependency-policy.json b/tests/fixtures/architecture/dependency-policy.json index 333acfa9f..99f6b886a 100644 --- a/tests/fixtures/architecture/dependency-policy.json +++ b/tests/fixtures/architecture/dependency-policy.json @@ -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" + } } diff --git a/tests/test_architecture_adapter_imports.py b/tests/test_architecture_adapter_imports.py index 512b1cab5..6ae594e38 100644 --- a/tests/test_architecture_adapter_imports.py +++ b/tests/test_architecture_adapter_imports.py @@ -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" + ] diff --git a/tests/test_architecture_ci.py b/tests/test_architecture_ci.py index ae2ffc17c..f54ecb195 100644 --- a/tests/test_architecture_ci.py +++ b/tests/test_architecture_ci.py @@ -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 diff --git a/tests/test_architecture_contract_baseline.py b/tests/test_architecture_contract_baseline.py index a486c65d9..915dd858c 100644 --- a/tests/test_architecture_contract_baseline.py +++ b/tests/test_architecture_contract_baseline.py @@ -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(): diff --git a/tests/test_architecture_dependencies.py b/tests/test_architecture_dependencies.py index 65089a07e..c09430d14 100644 --- a/tests/test_architecture_dependencies.py +++ b/tests/test_architecture_dependencies.py @@ -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"],