mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-08-28 19:47:41 +08:00
test(architecture): freeze direct adapter debt
This commit is contained in:
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 与上述架构测试。
|
||||
- 延迟导入不被接受为隐藏循环依赖的手段。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user