From 47f0de745c7a9acd2abae252f116bb16fa08de5d Mon Sep 17 00:00:00 2001 From: jxxghp Date: Thu, 27 Aug 2026 07:28:42 +0800 Subject: [PATCH] test(architecture): govern direct egress --- .github/workflows/test.yml | 1 + AGENTS.md | 2 +- docs/architecture-optimization-checklist.md | 10 +- docs/architecture-overview.md | 6 + docs/architecture-refactor-roadmap.md | 49 +- docs/rules/05-architecture.md | 19 + docs/rules/09-external-response.md | 14 +- scripts/architecture/baseline.py | 9 +- scripts/architecture/egress.py | 1053 ++++++++++++++ .../architecture/dependency-baseline.json | 1264 ++++++++++++++++- .../architecture/dependency-policy.json | 209 ++- .../fixtures/architecture/mypy-baseline.json | 3 +- tests/test_architecture_adapter_imports.py | 2 +- tests/test_architecture_baseline_cli.py | 24 + tests/test_architecture_ci.py | 1 + tests/test_architecture_contract_baseline.py | 15 +- tests/test_architecture_dependencies.py | 2 +- tests/test_architecture_egress.py | 925 ++++++++++++ tests/test_plugin_identity_transitions.py | 28 + 19 files changed, 3598 insertions(+), 38 deletions(-) create mode 100644 scripts/architecture/egress.py create mode 100644 tests/test_architecture_egress.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bca108f3d..343a5a425 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -46,6 +46,7 @@ jobs: uv run --locked --no-sync pytest \ tests/test_architecture_dependencies.py \ tests/test_architecture_adapter_imports.py \ + tests/test_architecture_egress.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 fffb0799e..ce72eb44a 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:** 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. +* **Required Constraints:** Host-authored ordinary HTTP must go through `RequestUtils`; this rule does not authorize Application/Chain to import the concrete Adapter. Canonical transport, SDK, streaming protocol, contained vendor, diagnostic and control-plane exceptions must match the exact direct-egress policy. 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` diff --git a/docs/architecture-optimization-checklist.md b/docs/architecture-optimization-checklist.md index ad7c098e7..033ab7a20 100644 --- a/docs/architecture-optimization-checklist.md +++ b/docs/architecture-optimization-checklist.md @@ -415,15 +415,19 @@ MoviePilot V3 已经形成较清晰的模块化单体:`foundation`、`domain` - `app/chain` 有 8 个文件、13 条直接 Adapter 导入,使用 `RequestUtils`、Browser、Cloudflare、 CookieCloud、ServerHelper 等具体能力。 - Passkey Application 服务直接判断 Redis 后端并调用 `RedisHelper.pop()`,安全策略识别了具体实现。 -- “所有 HTTP 必须经 RequestUtils”与 LLM streaming、第三方 SDK、移植库中的直接 HTTP 客户端现状 - 没有一份精确例外表,也没有 zero-growth gate。 +- 审计时 LLM streaming、第三方 SDK、移植库和本地控制面没有精确例外表;S0-L2.4b 已建立 + 66 条完整 egress identity 与 zero-growth policy,其中 11 条普通 HTTP/Session bridge 和 1 条 + Application DNS I/O 是清零债务;每条初始边另有独立指纹上界,不能靠同时刷新 baseline/policy + 掩盖同一边的调用面增长,债务删除后也不得恢复。 **目标与步骤** - [x] 建立 Application/Chain 原始 Adapter 直连事实与精确临时 policy,冻结新增、替换和陈旧条目。 +- [x] 建立全宿主 direct egress 事实;SDK/stream/vendor/local-control 例外精确到 bindings/uses 指纹。 - [ ] 将 Passkey 原子领取提升为 runtime cache contract,由 Memory/Redis backend 分别实现。 - [ ] 为 Backup 定义 Application-owned artifact store Port,由 startup 注入文件系统实现。 -- [ ] 普通外部请求迁移到统一网络能力;SDK transport、streaming 和 vendor code 例外必须精确到路径与原因。 +- [ ] 将 policy 中 11 条普通 HTTP/Session bridge 债务迁移到统一网络能力并把目标收缩为空。 +- [ ] 为 Application SSRF 校验注入 DNS 解析 Port,清除 `socket.getaddrinfo` 直接 I/O。 - [ ] 命名外部产品、安全敏感能力及通用技术 Adapter 均改为注入 Port;不在 Application/Chain 保留直连例外。 - [ ] 最终把基线收缩到零或少量书面化例外,而不是一次性禁止后再大量豁免。 diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index d96e266d7..57b934043 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -673,6 +673,11 @@ flowchart LR `--write-host` 不会替代人工决策。 - 同一 baseline 的 `direct_adapter_imports` 记录现存原始直连;policy 将其全部标为有 owner 的 `temporary_debt`,并以初始 28 条冻结上界、目标为空集合。新增、替换、删除后未清理 policy 都会失败。 +- `direct_egress` 记录全宿主 66 条 raw transport、network SDK 和协议操作 identity;11 条普通 HTTP/ + Session bridge 与 1 条 Application DNS I/O 是清零债务,54 条 canonical transport、SDK、 + stream/vendor/diagnostic/control-plane 事实是精确 containment。每条初始边的指纹由测试独立冻结, + bindings/uses 变化、分类互换、通配导入和初始边增长都会失败;债务删除时同步删除冻结项以禁止恢复, + `--write-host` 不会改写人工 policy 或冻结上界。 - 任何所有权迁移必须同步更新:canonical 导入、`app/runtime/compat/manifest.py`、 SDK 导出(若公开)、`docs/rules/05-architecture.md` 与上述架构测试。 - 延迟导入不被接受为隐藏循环依赖的手段。 @@ -687,6 +692,7 @@ flowchart LR | Python 模块 | 835 | | 内部导入边 | 6,810 | | 非平凡 SCC | 2(`ARCH-107` 临时 Chain 包根环;精确 containment 的 TMDB 移植包环) | +| Direct egress | 66(12 条待迁移债务,54 条精确 containment) | | Module Contract V2 spec | 215(其中 214 个进入 `run_module` 观察面) | | Event Contract | 53 | | Model/Oper 自动事务与自建 Session | 0 | diff --git a/docs/architecture-refactor-roadmap.md b/docs/architecture-refactor-roadmap.md index 5846444d0..9ccec73f9 100644 --- a/docs/architecture-refactor-roadmap.md +++ b/docs/architecture-refactor-roadmap.md @@ -76,8 +76,8 @@ G-ARCH 只有在以下条件全部满足后才可完成: | 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 直连事实 | `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.4 Adapter zero-growth | `DELIVERED` | S0-L2.3 | `2553226f3`:冻结 28 条直连及 owner,收缩/新增/stale policy 门禁生效,远端 `0/0` | +| S0-L2.4b HTTP/Egress 事实与政策 | `VERIFIED` | S0-L2.4 | 66 条事实、12 条债务和 54 条精确例外已冻结;collector、policy、全量测试与本地质量门禁通过,待推送后确认远端 CI | | S0-L2.5 Event consumer 识别 | `PLANNED` | S0-L2.1 | consumer 只识别可静态证明的 EventManager 注册,动态误报归零 | | S0-L2.6 事实源与 CI 投影 | `PLANNED` | S0-L2.2,S0-L2.4b,S0-L2.5 | fixture/policy/overview 职责固定,CI 分开报告语义 policy 与快照一致性 | @@ -107,8 +107,8 @@ G-ARCH 只有在以下条件全部满足后才可完成: | S2-L3 GlobalVar/provider 注册收口 | `PLANNED` | S2-L1 | `global_vars` canonical 消费清零,provider 注册进入显式装配阶段并可 reset;Legacy 入口精确保留 | | S2-L4 Passkey 缓存边界 | `PLANNED` | S0-L4 | Application 不识别 Redis;原子 consume 由 runtime cache contract + backend 实现 | | S2-L5 Backup artifact Port | `PLANNED` | S0-L4 | Application 不构造 `BackupFiles`,文件 I/O 由注入 Adapter 拥有 | -| S2-L6 Application Adapter 债务清零 | `PLANNED` | S2-L4,S2-L5 | Application 到具体 Adapter 的未批准边归零,批准通用机制有精确规则和门禁 | -| S2-L7 Chain Adapter/HTTP 债务清零 | `PLANNED` | S2-L6 | Chain 具体 Adapter 与普通 direct HTTP 依赖归零;SDK/stream/vendor 例外精确 containment | +| S2-L6 Application Adapter/DNS 债务清零 | `PLANNED` | S2-L4,S2-L5 | Application 到具体 Adapter 的未批准边归零,SSRF DNS I/O 进入注入 Port,批准通用机制有精确规则和门禁 | +| S2-L7 Chain Adapter/宿主 HTTP 债务清零 | `PLANNED` | S2-L6 | Chain 具体 Adapter 与 11 条普通 direct HTTP/Session bridge 归零;SDK/stream/vendor 例外保持精确 containment | ### S3:大型编排器职责清零 @@ -154,50 +154,53 @@ G-ARCH 只有在以下条件全部满足后才可完成: ## 4. 当前活动叶子 -### S0-L2.4 Adapter zero-growth +### S0-L2.4b HTTP/Egress 事实与政策 -**Status:** `VERIFIED`(本地验收完成,等待提交、推送和远端一致性确认) +**Status:** `VERIFIED`(本地验收完成,等待提交、推送和远端 CI 确认) **Outcome** -把 S0-L2.3 收集的 Application/Chain 到具体 Adapter 直连全部登记为有迁移 owner 的临时债务。 -初始 28 条形成只减不增的冻结上界,政策目标固定为空集合;新增、替换和删除后未同步清理的 -陈旧登记都必须失败。 +扫描宿主 raw transport、network SDK 和库名扫描会漏掉的协议操作,事实保留 import provenance、 +稳定 callable/operation,不保存行号。普通 HTTP/Session bridge 是待迁移债务;canonical transport、 +SDK、streaming、contained vendor 和 local control-plane 只允许精确 containment。 **Ownership** -- `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 投影。 +- `scripts/architecture/` 的 direct egress AST collector 与 registry。 +- `tests/fixtures/architecture/dependency-baseline.json` 的生成事实和 + `dependency-policy.json` 的人工分类。 +- `tests/test_architecture_egress.py` 的 collector、当前事实、policy 和 zero-growth 门禁。 +- HTTP 规范、架构总览、优化清单与快速架构 CI 投影。 - 本路线图的叶子状态和交付记录。 **Excluded** -- 不在本叶迁移 28 条运行时依赖;迁移由 S2-L4 至 S2-L7 按 owner 债务清零。 -- 不修改生成事实、完整依赖图、SCC、digest、`app/plugins/**` 或运行时代码。 -- 不把 direct HTTP/SDK/stream/vendor egress 混入 Adapter policy;这些由 S0-L2.4b 独立治理。 +- 不在本叶迁移生产 HTTP 调用;本叶完成完整事实、分类和不可增长门禁。 +- 不修改 Adapter 直连 policy、完整依赖图/SCC/digest、运行时代码或 `app/plugins/**`。 +- 未登记 registry 的任意第三方包不能被猜测为网络 SDK;新增 SDK 必须显式扩展 registry 与 policy。 **Acceptance** ```bash .venv/bin/python -m pytest \ - tests/test_architecture_adapter_imports.py \ + tests/test_architecture_egress.py \ tests/test_architecture_contract_baseline.py \ - tests/test_architecture_dependencies.py \ + tests/test_architecture_baseline_cli.py \ tests/test_architecture_ci.py \ - tests/test_architecture_documentation.py -q + tests/test_plugin_identity_transitions.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 tests/test_architecture_adapter_imports.py \ +.venv/bin/pylint scripts/architecture/baseline.py \ + scripts/architecture/egress.py \ + tests/test_architecture_egress.py \ tests/test_architecture_contract_baseline.py \ - tests/test_architecture_dependencies.py \ - tests/test_architecture_ci.py + tests/test_architecture_ci.py \ + tests/test_plugin_identity_transitions.py git diff --check ``` **Delivery** -- 单一提交主题:冻结现有 Adapter 直连债务并阻止增长。 +- 单一提交主题:建立并冻结 direct egress 事实与精确政策。 - 推送 `origin/v3` 后确认提交祖先关系、远端 SHA 和 ahead/behind `0/0`。 diff --git a/docs/rules/05-architecture.md b/docs/rules/05-architecture.md index 5f2d16065..f403dec8d 100644 --- a/docs/rules/05-architecture.md +++ b/docs/rules/05-architecture.md @@ -528,6 +528,25 @@ the Port required by its use case, startup injects the concrete Adapter, and Cha consumes the Application capability or an injected Port. A `canonical capability` never means permission to import a concrete `app.adapters.*` implementation. +Direct egress is a separate boundary from Adapter imports. The generated +`direct_egress` facts scan the complete host `app` tree except `app.plugins` and +record raw transports, registered network SDKs and exact protocol operations. +Each identity contains import provenance plus stable callable/operation uses and +has no line number. The manual policy classifies every full fingerprint as either +`temporary_debt` with a removal leaf and empty target state, or an +`approved_exception` with an exact owner and reason. Canonical transports, SDKs, +streaming protocols, contained vendor code, diagnostics and control planes are +contained exceptions, not category-wide permissions. In policy, `owner: "$source"` +means the fact's exact `source` module is the owner; it does not authorize sibling +or child modules. Runtime wildcard imports from a registered egress root are +forbidden. Updating the generated baseline never updates this policy; additions, +fact changes, classification swaps and stale entries fail independently. Current +debt may shrink without changing a fixed count, but no initial edge may grow or be +reclassified. Tests independently freeze every initial edge fingerprint, so +refreshing both generated facts and manual policy cannot hide growth on the same +`source/target`; when debt is removed, its frozen edge and fingerprint must be +removed in the same reviewed change so that it cannot return. + ## Permitted Call Directions | Direction | Status | diff --git a/docs/rules/09-external-response.md b/docs/rules/09-external-response.md index ec68e9089..bedc5e3dc 100644 --- a/docs/rules/09-external-response.md +++ b/docs/rules/09-external-response.md @@ -2,11 +2,19 @@ ## HTTP Client Conventions -**Rule:** Host outbound HTTP transport implementations must go through `RequestUtils` from +**Rule:** Host-authored ordinary outbound HTTP 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. +Plugins import the curated facade from `app.sdk.network`. + +Direct `requests`/`httpx` clients, SDK transports, product streaming protocols, contained vendor +code, diagnostics and control-plane access are governed by the exact `direct_egress` facts and +manual policy. An approved fingerprint is containment for that exact source/binding/use identity +only; `owner: "$source"` names that source module and does not authorize another caller or +operation. New SDK roots must be added to the registry and receive explicit policy review. +Ordinary direct HTTP, RequestUtils Session bridges and Application-owned DNS I/O remain +`temporary_debt` with an empty target state. Runtime wildcard imports from registered egress roots +are forbidden. `RequestUtils` handles: - Proxy configuration (from `settings.PROXY_*`) diff --git a/scripts/architecture/baseline.py b/scripts/architecture/baseline.py index 9af5e11a1..33d386ee8 100644 --- a/scripts/architecture/baseline.py +++ b/scripts/architecture/baseline.py @@ -13,10 +13,16 @@ from collections import defaultdict from pathlib import Path from typing import Any, Optional +try: + from scripts.architecture.egress import collect_direct_egress +except ModuleNotFoundError: + from egress import collect_direct_egress + PROJECT_ROOT = Path(__file__).resolve().parents[2] APP_ROOT = PROJECT_ROOT / "app" BASELINE_ROOT = PROJECT_ROOT / "tests" / "fixtures" / "architecture" DEPENDENCY_BASELINE_PATH = BASELINE_ROOT / "dependency-baseline.json" +DEPENDENCY_POLICY_PATH = BASELINE_ROOT / "dependency-policy.json" RUNTIME_BASELINE_PATH = BASELINE_ROOT / "runtime-contract-baseline.json" TRANSACTION_BASELINE_PATH = BASELINE_ROOT / "transaction-debt-baseline.json" CONFIGURATION_BASELINE_PATH = BASELINE_ROOT / "configuration-debt-baseline.json" @@ -440,7 +446,7 @@ def collect_dependency_baseline() -> dict[str, Any]: {edge["target"] for edge in direct_adapter_imports} ) return { - "schema_version": 2, + "schema_version": 3, "scope": "MoviePilot host app excluding app/plugins", "module_count": len(modules), "edge_count": len(edges), @@ -473,6 +479,7 @@ def collect_dependency_baseline() -> dict[str, Any]: "targets": direct_adapter_targets, "edges": direct_adapter_imports, }, + "direct_egress": collect_direct_egress(modules), "boundary_edges": collect_boundary_edges(graph, modules), } diff --git a/scripts/architecture/egress.py b/scripts/architecture/egress.py new file mode 100644 index 000000000..e0de1330c --- /dev/null +++ b/scripts/architecture/egress.py @@ -0,0 +1,1053 @@ +"""收集宿主直接网络传输、网络 SDK 与底层协议操作事实。""" + +from __future__ import annotations + +import ast +import hashlib +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +RAW_TRANSPORT_ROOTS = ( + "requests", + "httpx", + "httpx2", + "aiohttp", + "urllib.request", + "urllib3", + "http.client", + "websocket", + "websockets", + "curl_cffi", + "cloudscraper", + "httplib2", + "pycurl", +) +NETWORK_SDK_ROOTS = ( + "boto3", + "botocore", + "cloakbrowser", + "ddgs", + "discord", + "docker", + "google.genai", + "langchain_anthropic", + "langchain_aws", + "langchain_deepseek", + "langchain_google_genai", + "langchain_openai", + "lark_oapi", + "openai", + "oss2", + "plexapi", + "pywebpush", + "qbittorrentapi", + "redis", + "slack_bolt", + "slack_sdk", + "smbclient", + "smbprotocol", + "telebot", + "transmission_rpc", +) +PROTOCOL_OPERATIONS = { + "asyncio.open_connection", + "socket.create_connection", + "socket.getaddrinfo", + "socket.gethostbyname", + "xmlrpc.client.ServerProxy", +} +REEXPORTED_TARGETS = { + ("app.adapters.network.http", "requests"): "requests", +} + + +@dataclass(frozen=True) +class _SymbolBinding: + """记录表达式符号对应的导入来源与网络能力。""" + + canonical: str + target: str | None + binding: str + + +_SymbolChoices = tuple[_SymbolBinding, ...] + + +def _unique_bindings(bindings: Iterable[_SymbolBinding]) -> _SymbolChoices: + """把多个可证明来源去重并稳定排序。""" + return tuple( + sorted( + set(bindings), + key=lambda item: (item.target or "", item.canonical, item.binding), + ) + ) + + +def _is_type_checking_test(test: ast.expr) -> bool: + """判断条件是否只在静态类型检查阶段成立。""" + return ( + isinstance(test, ast.Name) + and test.id == "TYPE_CHECKING" + ) or ( + isinstance(test, ast.Attribute) + and isinstance(test.value, ast.Name) + and test.value.id == "typing" + and test.attr == "TYPE_CHECKING" + ) + + +def _runtime_import_nodes(tree: ast.AST) -> Iterable[ast.Import | ast.ImportFrom]: + """遍历运行期导入并排除 TYPE_CHECKING 分支。""" + parents: dict[ast.AST, ast.AST] = {} + for parent in ast.walk(tree): + for child in ast.iter_child_nodes(parent): + parents[child] = parent + for node in ast.walk(tree): + if not isinstance(node, (ast.Import, ast.ImportFrom)): + continue + child: ast.AST = node + parent = parents.get(child) + while parent is not None: + if ( + isinstance(parent, ast.If) + and _is_type_checking_test(parent.test) + and child in parent.body + ): + break + child = parent + parent = parents.get(parent) + else: + yield node + + +def _registered_target(imported: str) -> tuple[str, str] | None: + """把导入模块归一到最长的 raw transport 或 network SDK 根。""" + candidates = sorted( + (*RAW_TRANSPORT_ROOTS, *NETWORK_SDK_ROOTS), + key=len, + reverse=True, + ) + for target in candidates: + if imported == target or imported.startswith(f"{target}."): + kind = "raw_transport" if target in RAW_TRANSPORT_ROOTS else "network_sdk" + return target, kind + return None + + +def _attribute_parts(node: ast.AST) -> tuple[str, ...] | None: + """把 Name/Attribute 表达式拆成稳定的点分片段。""" + if isinstance(node, ast.Name): + return (node.id,) + if not isinstance(node, ast.Attribute): + return None + prefix = _attribute_parts(node.value) + if prefix is None: + return None + return (*prefix, node.attr) + + +def _bound_name(node: ast.AST) -> tuple[str, ...] | None: + """返回可进行有限传播的 Name 或 self/cls 属性名。""" + parts = _attribute_parts(node) + if not parts: + return None + if len(parts) == 1 or parts[0] in {"self", "cls"}: + return parts + return None + + +def _fact_fingerprint(fact: dict[str, object]) -> str: + """为不含行号的完整事实生成稳定指纹。""" + payload = json.dumps(fact, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _returns_network_capability(target: str, kind: str, operation: str) -> bool: + """判断调用结果是否仍是应继续追踪的 client/capability。""" + name = operation.rsplit(".", maxsplit=1)[-1] + if kind == "raw_transport": + return name in { + "AsyncClient", + "Client", + "ClientSession", + "Session", + "WebSocketApp", + "__call__", + } + return ( + name[:1].isupper() + or name in {"client", "connect", "register_session", "resource"} + or target in {"smbclient"} + ) + + +class _EgressVisitor(ast.NodeVisitor): + """根据 import provenance 收集稳定的网络能力使用点。""" + + def __init__( + self, + source: str, + import_records: dict[ast.AST, dict[tuple[str, ...], _SymbolChoices]], + facts: dict[tuple[str, str, str], dict[str, object]], + *, + function_injections: dict[ + ast.FunctionDef | ast.AsyncFunctionDef, + dict[str, _SymbolChoices], + ] | None = None, + module_final_scope: dict[ + tuple[str, ...], _SymbolChoices | None + ] | None = None, + ) -> None: + """初始化单模块 visitor,并复用预收集的导入符号。""" + self._source = source + self._import_records = import_records + self._facts = facts + self._qualnames: list[str] = [] + self._scopes: list[dict[tuple[str, ...], _SymbolChoices | None]] = [{}] + self._scope_kinds = ["module"] + self._call_results: dict[ast.Call, _SymbolChoices | None] = {} + self._function_injections = { + function: dict(injections) + for function, injections in (function_injections or {}).items() + } + self._functions: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {} + self._module_final_scope = ( + dict(module_final_scope) if module_final_scope is not None else None + ) + self._collecting_class_bindings = False + + def _qualname(self) -> str: + """返回当前调用所在的稳定限定名。""" + return ".".join(self._qualnames) or "" + + def _fact(self, target: str, kind: str) -> dict[str, object]: + """取得或创建当前 source/capability 的聚合事实。""" + key = (self._source, target, kind) + return self._facts.setdefault( + key, + { + "source": self._source, + "target": target, + "kind": kind, + "bindings": set(), + "uses": [], + }, + ) + + def _resolve(self, node: ast.AST) -> tuple[_SymbolChoices, tuple[str, ...]] | None: + """按最长符号前缀解析表达式及剩余操作片段。""" + parts = _attribute_parts(node) + if not parts: + return None + for scope in reversed(self._scopes): + for size in range(len(parts), 0, -1): + key = parts[:size] + if key not in scope: + continue + binding = scope[key] + if binding is None: + return None + return binding, parts[size:] + return None + + def _set_symbol( + self, + name: tuple[str, ...], + binding: _SymbolChoices | None, + ) -> None: + """写入当前 lexical scope;self/cls 属性写入最近类作用域。""" + if name[0] in {"self", "cls"}: + for index in range(len(self._scope_kinds) - 1, -1, -1): + if self._scope_kinds[index] == "class": + if self._collecting_class_bindings: + if binding is not None: + existing = self._scopes[index].get(name) or () + self._scopes[index][name] = _unique_bindings( + (*existing, *binding) + ) + return + self._scopes[index][name] = binding + return + self._scopes[-1][name] = binding + + def _push_scope(self, kind: str) -> None: + """压入一个 lexical symbol scope。""" + self._scopes.append({}) + self._scope_kinds.append(kind) + + def _pop_scope(self) -> None: + """弹出当前 lexical symbol scope。""" + self._scopes.pop() + self._scope_kinds.pop() + + def seed_module(self, tree: ast.Module) -> None: + """预发布模块级 import/alias,使函数分析不依赖定义先后。""" + parents: dict[ast.AST, ast.AST] = {} + for parent in ast.walk(tree): + for child in ast.iter_child_nodes(parent): + parents[child] = parent + + def is_module_level(node: ast.AST) -> bool: + parent = parents.get(node) + while parent is not None and parent is not tree: + if isinstance( + parent, + ( + ast.ClassDef, + ast.FunctionDef, + ast.AsyncFunctionDef, + ast.Lambda, + ast.ListComp, + ast.SetComp, + ast.DictComp, + ast.GeneratorExp, + ), + ): + return False + parent = parents.get(parent) + return True + + for import_node, symbols in self._import_records.items(): + if is_module_level(import_node): + self._scopes[0].update(symbols) + for node in ast.walk(tree): + if not is_module_level(node): + continue + if isinstance(node, ast.Assign): + targets = node.targets + value = node.value + elif isinstance(node, ast.AnnAssign) and node.value is not None: + targets = [node.target] + value = node.value + else: + continue + choices = self._static_bindings(value) + if not choices: + continue + for target_node in targets: + target = _bound_name(target_node) + if target and len(target) == 1: + self._scopes[0][target] = choices + self._functions = { + node.name: node + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + def function_injections(self) -> dict[ + ast.FunctionDef | ast.AsyncFunctionDef, + dict[str, _SymbolChoices], + ]: + """返回当前调用点已证明的顶层函数参数来源。""" + return { + function: dict(injections) + for function, injections in self._function_injections.items() + } + + def module_scope(self) -> dict[tuple[str, ...], _SymbolChoices | None]: + """返回按模块执行顺序收敛后的符号状态。""" + return dict(self._scopes[0]) + + def _record_function_injection(self, node: ast.Call) -> None: + """从调用方当前 scope 传播顶层包装函数的可证明参数来源。""" + if not isinstance(node.func, ast.Name): + return + function = self._functions.get(node.func.id) + if function is None: + return + positional = (*function.args.posonlyargs, *function.args.args) + arguments: list[tuple[str, ast.AST]] = [ + (parameter.arg, argument) + for parameter, argument in zip(positional, node.args) + ] + known_parameters = { + argument.arg + for argument in (*positional, *function.args.kwonlyargs) + } + arguments.extend( + (keyword.arg, keyword.value) + for keyword in node.keywords + if keyword.arg in known_parameters + ) + injections = self._function_injections.setdefault(function, {}) + for parameter, argument in arguments: + choices = self._static_bindings(argument) + if not choices: + continue + injections[parameter] = _unique_bindings( + (*injections.get(parameter, ()), *choices) + ) + + def _shadow_arguments(self, arguments: ast.arguments) -> None: + """让函数参数遮蔽同名外层 import 或别名。""" + all_arguments = ( + *arguments.posonlyargs, + *arguments.args, + *arguments.kwonlyargs, + ) + for argument in all_arguments: + if argument.arg in {"self", "cls"}: + continue + self._set_symbol( + (argument.arg,), + self._annotation_bindings(argument.annotation), + ) + if arguments.vararg: + self._set_symbol((arguments.vararg.arg,), None) + if arguments.kwarg: + self._set_symbol((arguments.kwarg.arg,), None) + + def _annotation_bindings(self, annotation: ast.AST | None) -> _SymbolChoices | None: + """从显式 Client/Session 参数注解恢复注入网络能力。""" + if annotation is None: + return None + bindings: list[_SymbolBinding] = [] + for candidate in ast.walk(annotation): + if not isinstance(candidate, (ast.Name, ast.Attribute)): + continue + resolved = self._resolve(candidate) + if not resolved: + continue + choices, remainder = resolved + for binding in choices: + canonical = ".".join( + part for part in (binding.canonical, *remainder) if part + ) + if canonical.rsplit(".", maxsplit=1)[-1] not in { + "AsyncClient", + "Client", + "ClientSession", + "Session", + }: + continue + bindings.append( + _SymbolBinding( + canonical=binding.target or canonical, + target=binding.target, + binding=binding.binding, + ) + ) + return _unique_bindings(bindings) or None + + def _static_bindings(self, value: ast.AST) -> _SymbolChoices | None: + """不记录 use,仅解析赋值表达式可能产生的网络能力集合。""" + if isinstance(value, ast.Call): + resolved = self._resolve(value.func) + elif isinstance(value, ast.IfExp): + return _unique_bindings( + binding + for branch in (value.body, value.orelse) + for binding in (self._static_bindings(branch) or ()) + ) or None + else: + resolved = self._resolve(value) + if not resolved: + return None + choices, remainder = resolved + if isinstance(value, ast.Call): + choices = tuple( + binding + for binding in choices + if binding.target is not None + and (registered := _registered_target(binding.target)) is not None + and _returns_network_capability( + registered[0], + registered[1], + ".".join( + part for part in (binding.canonical, *remainder) if part + ).removeprefix(registered[0]).lstrip(".") + or "__call__", + ) + ) + return _unique_bindings( + _SymbolBinding( + canonical=binding.target + or ".".join( + part for part in (binding.canonical, *remainder) if part + ), + target=binding.target, + binding=binding.binding, + ) + for binding in choices + if binding.target is not None + ) or None + + def _record_call(self, node: ast.Call) -> _SymbolChoices | None: + """记录已知能力调用或精确底层协议操作。""" + if node in self._call_results: + return self._call_results[node] + if ( + isinstance(node.func, ast.Name) + and node.func.id == "__import__" + and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + and (registered := _registered_target(node.args[0].value)) + ): + target, kind = registered + binding_text = f"dynamic:__import__({node.args[0].value})" + fact = self._fact(target, kind) + fact["bindings"].add(binding_text) # type: ignore[union-attr] + fact["uses"].append(f"{self._qualname()}|call:dynamic-import") # type: ignore[union-attr] + result = ( + _SymbolBinding( + canonical=target, + target=target, + binding=binding_text, + ), + ) + self._call_results[node] = result + return result + resolved = self._resolve(node.func) + if ( + resolved is None + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Call) + ): + if parent_bindings := self._record_call(node.func.value): + resolved = (parent_bindings, (node.func.attr,)) + if resolved is None: + self._call_results[node] = None + return None + bindings, remainder = resolved + results: list[_SymbolBinding] = [] + for binding in bindings: + canonical = ".".join( + part for part in (binding.canonical, *remainder) if part + ) + if ( + canonical == "importlib.import_module" + and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + and (registered := _registered_target(node.args[0].value)) + ): + target, kind = registered + binding_text = f"dynamic:importlib.import_module({node.args[0].value})" + fact = self._fact(target, kind) + fact["bindings"].add(binding_text) # type: ignore[union-attr] + fact["uses"].append(f"{self._qualname()}|call:dynamic-import") # type: ignore[union-attr] + results.append( + _SymbolBinding( + canonical=target, + target=target, + binding=binding_text, + ) + ) + continue + if canonical in PROTOCOL_OPERATIONS: + fact = self._fact(canonical, "protocol_operation") + fact["bindings"].add(binding.binding) # type: ignore[union-attr] + fact["uses"].append(f"{self._qualname()}|call:{canonical}") # type: ignore[union-attr] + results.append( + _SymbolBinding( + canonical=canonical, + target=canonical, + binding=binding.binding, + ) + ) + continue + if binding.target is None: + continue + registered = _registered_target(binding.target) + if registered is None: + continue + target, kind = registered + operation = canonical.removeprefix(target).lstrip(".") or "__call__" + fact = self._fact(target, kind) + fact["bindings"].add(binding.binding) # type: ignore[union-attr] + fact["uses"].append(f"{self._qualname()}|call:{operation}") # type: ignore[union-attr] + if _returns_network_capability(target, kind, operation): + results.append( + _SymbolBinding( + canonical=target, + target=target, + binding=binding.binding, + ) + ) + result = _unique_bindings(results) or None + self._call_results[node] = result + return result + + def _propagate(self, target_node: ast.AST, value: ast.AST) -> None: + """把简单别名或 Client/Session 获取结果传播到局部符号。""" + target_name = _bound_name(target_node) + if target_name is None: + return + source_bindings: _SymbolChoices | None = None + if isinstance(value, ast.Call): + source_bindings = self._record_call(value) + elif isinstance(value, ast.IfExp): + alternatives = [self._resolve(value.body), self._resolve(value.orelse)] + resolved_bindings = [binding for item in alternatives if item for binding in item[0]] + source_bindings = _unique_bindings( + _SymbolBinding( + canonical=binding.target or binding.canonical, + target=binding.target, + binding=binding.binding, + ) + for binding in resolved_bindings + ) or None + else: + resolved = self._resolve(value) + if resolved: + bindings, remainder = resolved + source_bindings = _unique_bindings( + _SymbolBinding( + canonical=".".join( + part for part in (binding.canonical, *remainder) if part + ), + target=binding.target, + binding=binding.binding, + ) + for binding in bindings + ) + self._set_symbol(target_name, source_bindings) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + """在类限定名下访问定义体。""" + for expression in (*node.decorator_list, *node.bases, *node.keywords): + self.visit(expression.value if isinstance(expression, ast.keyword) else expression) + self._qualnames.append(node.name) + self._push_scope("class") + self._prebind_class_attributes(node) + for statement in node.body: + self.visit(statement) + self._pop_scope() + self._qualnames.pop() + + def _prebind_class_attributes(self, node: ast.ClassDef) -> None: + """无副作用预分析各方法,累积 self/cls 可证明网络能力。""" + original_facts = self._facts + original_results = self._call_results + original_flag = self._collecting_class_bindings + self._facts = {} + self._call_results = {} + self._collecting_class_bindings = True + try: + for method in node.body: + if isinstance(method, (ast.FunctionDef, ast.AsyncFunctionDef)): + self.visit(method) + finally: + self._collecting_class_bindings = original_flag + self._call_results = original_results + self._facts = original_facts + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + """在函数限定名下访问定义体。""" + for expression in ( + *node.decorator_list, + *node.args.defaults, + *(default for default in node.args.kw_defaults if default is not None), + ): + self.visit(expression) + original_module_scope = self._scopes[0] + if self._module_final_scope is not None: + self._scopes[0] = dict(self._module_final_scope) + class_index = next( + ( + index + for index in range(len(self._scope_kinds) - 1, -1, -1) + if self._scope_kinds[index] == "class" + ), + None, + ) + class_scope = ( + dict(self._scopes[class_index]) + if class_index is not None + else None + ) + self._qualnames.append(node.name) + self._push_scope("function") + self._shadow_arguments(node.args) + for parameter, choices in self._function_injections.get(node, {}).items(): + existing = self._scopes[-1].get((parameter,)) or () + self._set_symbol( + (parameter,), + _unique_bindings((*existing, *choices)), + ) + positional = (*node.args.posonlyargs, *node.args.args) + for argument, default in zip( + positional[-len(node.args.defaults):], + node.args.defaults, + ): + if self._static_bindings(default): + self._propagate(ast.Name(id=argument.arg), default) + for argument, default in zip(node.args.kwonlyargs, node.args.kw_defaults): + if default is not None and self._static_bindings(default): + self._propagate(ast.Name(id=argument.arg), default) + for statement in node.body: + self.visit(statement) + self._pop_scope() + self._qualnames.pop() + if ( + not self._collecting_class_bindings + and class_index is not None + and class_scope is not None + ): + self._scopes[class_index] = class_scope + self._scopes[0] = original_module_scope + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + """在异步函数限定名下访问定义体。""" + self.visit_FunctionDef(node) + + def visit_Import(self, node: ast.Import) -> None: + """在导入发生的 lexical scope 发布网络符号。""" + self._scopes[-1].update(self._import_records.get(node, {})) + + def visit_ImportFrom(self, node: ast.ImportFrom) -> None: + """在 from-import 发生的 lexical scope 发布网络符号。""" + self._scopes[-1].update(self._import_records.get(node, {})) + + def visit_If(self, node: ast.If) -> None: + """跳过 TYPE_CHECKING 专用分支,其余条件按源码顺序扫描。""" + self.visit(node.test) + if _is_type_checking_test(node.test): + for statement in node.orelse: + self.visit(statement) + return + + original = [dict(scope) for scope in self._scopes] + for statement in node.body: + self.visit(statement) + body_scopes = [dict(scope) for scope in self._scopes] + + self._scopes = [dict(scope) for scope in original] + for statement in node.orelse: + self.visit(statement) + else_scopes = [dict(scope) for scope in self._scopes] + + merged_scopes: list[dict[tuple[str, ...], _SymbolChoices | None]] = [] + for original_scope, body_scope, else_scope in zip( + original, + body_scopes, + else_scopes, + ): + merged: dict[tuple[str, ...], _SymbolChoices | None] = {} + for name in original_scope.keys() | body_scope.keys() | else_scope.keys(): + alternatives = _unique_bindings( + binding + for binding in ( + body_scope.get(name, original_scope.get(name)), + else_scope.get(name, original_scope.get(name)), + ) + if binding is not None + for binding in binding + ) + merged[name] = alternatives or None + merged_scopes.append(merged) + self._scopes = merged_scopes + + def visit_Try(self, node: ast.Try) -> None: + """按成功/异常路径合并符号来源,finally 在合并结果上执行。""" + original = [dict(scope) for scope in self._scopes] + states: list[list[dict[tuple[str, ...], _SymbolChoices | None]]] = [] + + for statement in (*node.body, *node.orelse): + self.visit(statement) + states.append([dict(scope) for scope in self._scopes]) + + for handler in node.handlers: + self._scopes = [dict(scope) for scope in original] + if handler.type is not None: + self.visit(handler.type) + if handler.name: + self._set_symbol((handler.name,), None) + for statement in handler.body: + self.visit(statement) + states.append([dict(scope) for scope in self._scopes]) + + merged_scopes: list[dict[tuple[str, ...], _SymbolChoices | None]] = [] + for index, original_scope in enumerate(original): + merged: dict[tuple[str, ...], _SymbolChoices | None] = {} + names = original_scope.keys() + for state in states: + names = names | state[index].keys() + for name in names: + alternatives = _unique_bindings( + binding + for state in states + for choices in (state[index].get(name, original_scope.get(name)),) + if choices is not None + for binding in choices + ) + merged[name] = alternatives or None + merged_scopes.append(merged) + self._scopes = merged_scopes + for statement in node.finalbody: + self.visit(statement) + + def visit_For(self, node: ast.For) -> None: + """for 目标在循环体遮蔽同名符号,并保留循环零次路径。""" + self.visit(node.iter) + original = [dict(scope) for scope in self._scopes] + target = _bound_name(node.target) + if target: + self._set_symbol(target, None) + for statement in (*node.body, *node.orelse): + self.visit(statement) + body_scopes = [dict(scope) for scope in self._scopes] + merged_scopes: list[dict[tuple[str, ...], _SymbolChoices | None]] = [] + for original_scope, body_scope in zip(original, body_scopes): + merged: dict[tuple[str, ...], _SymbolChoices | None] = {} + for name in original_scope.keys() | body_scope.keys(): + alternatives = _unique_bindings( + binding + for choices in ( + original_scope.get(name), + body_scope.get(name, original_scope.get(name)), + ) + if choices is not None + for binding in choices + ) + merged[name] = alternatives or None + merged_scopes.append(merged) + self._scopes = merged_scopes + + def visit_AsyncFor(self, node: ast.AsyncFor) -> None: + """异步 for 使用与同步 for 相同的遮蔽规则。""" + self.visit_For(node) + + def visit_Lambda(self, node: ast.Lambda) -> None: + """lambda 参数只在其表达式 scope 内遮蔽外层符号。""" + for default in (*node.args.defaults, *node.args.kw_defaults): + if default is not None: + self.visit(default) + self._push_scope("lambda") + self._shadow_arguments(node.args) + self.visit(node.body) + self._pop_scope() + + def _visit_comprehension( + self, + generators: list[ast.comprehension], + expressions: tuple[ast.AST, ...], + ) -> None: + """在独立 comprehension scope 中处理迭代目标遮蔽。""" + self._push_scope("comprehension") + for generator in generators: + self.visit(generator.iter) + target = _bound_name(generator.target) + if target: + self._set_symbol(target, None) + for condition in generator.ifs: + self.visit(condition) + for expression in expressions: + self.visit(expression) + self._pop_scope() + + def visit_ListComp(self, node: ast.ListComp) -> None: + """访问列表推导式的独立 scope。""" + self._visit_comprehension(node.generators, (node.elt,)) + + def visit_SetComp(self, node: ast.SetComp) -> None: + """访问集合推导式的独立 scope。""" + self._visit_comprehension(node.generators, (node.elt,)) + + def visit_GeneratorExp(self, node: ast.GeneratorExp) -> None: + """访问生成器表达式的独立 scope。""" + self._visit_comprehension(node.generators, (node.elt,)) + + def visit_DictComp(self, node: ast.DictComp) -> None: + """访问字典推导式的独立 scope。""" + self._visit_comprehension(node.generators, (node.key, node.value)) + + def visit_Assign(self, node: ast.Assign) -> None: + """记录赋值右侧调用并传播简单目标。""" + self.visit(node.value) + for target in node.targets: + self._propagate(target, node.value) + + def visit_AnnAssign(self, node: ast.AnnAssign) -> None: + """记录带注解赋值并传播简单目标。""" + if node.value is not None: + self.visit(node.value) + self._propagate(node.target, node.value) + + def visit_NamedExpr(self, node: ast.NamedExpr) -> None: + """记录海象赋值并传播简单目标。""" + self.visit(node.value) + self._propagate(node.target, node.value) + + def visit_With(self, node: ast.With) -> None: + """记录上下文能力获取,并把 as 目标传播到定义体。""" + for item in node.items: + self.visit(item.context_expr) + if item.optional_vars is not None: + self._propagate(item.optional_vars, item.context_expr) + for statement in node.body: + self.visit(statement) + + def visit_AsyncWith(self, node: ast.AsyncWith) -> None: + """记录异步上下文能力获取,并把 as 目标传播到定义体。""" + for item in node.items: + self.visit(item.context_expr) + if item.optional_vars is not None: + self._propagate(item.optional_vars, item.context_expr) + for statement in node.body: + self.visit(statement) + + def visit_Call(self, node: ast.Call) -> None: + """记录调用后继续访问参数中的嵌套调用。""" + self._record_call(node) + self._record_function_injection(node) + self.visit(node.func) + for argument in (*node.args, *node.keywords): + self.visit(argument.value if isinstance(argument, ast.keyword) else argument) + + +def _import_records( + source: str, + tree: ast.Module, + facts: dict[tuple[str, str, str], dict[str, object]], +) -> dict[ast.AST, dict[tuple[str, ...], _SymbolChoices]]: + """预收集运行期 import 事实,并按 AST 节点保留 lexical 发布记录。""" + records: dict[ast.AST, dict[tuple[str, ...], _SymbolChoices]] = {} + for node in _runtime_import_nodes(tree): + symbols: dict[tuple[str, ...], _SymbolChoices] = {} + if isinstance(node, ast.Import): + for alias in node.names: + bound = alias.asname or alias.name.split(".", maxsplit=1)[0] + canonical = alias.name if alias.asname else bound + registered = _registered_target(alias.name) + target, kind = registered if registered else (None, None) + binding_text = f"import:{alias.name} as {bound}" + symbols[(bound,)] = ( + _SymbolBinding(canonical, target, binding_text), + ) + if registered: + fact = facts.setdefault( + (source, target, kind), + { + "source": source, + "target": target, + "kind": kind, + "bindings": set(), + "uses": [], + }, + ) + fact["bindings"].add(binding_text) # type: ignore[union-attr] + records[node] = symbols + continue + module = node.module or "" + for alias in node.names: + if alias.name == "*": + if registered := _registered_target(module): + target, kind = registered + binding_text = f"from:{module}.* as *" + fact = facts.setdefault( + (source, target, kind), + { + "source": source, + "target": target, + "kind": kind, + "bindings": set(), + "uses": [], + }, + ) + fact["bindings"].add(binding_text) # type: ignore[union-attr] + continue + bound = alias.asname or alias.name + reexport = REEXPORTED_TARGETS.get((module, alias.name)) + imported = reexport or f"{module}.{alias.name}" + registered = _registered_target(imported) or _registered_target(module) + target, kind = registered if registered else (None, None) + if reexport: + canonical = reexport + binding_text = f"reexport:{module}.{alias.name} as {bound}" + elif registered and imported == target: + canonical = imported + binding_text = f"from:{module}.{alias.name} as {bound}" + else: + canonical = imported + binding_text = f"from:{module}.{alias.name} as {bound}" + symbols[(bound,)] = ( + _SymbolBinding(canonical, target, binding_text), + ) + if registered: + fact = facts.setdefault( + (source, target, kind), + { + "source": source, + "target": target, + "kind": kind, + "bindings": set(), + "uses": [], + }, + ) + fact["bindings"].add(binding_text) # type: ignore[union-attr] + records[node] = symbols + return records + + +def collect_direct_egress(modules: dict[str, Path]) -> dict[str, object]: + """收集宿主 direct egress 事实、注册表和稳定统计。""" + facts: dict[tuple[str, str, str], dict[str, object]] = {} + for source, path in sorted(modules.items()): + tree = ast.parse(path.read_text(encoding="utf-8-sig"), filename=str(path)) + discovery_facts: dict[tuple[str, str, str], dict[str, object]] = {} + discovery_records = _import_records(source, tree, discovery_facts) + discovery = _EgressVisitor(source, discovery_records, discovery_facts) + discovery.seed_module(tree) + discovery.visit(tree) + import_records = _import_records(source, tree, facts) + visitor = _EgressVisitor( + source, + import_records, + facts, + function_injections=discovery.function_injections(), + module_final_scope=discovery.module_scope(), + ) + visitor.seed_module(tree) + visitor.visit(tree) + + entries: list[dict[str, object]] = [] + for fact in facts.values(): + normalized = { + "source": fact["source"], + "target": fact["target"], + "kind": fact["kind"], + "bindings": sorted(fact["bindings"]), + "uses": sorted(fact["uses"]), + } + entries.append({**normalized, "fingerprint": _fact_fingerprint(normalized)}) + entries.sort(key=lambda item: (item["source"], item["target"], item["kind"])) + + counts_by_kind = { + kind: sum(entry["kind"] == kind for entry in entries) + for kind in ("raw_transport", "network_sdk", "protocol_operation") + } + source_roots = { + root: sum( + entry["source"] == root or str(entry["source"]).startswith(f"{root}.") + for entry in entries + ) + for root in ("app.application", "app.chain") + } + return { + "scope": { + "root": "app", + "excluded_roots": ["app.plugins"], + "runtime_only": True, + "line_numbers": False, + }, + "registry": { + "raw_transports": list(RAW_TRANSPORT_ROOTS), + "network_sdks": list(NETWORK_SDK_ROOTS), + "protocol_operations": sorted(PROTOCOL_OPERATIONS), + "reexports": [ + { + "source": f"{module}.{member}", + "target": target, + } + for (module, member), target in sorted(REEXPORTED_TARGETS.items()) + ], + }, + "count": len(entries), + "counts_by_kind": counts_by_kind, + "application_chain_counts": source_roots, + "entries": entries, + } diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index f6f17a9cd..bdce75911 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -179,6 +179,1268 @@ "app.adapters.system.host" ] }, + "direct_egress": { + "application_chain_counts": { + "app.application": 1, + "app.chain": 0 + }, + "count": 66, + "counts_by_kind": { + "network_sdk": 29, + "protocol_operation": 7, + "raw_transport": 30 + }, + "entries": [ + { + "bindings": [ + "from:redis.asyncio.BlockingConnectionPool as AsyncBlockingConnectionPool", + "from:redis.asyncio.Redis as Redis", + "import:redis as redis" + ], + "fingerprint": "9d455a5298d4373ff18d74a9d498a3dd797bcb5f5543af9776c0c741c30362c7", + "kind": "network_sdk", + "source": "app.adapters.cache.redis", + "target": "redis", + "uses": [ + "AsyncRedisHelper._close_client|call:close", + "AsyncRedisHelper._connect|call:asyncio.BlockingConnectionPool.from_url", + "AsyncRedisHelper._connect|call:asyncio.Redis", + "AsyncRedisHelper._connect|call:ping", + "AsyncRedisHelper.clear|call:flushdb", + "AsyncRedisHelper.clear|call:pipeline", + "AsyncRedisHelper.clear|call:scan_iter", + "AsyncRedisHelper.delete|call:delete", + "AsyncRedisHelper.exists|call:exists", + "AsyncRedisHelper.get|call:get", + "AsyncRedisHelper.items|call:get", + "AsyncRedisHelper.items|call:get", + "AsyncRedisHelper.items|call:scan_iter", + "AsyncRedisHelper.items|call:scan_iter", + "AsyncRedisHelper.set_memory_limit|call:config_set", + "AsyncRedisHelper.set_memory_limit|call:config_set", + "AsyncRedisHelper.set|call:set", + "RedisHelper._connect|call:BlockingConnectionPool.from_url", + "RedisHelper._connect|call:Redis", + "RedisHelper._connect|call:ping", + "RedisHelper.clear|call:flushdb", + "RedisHelper.clear|call:pipeline", + "RedisHelper.clear|call:scan_iter", + "RedisHelper.close|call:close", + "RedisHelper.delete|call:delete", + "RedisHelper.exists|call:exists", + "RedisHelper.get|call:get", + "RedisHelper.items|call:get", + "RedisHelper.items|call:get", + "RedisHelper.items|call:scan_iter", + "RedisHelper.items|call:scan_iter", + "RedisHelper.pop|call:getdel", + "RedisHelper.set_memory_limit|call:config_set", + "RedisHelper.set_memory_limit|call:config_set", + "RedisHelper.set|call:set" + ] + }, + { + "bindings": [ + "import:httpx2 as httpx2" + ], + "fingerprint": "d4a648e8188818c0465013fc63cc3e49899da4df38541344303c251896564b1f", + "kind": "raw_transport", + "source": "app.adapters.external.market", + "target": "httpx2", + "uses": [] + }, + { + "bindings": [ + "from:requests.Response as Response" + ], + "fingerprint": "ecc5368adfced20741e5ed8696008ea7555a4a81ceda6d7149e09f5f3d0ff7e3", + "kind": "raw_transport", + "source": "app.adapters.external.market", + "target": "requests", + "uses": [] + }, + { + "bindings": [ + "from:cloakbrowser.launch_context as launch_context", + "from:cloakbrowser.launch_context_async as launch_context_async" + ], + "fingerprint": "15c1777b14eb9147d6cab9783f67577011714f9220600dda5db5269b59726173", + "kind": "network_sdk", + "source": "app.adapters.network.browser", + "target": "cloakbrowser", + "uses": [ + "launch_browser_context_async|call:launch_context_async", + "launch_browser_context|call:launch_context" + ] + }, + { + "bindings": [ + "import:socket as socket" + ], + "fingerprint": "4ff03419dfacc6bf582b7d4421dd5a0666a63f8ca79be2b1e625f4c8f4c96b71", + "kind": "protocol_operation", + "source": "app.adapters.network.doh", + "target": "socket.getaddrinfo", + "uses": [ + "enable_doh._patched_getaddrinfo|call:socket.getaddrinfo", + "enable_doh._patched_getaddrinfo|call:socket.getaddrinfo", + "enable_doh._patched_getaddrinfo|call:socket.getaddrinfo", + "enable_doh._patched_getaddrinfo|call:socket.getaddrinfo" + ] + }, + { + "bindings": [ + "import:urllib.request as urllib" + ], + "fingerprint": "6f5f5fd3da02a9e780ea5e7cc1e47bd962314a1a358f14b4ee698485f96ab52b", + "kind": "raw_transport", + "source": "app.adapters.network.doh", + "target": "urllib.request", + "uses": [ + "_doh_query|call:Request", + "_doh_query|call:urlopen", + "doh_query_json|call:Request", + "doh_query_json|call:urlopen" + ] + }, + { + "bindings": [ + "import:httpx2 as httpx2" + ], + "fingerprint": "a70799e9930ff79cd28ebed92836c8108cd2d18bc668e0081f68533d84f69d0d", + "kind": "raw_transport", + "source": "app.adapters.network.http", + "target": "httpx2", + "uses": [ + "AsyncRequestUtils._dispatch_request|call:AsyncClient", + "AsyncRequestUtils._dispatch_request|call:AsyncClient", + "AsyncRequestUtils._dispatch_request|call:Timeout", + "AsyncRequestUtils._make_request|call:request", + "AsyncRequestUtils._make_request|call:request", + "AsyncRequestUtils.get_stream|call:AsyncClient", + "AsyncRequestUtils.get_stream|call:AsyncClient", + "AsyncRequestUtils.get_stream|call:Timeout", + "AsyncRequestUtils.get_stream|call:stream", + "AsyncRequestUtils.get_stream|call:stream", + "_get_shared_async_transport|call:AsyncHTTPTransport", + "_get_shared_async_transport|call:Limits", + "_h2_proxy_breaker_key|call:URL" + ] + }, + { + "bindings": [ + "from:requests.Response as Response", + "from:requests.Session as Session", + "import:requests as requests" + ], + "fingerprint": "95fb4576cec363355f40aa1758d90fbb09242a2b1d029b7ab5ab65e2aaca12f8", + "kind": "raw_transport", + "source": "app.adapters.network.http", + "target": "requests", + "uses": [ + "RequestUtils.request|call:close", + "RequestUtils.request|call:request", + "RequestUtils.request|call:request", + "RequestUtils.request|call:request", + "RequestUtils.request|call:request" + ] + }, + { + "bindings": [ + "from:urllib3.exceptions.InsecureRequestWarning as InsecureRequestWarning", + "import:urllib3 as urllib3" + ], + "fingerprint": "66cbd8ec4e7552bd458db0baada30f1953e6d0493793822d23a2199567bca98a", + "kind": "raw_transport", + "source": "app.adapters.network.http", + "target": "urllib3", + "uses": [ + "|call:disable_warnings" + ] + }, + { + "bindings": [ + "import:socket as socket" + ], + "fingerprint": "a43e8969e2f26546fcf925b258728b309f1b9b966ff3c7b48a64273b8c82d048", + "kind": "protocol_operation", + "source": "app.adapters.network.ip", + "target": "socket.gethostbyname", + "uses": [ + "IpUtils.is_internal_domain|call:socket.gethostbyname" + ] + }, + { + "bindings": [ + "from:openai.OpenAI as OpenAI" + ], + "fingerprint": "60160ab01b60be2c9794b9724a50b90f8875c6a10748ff9392a1e4bc7d48c334", + "kind": "network_sdk", + "source": "app.agent.llm.capability", + "target": "openai", + "uses": [ + "MiniMaxAudioProvider._build_client|call:OpenAI", + "OpenAIAudioProvider._build_client|call:OpenAI", + "OpenAIChatAudioProvider._build_client|call:OpenAI" + ] + }, + { + "bindings": [ + "from:google.genai as genai", + "from:google.genai.types.HttpOptions as HttpOptions" + ], + "fingerprint": "d41688ecda4c5de296fabb0d0d25c79a7b0f29f67ccdd44569dc1110e1db9b86", + "kind": "network_sdk", + "source": "app.agent.llm.helper", + "target": "google.genai", + "uses": [ + "LLMHelper._get_google_models|call:Client", + "LLMHelper._get_google_models|call:aio.aclose", + "LLMHelper._get_google_models|call:aio.models.list", + "LLMHelper._get_google_models|call:types.HttpOptions" + ] + }, + { + "bindings": [ + "import:httpx as httpx" + ], + "fingerprint": "caeccd432f48d9a23948dd5b9220ddbebc57d2b2936bfa499fc7d4876e935ea8", + "kind": "raw_transport", + "source": "app.agent.llm.helper", + "target": "httpx", + "uses": [ + "_build_httpx_client|call:__call__" + ] + }, + { + "bindings": [ + "from:langchain_anthropic.ChatAnthropic as ChatAnthropic" + ], + "fingerprint": "c8d2c3166c4d42d61e7ef38a25080cac0f93422f47393df6dc19b329a7f72265", + "kind": "network_sdk", + "source": "app.agent.llm.helper", + "target": "langchain_anthropic", + "uses": [ + "LLMHelper.get_llm|call:ChatAnthropic" + ] + }, + { + "bindings": [ + "from:langchain_aws.ChatBedrockConverse as ChatBedrockConverse" + ], + "fingerprint": "51dd02f5e2305cce4e1f6f29b4ed47edf54dde9f6557781178a6edf96bfeeb6b", + "kind": "network_sdk", + "source": "app.agent.llm.helper", + "target": "langchain_aws", + "uses": [ + "LLMHelper.get_llm|call:ChatBedrockConverse" + ] + }, + { + "bindings": [ + "from:langchain_deepseek.ChatDeepSeek as ChatDeepSeek" + ], + "fingerprint": "bcf8b1ffae0995cfc33cfe26609cef8014cd2ce036b845c2a88c0cd878b1ebc4", + "kind": "network_sdk", + "source": "app.agent.llm.helper", + "target": "langchain_deepseek", + "uses": [ + "LLMHelper.get_llm|call:ChatDeepSeek" + ] + }, + { + "bindings": [ + "from:langchain_google_genai.ChatGoogleGenerativeAI as ChatGoogleGenerativeAI", + "import:langchain_google_genai.chat_models as _cm" + ], + "fingerprint": "79ae88c63908da9ea36dcfc23c4ad5345bc5d1977b80cc2e0b89292d063ed8ee", + "kind": "network_sdk", + "source": "app.agent.llm.helper", + "target": "langchain_google_genai", + "uses": [ + "LLMHelper.get_llm|call:ChatGoogleGenerativeAI", + "_patch_gemini_thought_signature._patched_parse_chat_history|call:chat_models._parse_chat_history" + ] + }, + { + "bindings": [ + "from:langchain_openai.ChatOpenAI as ChatOpenAI", + "import:langchain_openai.chat_models.base as _openai_base" + ], + "fingerprint": "15775bf1694780d5cf516e6cc3d3b5174ebb25e3038433132d3071e6abceba52", + "kind": "network_sdk", + "source": "app.agent.llm.helper", + "target": "langchain_openai", + "uses": [ + "LLMHelper.get_llm|call:ChatOpenAI" + ] + }, + { + "bindings": [ + "from:openai.AsyncOpenAI as AsyncOpenAI" + ], + "fingerprint": "dbebd5e9fa38b2a525a5a2e5a92d855ba9d05762410590bfb544e665fe38f620", + "kind": "network_sdk", + "source": "app.agent.llm.helper", + "target": "openai", + "uses": [ + "LLMHelper._get_openai_compatible_models|call:AsyncOpenAI", + "LLMHelper._get_openai_compatible_models|call:close", + "LLMHelper._get_openai_compatible_models|call:models.list" + ] + }, + { + "bindings": [ + "import:boto3 as boto3" + ], + "fingerprint": "04ca4b5ecbda1531266d5f53817dde061dcfdd3293cabf9301fc8d8e4c281a88", + "kind": "network_sdk", + "source": "app.agent.llm.provider", + "target": "boto3", + "uses": [ + "LLMProviderManager.create_bedrock_client|call:client", + "LLMProviderManager.create_bedrock_client|call:client", + "LLMProviderManager.create_bedrock_client|call:meta.events.register" + ] + }, + { + "bindings": [ + "from:botocore.UNSIGNED as UNSIGNED", + "from:botocore.config.Config as Config" + ], + "fingerprint": "fbeaa702703665ca60233a153aade0da7e49dfb6fe31f72e5593c883fb7d3f11", + "kind": "network_sdk", + "source": "app.agent.llm.provider", + "target": "botocore", + "uses": [ + "LLMProviderManager._build_bedrock_boto3_config|call:config.Config" + ] + }, + { + "bindings": [ + "from:google.genai as genai", + "from:google.genai.types.HttpOptions as HttpOptions" + ], + "fingerprint": "b545cffb169b87f87606c17762b5d9da3f35372bd411c1f4ebba010b3d7c3ebb", + "kind": "network_sdk", + "source": "app.agent.llm.provider", + "target": "google.genai", + "uses": [ + "LLMProviderManager._list_models_from_google|call:Client", + "LLMProviderManager._list_models_from_google|call:aio.models.list", + "LLMProviderManager._list_models_from_google|call:types.HttpOptions" + ] + }, + { + "bindings": [ + "import:httpx as httpx" + ], + "fingerprint": "a68ad216d86b4b33aa8c35572c26f7993245c0c12dd512e962bcacb46d7f15fc", + "kind": "raw_transport", + "source": "app.agent.llm.provider", + "target": "httpx", + "uses": [ + "LLMProviderManager._exchange_chatgpt_code_for_tokens|call:AsyncClient", + "LLMProviderManager._exchange_chatgpt_code_for_tokens|call:post", + "LLMProviderManager._fetch_models_dev|call:AsyncClient", + "LLMProviderManager._fetch_models_dev|call:get", + "LLMProviderManager._list_models_from_copilot|call:AsyncClient", + "LLMProviderManager._list_models_from_copilot|call:get", + "LLMProviderManager._list_models_from_openai_compatible|call:AsyncClient", + "LLMProviderManager._poll_chatgpt_device_auth|call:AsyncClient", + "LLMProviderManager._poll_chatgpt_device_auth|call:post", + "LLMProviderManager._poll_copilot_device_auth|call:AsyncClient", + "LLMProviderManager._poll_copilot_device_auth|call:post", + "LLMProviderManager._refresh_chatgpt_tokens|call:AsyncClient", + "LLMProviderManager._refresh_chatgpt_tokens|call:post", + "LLMProviderManager.start_auth|call:AsyncClient", + "LLMProviderManager.start_auth|call:AsyncClient", + "LLMProviderManager.start_auth|call:post", + "LLMProviderManager.start_auth|call:post", + "LLMProviderManager|call:Timeout" + ] + }, + { + "bindings": [ + "from:openai.AsyncOpenAI as AsyncOpenAI" + ], + "fingerprint": "ffac3b355eb640f6421299cf838cb4e24866a39afabd9dcfd8d0f340925dc1a8", + "kind": "network_sdk", + "source": "app.agent.llm.provider", + "target": "openai", + "uses": [ + "LLMProviderManager._list_models_from_openai_compatible|call:AsyncOpenAI", + "LLMProviderManager._list_models_from_openai_compatible|call:models.list" + ] + }, + { + "bindings": [ + "from:ddgs.DDGS as DDGS" + ], + "fingerprint": "377e73bb3be804b825d60ad6e792344ca633de107f8c4dbb45d50a34a4dfa04b", + "kind": "network_sdk", + "source": "app.agent.tools.impl.search_web", + "target": "ddgs", + "uses": [ + "SearchWebTool._search_ddgs.sync_search|call:DDGS", + "SearchWebTool._search_ddgs.sync_search|call:text" + ] + }, + { + "bindings": [ + "from:pywebpush.WebPushException as WebPushException", + "from:pywebpush.webpush as webpush" + ], + "fingerprint": "7d83ca0dc89dfb6af1cdd7bc90d922105de94222e4e3a44bba4f53bbd97bc31b", + "kind": "network_sdk", + "source": "app.api.endpoints.message", + "target": "pywebpush", + "uses": [ + "send_notification|call:webpush" + ] + }, + { + "bindings": [ + "import:socket as socket" + ], + "fingerprint": "89c563fdb640259584563fc718f7104a7b4dc6717e97b9c9e449f70f74a9532b", + "kind": "protocol_operation", + "source": "app.application.security.url", + "target": "socket.getaddrinfo", + "uses": [ + "SecurityUtils._hostname_addresses|call:socket.getaddrinfo" + ] + }, + { + "bindings": [ + "from:urllib.request.Request as Request", + "from:urllib.request.urlopen as urlopen" + ], + "fingerprint": "71b3e5be2a7d85fdc7a207d15c0e690d7ab96b335b882a3eaec03a1cb4ec1d1b", + "kind": "raw_transport", + "source": "app.cli", + "target": "urllib.request", + "uses": [ + "_frontend_health|call:Request", + "_frontend_health|call:urlopen", + "_http_request|call:Request", + "_http_request|call:urlopen" + ] + }, + { + "bindings": [ + "import:socket as socket" + ], + "fingerprint": "dfb34d4710353029dcc06e0e0ce3b299bae1a7e71a5743de1d253ac26380f9b6", + "kind": "protocol_operation", + "source": "app.doctor.checks", + "target": "socket.create_connection", + "uses": [ + "_can_connect|call:socket.create_connection" + ] + }, + { + "bindings": [ + "from:urllib.request.Request as Request", + "from:urllib.request.urlopen as urlopen" + ], + "fingerprint": "d8dd2279263ef58c3b4c59c70451533a4fc27d2ccd76960ca3aff29cd73f3354", + "kind": "raw_transport", + "source": "app.doctor.checks", + "target": "urllib.request", + "uses": [ + "_backend_health_payload|call:Request", + "_backend_health_payload|call:urlopen" + ] + }, + { + "bindings": [ + "import:requests as requests" + ], + "fingerprint": "e5baf8a89b8661025169e9eec8d3c13d30660f63b35d94ec4896cd97e7fde3bf", + "kind": "raw_transport", + "source": "app.modules.bangumi.bangumi", + "target": "requests", + "uses": [ + "BangumiApi.__init__|call:Session", + "BangumiApi.close|call:close" + ] + }, + { + "bindings": [ + "from:discord.app_commands as app_commands", + "import:discord as discord" + ], + "fingerprint": "e1486525fedbfe574aa47d60b483426c8c48e4e27fa35dbe34f6cd2f47d37c86", + "kind": "network_sdk", + "source": "app.modules.discord.discord", + "target": "discord", + "uses": [ + "Discord.__init__|call:Client", + "Discord.__init__|call:Intents.default", + "Discord.__init__|call:app_commands.CommandTree", + "Discord._build_embed|call:Embed", + "Discord._build_embed|call:add_field", + "Discord._build_embed|call:set_image", + "Discord._build_media_embeds|call:Embed", + "Discord._build_media_embeds|call:set_thumbnail", + "Discord._build_slash_command|call:app_commands.Command", + "Discord._build_slash_command|call:app_commands.describe", + "Discord._build_torrent_embeds|call:Embed", + "Discord._build_torrent_embeds|call:set_thumbnail", + "Discord._build_view|call:add_item", + "Discord._build_view|call:add_item", + "Discord._build_view|call:ui.Button", + "Discord._build_view|call:ui.Button", + "Discord._build_view|call:ui.Button", + "Discord._build_view|call:ui.View", + "Discord._get_dm_channel|call:fetch_user", + "Discord._get_dm_channel|call:get_user", + "Discord._resolve_channel|call:fetch_channel", + "Discord._resolve_channel|call:fetch_channel", + "Discord._resolve_channel|call:fetch_channel", + "Discord._resolve_channel|call:get_channel", + "Discord._resolve_channel|call:get_channel", + "Discord._resolve_channel|call:get_channel", + "Discord._resolve_channel|call:get_guild", + "Discord._send_file|call:File", + "Discord._should_process_message|call:user.mentioned_in", + "Discord._start.runner|call:close", + "Discord._start.runner|call:start", + "Discord._sync_registered_commands|call:Object", + "Discord._sync_registered_commands|call:add_command", + "Discord._sync_registered_commands|call:clear_commands", + "Discord._sync_registered_commands|call:is_ready", + "Discord._sync_registered_commands|call:sync", + "Discord._sync_registered_commands|call:wait_until_ready", + "Discord.stop|call:close" + ] + }, + { + "bindings": [ + "import:httpx2 as httpx2" + ], + "fingerprint": "edc337b12976d978cad3f83d0fa6faf71b2af2d814926dff890d5acc94f0a0a6", + "kind": "raw_transport", + "source": "app.modules.douban.apiv2", + "target": "httpx2", + "uses": [] + }, + { + "bindings": [ + "import:requests as requests" + ], + "fingerprint": "6e871a27b88263676797edd65093d0b38db48f77fb5db2bb581b7b82e9b2977d", + "kind": "raw_transport", + "source": "app.modules.douban.apiv2", + "target": "requests", + "uses": [ + "DoubanApi.__init__|call:Session", + "DoubanApi.close|call:close" + ] + }, + { + "bindings": [ + "from:requests.Response as Response" + ], + "fingerprint": "6c4c17fe170226ea1272f5859bc119c2775442cabe8ce2a9edcabaf9c37bec31", + "kind": "raw_transport", + "source": "app.modules.emby.emby", + "target": "requests", + "uses": [] + }, + { + "bindings": [ + "from:lark_oapi.api.cardkit.v1.ContentCardElementRequest as ContentCardElementRequest", + "from:lark_oapi.api.cardkit.v1.ContentCardElementRequestBody as ContentCardElementRequestBody", + "from:lark_oapi.api.cardkit.v1.CreateCardRequest as CreateCardRequest", + "from:lark_oapi.api.cardkit.v1.CreateCardRequestBody as CreateCardRequestBody", + "from:lark_oapi.api.cardkit.v1.SettingsCardRequest as SettingsCardRequest", + "from:lark_oapi.api.cardkit.v1.SettingsCardRequestBody as SettingsCardRequestBody", + "from:lark_oapi.api.im.v1.CreateFileRequest as CreateFileRequest", + "from:lark_oapi.api.im.v1.CreateFileRequestBody as CreateFileRequestBody", + "from:lark_oapi.api.im.v1.CreateImageRequest as CreateImageRequest", + "from:lark_oapi.api.im.v1.CreateImageRequestBody as CreateImageRequestBody", + "from:lark_oapi.api.im.v1.CreateMessageReactionRequest as CreateMessageReactionRequest", + "from:lark_oapi.api.im.v1.CreateMessageReactionRequestBody as CreateMessageReactionRequestBody", + "from:lark_oapi.api.im.v1.CreateMessageRequest as CreateMessageRequest", + "from:lark_oapi.api.im.v1.CreateMessageRequestBody as CreateMessageRequestBody", + "from:lark_oapi.api.im.v1.DeleteMessageReactionRequest as DeleteMessageReactionRequest", + "from:lark_oapi.api.im.v1.Emoji as Emoji", + "from:lark_oapi.api.im.v1.GetFileRequest as GetFileRequest", + "from:lark_oapi.api.im.v1.GetImageRequest as GetImageRequest", + "from:lark_oapi.api.im.v1.GetMessageResourceRequest as GetMessageResourceRequest", + "from:lark_oapi.api.im.v1.P2ImChatAccessEventBotP2pChatEnteredV1 as P2ImChatAccessEventBotP2pChatEnteredV1", + "from:lark_oapi.api.im.v1.P2ImMessageMessageReadV1 as P2ImMessageMessageReadV1", + "from:lark_oapi.api.im.v1.P2ImMessageReactionCreatedV1 as P2ImMessageReactionCreatedV1", + "from:lark_oapi.api.im.v1.P2ImMessageReactionDeletedV1 as P2ImMessageReactionDeletedV1", + "from:lark_oapi.api.im.v1.P2ImMessageRecalledV1 as P2ImMessageRecalledV1", + "from:lark_oapi.api.im.v1.P2ImMessageReceiveV1 as P2ImMessageReceiveV1", + "from:lark_oapi.api.im.v1.PatchMessageRequest as PatchMessageRequest", + "from:lark_oapi.api.im.v1.PatchMessageRequestBody as PatchMessageRequestBody", + "from:lark_oapi.api.im.v1.ReplyMessageRequest as ReplyMessageRequest", + "from:lark_oapi.api.im.v1.ReplyMessageRequestBody as ReplyMessageRequestBody", + "from:lark_oapi.core.const.FEISHU_DOMAIN as FEISHU_DOMAIN", + "from:lark_oapi.core.enum.LogLevel as LogLevel", + "from:lark_oapi.event.callback.model.p2_card_action_trigger.P2CardActionTrigger as P2CardActionTrigger", + "from:lark_oapi.event.callback.model.p2_card_action_trigger.P2CardActionTriggerResponse as P2CardActionTriggerResponse", + "import:lark_oapi as lark", + "import:lark_oapi.ws.client as lark_ws_client_module" + ], + "fingerprint": "99d16968a59932b5980f6d42ea2bbc8fa35ccdb1a27933beec29b4bdc093d78b", + "kind": "network_sdk", + "source": "app.modules.feishu.feishu", + "target": "lark_oapi", + "uses": [ + "Feishu._build_api_client|call:Client.builder", + "Feishu._build_event_handler|call:EventDispatcherHandler.builder", + "Feishu._create_streaming_card|call:api.cardkit.v1.CreateCardRequest.builder", + "Feishu._create_streaming_card|call:api.cardkit.v1.CreateCardRequestBody.builder", + "Feishu._disconnect_ws_client_quietly|call:_conn.close", + "Feishu._disconnect_ws_client_quietly|call:_lock.acquire", + "Feishu._disconnect_ws_client_quietly|call:_lock.release", + "Feishu._on_card_action|call:event.callback.model.p2_card_action_trigger.P2CardActionTriggerResponse", + "Feishu._reply_message|call:api.im.v1.ReplyMessageRequest.builder", + "Feishu._reply_message|call:api.im.v1.ReplyMessageRequestBody.builder", + "Feishu._run_ws_client|call:start", + "Feishu._run_ws_client|call:ws.Client", + "Feishu._send_message|call:api.im.v1.CreateMessageRequest.builder", + "Feishu._send_message|call:api.im.v1.CreateMessageRequestBody.builder", + "Feishu._update_streaming_card_content|call:api.cardkit.v1.ContentCardElementRequest.builder", + "Feishu._update_streaming_card_content|call:api.cardkit.v1.ContentCardElementRequestBody.builder", + "Feishu._upload_file|call:api.im.v1.CreateFileRequest.builder", + "Feishu._upload_file|call:api.im.v1.CreateFileRequestBody.builder", + "Feishu._upload_image|call:api.im.v1.CreateImageRequest.builder", + "Feishu._upload_image|call:api.im.v1.CreateImageRequestBody.builder", + "Feishu.add_message_reaction|call:api.im.v1.CreateMessageReactionRequest.builder", + "Feishu.add_message_reaction|call:api.im.v1.CreateMessageReactionRequestBody.builder", + "Feishu.add_message_reaction|call:api.im.v1.Emoji.builder", + "Feishu.close_streaming_card|call:api.cardkit.v1.SettingsCardRequest.builder", + "Feishu.close_streaming_card|call:api.cardkit.v1.SettingsCardRequestBody.builder", + "Feishu.delete_message_reaction|call:api.im.v1.DeleteMessageReactionRequest.builder", + "Feishu.download_file_bytes|call:api.im.v1.GetFileRequest.builder", + "Feishu.download_image_bytes|call:api.im.v1.GetImageRequest.builder", + "Feishu.download_message_resource_bytes|call:api.im.v1.GetMessageResourceRequest.builder", + "Feishu.edit_message|call:api.im.v1.PatchMessageRequest.builder", + "Feishu.edit_message|call:api.im.v1.PatchMessageRequestBody.builder", + "_select_bound_ws_client|call:ws.client._select" + ] + }, + { + "bindings": [ + "import:requests as requests" + ], + "fingerprint": "82d84425f58ed9664a6e2a4ece53240198c95a0f9fee5d0afd0a11e3c459ecf6", + "kind": "raw_transport", + "source": "app.modules.filemanager.storages.alipan", + "target": "requests", + "uses": [ + "AliPan.__get_access_token|call:post", + "AliPan.__get_drive_id|call:post", + "AliPan.__init__|call:Session", + "AliPan.__refresh_access_token|call:post", + "AliPan._init_session|call:headers.update", + "AliPan._request_api|call:request", + "AliPan._upload_part|call:put", + "AliPan.access_token|call:headers.update", + "AliPan.check_login|call:get", + "AliPan.generate_qrcode|call:post" + ] + }, + { + "bindings": [ + "from:smbclient.ClientConfig as ClientConfig", + "from:smbclient.register_session as register_session", + "from:smbclient.reset_connection_cache as reset_connection_cache", + "import:smbclient as smbclient" + ], + "fingerprint": "62e0585282ef206ac81b2bf9e93a58423fb058685edc0ef4ee592453bbf30376", + "kind": "network_sdk", + "source": "app.modules.filemanager.storages.smb", + "target": "smbclient", + "uses": [ + "SMB.__del__|call:reset_connection_cache", + "SMB._create_fileitem|call:path.isdir", + "SMB._init_connection|call:ClientConfig", + "SMB._init_connection|call:register_session", + "SMB._recursive_delete|call:listdir", + "SMB._recursive_delete|call:path.exists", + "SMB._recursive_delete|call:path.isdir", + "SMB._recursive_delete|call:path.isfile", + "SMB._recursive_delete|call:remove", + "SMB._recursive_delete|call:remove", + "SMB._recursive_delete|call:rmdir", + "SMB._test_connection|call:listdir", + "SMB.create_folder|call:mkdir", + "SMB.delete|call:path.exists", + "SMB.delete|call:remove", + "SMB.download|call:open_file", + "SMB.download|call:read", + "SMB.get_item_strict|call:stat", + "SMB.get_item|call:path.exists", + "SMB.get_item|call:stat", + "SMB.init_storage|call:reset_connection_cache", + "SMB.link|call:link", + "SMB.link|call:makedirs", + "SMB.link|call:path.exists", + "SMB.link|call:path.exists", + "SMB.list|call:listdir", + "SMB.list|call:stat", + "SMB.rename|call:rename", + "SMB.upload|call:open_file", + "SMB.upload|call:write", + "SMB.usage|call:stat_volume" + ] + }, + { + "bindings": [ + "from:smbprotocol.exceptions.SMBAuthenticationError as SMBAuthenticationError", + "from:smbprotocol.exceptions.SMBException as SMBException", + "from:smbprotocol.exceptions.SMBResponseException as SMBResponseException" + ], + "fingerprint": "f02686afd99c59820dffa7b4c2627a0be1ad62980c98a9cd33b7564697842b22", + "kind": "network_sdk", + "source": "app.modules.filemanager.storages.smb", + "target": "smbprotocol", + "uses": [] + }, + { + "bindings": [ + "import:httpx as httpx" + ], + "fingerprint": "f6678109963f9973d5d15e38e5f5e8dca01ec5e0117598ad1ac877b841ff7961", + "kind": "raw_transport", + "source": "app.modules.filemanager.storages.u115", + "target": "httpx", + "uses": [ + "U115Pan.__check_oauth_login|call:get", + "U115Pan.__get_access_token|call:post", + "U115Pan.__init__|call:Client", + "U115Pan.__refresh_access_token|call:post", + "U115Pan._init_session|call:headers.update", + "U115Pan._request_api|call:request", + "U115Pan.access_token|call:headers.update", + "U115Pan.check_login|call:get", + "U115Pan.download|call:stream", + "U115Pan.generate_auth_url|call:get", + "U115Pan.generate_qrcode|call:post" + ] + }, + { + "bindings": [ + "from:oss2.SizedFileAdapter as SizedFileAdapter", + "from:oss2.determine_part_size as determine_part_size", + "from:oss2.models.PartInfo as PartInfo", + "import:oss2 as oss2" + ], + "fingerprint": "f7b89c8ae6dad2603f0a9e0caaa159769aef5b3581d7e728f62445b979366eae", + "kind": "network_sdk", + "source": "app.modules.filemanager.storages.u115", + "target": "oss2", + "uses": [ + "U115Pan.upload.encode_callback|call:utils.b64encode_as_string", + "U115Pan.upload.refresh_upload_bucket|call:Bucket", + "U115Pan.upload.refresh_upload_bucket|call:StsAuth", + "U115Pan.upload|call:Bucket", + "U115Pan.upload|call:SizedFileAdapter", + "U115Pan.upload|call:StsAuth", + "U115Pan.upload|call:complete_multipart_upload", + "U115Pan.upload|call:determine_part_size", + "U115Pan.upload|call:init_multipart_upload", + "U115Pan.upload|call:models.PartInfo", + "U115Pan.upload|call:upload_part" + ] + }, + { + "bindings": [ + "import:requests as requests" + ], + "fingerprint": "3d05532e62d2cddf3bb05495196176b76aa02216c2b0d868cf9281d2257d9627", + "kind": "raw_transport", + "source": "app.modules.imdb.api", + "target": "requests", + "uses": [ + "ImdbApi.__init__|call:Session", + "ImdbApi.close|call:close" + ] + }, + { + "bindings": [ + "from:requests.Session as Session" + ], + "fingerprint": "facdb091442bacba0fc0f631637643e95e18545bd7ab63f1d49d67a9a928d016", + "kind": "raw_transport", + "source": "app.modules.indexer.parser", + "target": "requests", + "uses": [ + "SiteParserBase.close|call:close" + ] + }, + { + "bindings": [ + "from:requests.Response as Response" + ], + "fingerprint": "5c46d09ca9a4bcc0bae21ca5d554ef09baa3f901c562b27c5f5ee1439c7746b5", + "kind": "raw_transport", + "source": "app.modules.jellyfin.jellyfin", + "target": "requests", + "uses": [] + }, + { + "bindings": [ + "from:requests.Session as Session" + ], + "fingerprint": "c54a10dd672fb0ebb32a536ae42c221ad434313466cc108d43e433dd84cac9a5", + "kind": "raw_transport", + "source": "app.modules.musicbrainz", + "target": "requests", + "uses": [ + "MusicBrainzModule._get_session|call:Session" + ] + }, + { + "bindings": [ + "from:plexapi.exceptions.NotFound as NotFound", + "from:plexapi.myplex.MyPlexAccount as MyPlexAccount", + "from:plexapi.server.PlexServer as PlexServer" + ], + "fingerprint": "76c1334863dc6c6623ce6ad3415bec5ff92656f51c31e4d982285898f193e57e", + "kind": "network_sdk", + "source": "app.modules.plex.plex", + "target": "plexapi", + "uses": [ + "Plex.__fetch_item|call:fetchItem", + "Plex.__get_library_images|call:fetchItems", + "Plex.__init__|call:library.sections", + "Plex.__init__|call:server.PlexServer", + "Plex.__search_show|call:library.search", + "Plex.__search_show|call:library.search", + "Plex.authenticate|call:myplex.MyPlexAccount", + "Plex.authenticate|call:server.PlexServer", + "Plex.get_items_count|call:library.sectionByID", + "Plex.get_items|call:library.sectionByID", + "Plex.get_latest|call:fetchItems", + "Plex.get_librarys|call:library.sections", + "Plex.get_medias_count|call:library.sections", + "Plex.get_movies|call:library.search", + "Plex.get_movies|call:library.search", + "Plex.get_movies|call:library.search", + "Plex.get_movies|call:library.search", + "Plex.get_music|call:library.sections", + "Plex.get_remote_image_by_id|call:fetchItem", + "Plex.get_resume|call:fetchItems", + "Plex.reconnect|call:library.sections", + "Plex.reconnect|call:server.PlexServer", + "Plex.refresh_library_by_items|call:library.update", + "Plex.refresh_library_by_items|call:query", + "Plex.refresh_root_library|call:library.update" + ] + }, + { + "bindings": [ + "from:requests.Response as Response", + "from:requests.Session as Session" + ], + "fingerprint": "93f20b9c01571553e9601f4b9f4ee9471073e65f938cb635320f72fa41ec3843", + "kind": "raw_transport", + "source": "app.modules.plex.plex", + "target": "requests", + "uses": [ + "Plex.__adapt_plex_session|call:Session" + ] + }, + { + "bindings": [ + "from:qbittorrentapi.TorrentDictionary as TorrentDictionary", + "from:qbittorrentapi.TorrentFilesList as TorrentFilesList", + "from:qbittorrentapi.client.Client as Client", + "from:qbittorrentapi.transfer.TransferInfoDictionary as TransferInfoDictionary", + "import:qbittorrentapi as qbittorrentapi" + ], + "fingerprint": "b2f5a27f0c54cf95ed42fe99848fd6c9c0641caca8bc4ff8006a411cb427066d", + "kind": "network_sdk", + "source": "app.modules.qbittorrent.qbittorrent", + "target": "qbittorrentapi", + "uses": [ + "Qbittorrent.__login_qbittorrent|call:Client", + "Qbittorrent.__login_qbittorrent|call:app_version", + "Qbittorrent.__login_qbittorrent|call:auth_log_in", + "Qbittorrent.__sync_incomplete_file_suffix|call:app_preferences", + "Qbittorrent.__sync_incomplete_file_suffix|call:app_set_preferences" + ] + }, + { + "bindings": [ + "import:websocket as websocket" + ], + "fingerprint": "6f6b7d61f3a95e620e67c450d544f0aa077f087a188d98f32e4501d94c6b37ae", + "kind": "raw_transport", + "source": "app.modules.qqbot.gateway", + "target": "websocket", + "uses": [ + "run_gateway|call:WebSocketApp", + "run_gateway|call:run_forever" + ] + }, + { + "bindings": [ + "import:http.client as http" + ], + "fingerprint": "6adc93b3bc479dfe81197554c7977930a60abc51714f14ef29298eb40730aaf7", + "kind": "raw_transport", + "source": "app.modules.rtorrent.rtorrent", + "target": "http.client", + "uses": [ + "SCGITransport._build_response|call:HTTPResponse" + ] + }, + { + "bindings": [ + "import:socket as socket" + ], + "fingerprint": "878114d6b1bda091bd3b3d8aa819831d84381682feba78267592d2593295af90", + "kind": "protocol_operation", + "source": "app.modules.rtorrent.rtorrent", + "target": "socket.create_connection", + "uses": [ + "SCGITransport.single_request|call:socket.create_connection" + ] + }, + { + "bindings": [ + "import:xmlrpc.client as xmlrpc" + ], + "fingerprint": "2ac64b5670c930bc28bb2135cc4b73891c1cae01a74a9586f74e302878c45c85", + "kind": "protocol_operation", + "source": "app.modules.rtorrent.rtorrent", + "target": "xmlrpc.client.ServerProxy", + "uses": [ + "Rtorrent.__login_rtorrent|call:xmlrpc.client.ServerProxy", + "Rtorrent.__login_rtorrent|call:xmlrpc.client.ServerProxy" + ] + }, + { + "bindings": [ + "from:slack_bolt.App as App", + "from:slack_bolt.adapter.socket_mode.SocketModeHandler as SocketModeHandler" + ], + "fingerprint": "be5dcb032ece8d8627abeb243f98143aaf60f26751ab6e5e098813d4048419e6", + "kind": "network_sdk", + "source": "app.modules.slack.slack", + "target": "slack_bolt", + "uses": [ + "Slack.__find_public_channel|call:client.conversations_list", + "Slack.__init__|call:App", + "Slack.__init__|call:action", + "Slack.__init__|call:adapter.socket_mode.SocketModeHandler", + "Slack.__init__|call:command", + "Slack.__init__|call:connect", + "Slack.__init__|call:event", + "Slack.__init__|call:event", + "Slack.__init__|call:shortcut", + "Slack.add_reaction|call:client.reactions_add", + "Slack.delete_msg|call:client.chat_delete", + "Slack.remove_reaction|call:client.reactions_remove", + "Slack.send_file|call:client.files_upload_v2", + "Slack.send_medias_msg|call:client.chat_postMessage", + "Slack.send_medias_msg|call:client.chat_update", + "Slack.send_msg|call:client.chat_postMessage", + "Slack.send_msg|call:client.chat_update", + "Slack.send_torrents_msg|call:client.chat_postMessage", + "Slack.send_torrents_msg|call:client.chat_update", + "Slack.stop|call:close" + ] + }, + { + "bindings": [ + "from:slack_sdk.WebClient as WebClient" + ], + "fingerprint": "7559f31e4172ad3bbbaf161e1164ea48b997c56d05c77b49b82626298c39aa14", + "kind": "network_sdk", + "source": "app.modules.slack.slack", + "target": "slack_sdk", + "uses": [ + "Slack.__init__|call:WebClient" + ] + }, + { + "bindings": [ + "from:urllib3.fields as fields" + ], + "fingerprint": "18862bbdb252b59573f57ea776b5d64bfb775e7739603fecd23cc8f2c38a7e0d", + "kind": "raw_transport", + "source": "app.modules.telegram.compat", + "target": "urllib3", + "uses": [] + }, + { + "bindings": [ + "from:telebot.TeleBot as TeleBot", + "from:telebot.apihelper as apihelper", + "from:telebot.types.BotCommand as BotCommand", + "from:telebot.types.ForceReply as ForceReply", + "from:telebot.types.InlineKeyboardButton as InlineKeyboardButton", + "from:telebot.types.InlineKeyboardMarkup as InlineKeyboardMarkup", + "from:telebot.types.InputMediaPhoto as InputMediaPhoto", + "from:telebot.types.InputRichMessage as TelebotInputRichMessage", + "from:telebot.types.ReplyParameters as ReplyParameters" + ], + "fingerprint": "78f5ab18bfd67ba4fa0f3c0fc4a1a561a7b1d9e81e335fb80de86edb480b84b8", + "kind": "network_sdk", + "source": "app.modules.telegram.telegram", + "target": "telebot", + "uses": [ + "Telegram.__edit_message_text_or_caption|call:edit_message_caption", + "Telegram.__edit_message_text_or_caption|call:edit_message_text", + "Telegram.__edit_message|call:edit_message_media", + "Telegram.__edit_message|call:types.InputMediaPhoto", + "Telegram.__edit_rich_message|call:edit_message_text", + "Telegram.__init__.callback_query|call:answer_callback_query", + "Telegram.__init__.callback_query|call:answer_callback_query", + "Telegram.__init__.run_polling|call:infinity_polling", + "Telegram.__init__.send_welcome|call:reply_to", + "Telegram.__init__|call:TeleBot", + "Telegram.__init__|call:callback_query_handler", + "Telegram.__init__|call:get_me", + "Telegram.__init__|call:message_handler", + "Telegram.__init__|call:message_handler", + "Telegram.__send_long_message|call:send_document", + "Telegram.__send_long_message|call:send_message", + "Telegram.__send_long_message|call:send_photo", + "Telegram.__send_long_plain_message|call:send_message", + "Telegram.__send_long_plain_message|call:send_photo", + "Telegram.__send_rich_message|call:send_rich_message", + "Telegram.__send_rich_message|call:types.ReplyParameters", + "Telegram.__send_short_message|call:send_message", + "Telegram.__send_short_message|call:send_photo", + "Telegram._build_rich_message_chunks|call:types.InputRichMessage", + "Telegram._create_force_reply_markup|call:types.ForceReply", + "Telegram._create_force_reply_markup|call:types.ForceReply", + "Telegram._create_force_reply_markup|call:types.ForceReply", + "Telegram._create_inline_keyboard|call:types.InlineKeyboardButton", + "Telegram._create_inline_keyboard|call:types.InlineKeyboardButton", + "Telegram._create_inline_keyboard|call:types.InlineKeyboardMarkup", + "Telegram._start_typing_task.typing_worker|call:send_chat_action", + "Telegram.answer_callback_query|call:answer_callback_query", + "Telegram.delete_commands|call:delete_my_commands", + "Telegram.delete_msg|call:delete_message", + "Telegram.download_file|call:apihelper.FILE_URL.format", + "Telegram.download_file|call:get_file", + "Telegram.register_commands|call:delete_my_commands", + "Telegram.register_commands|call:set_my_commands", + "Telegram.register_commands|call:types.BotCommand", + "Telegram.send_file|call:send_document", + "Telegram.send_file|call:send_photo", + "Telegram.send_voice|call:send_voice" + ] + }, + { + "bindings": [ + "import:requests as requests", + "import:requests.exceptions as requests" + ], + "fingerprint": "d605eb176a203b3f4d205c5d183469b3002426682eb9cb4e7408bb6013652484", + "kind": "raw_transport", + "source": "app.modules.themoviedb.tmdbv3api.tmdb", + "target": "requests", + "uses": [ + "TMDb._init_session|call:Session" + ] + }, + { + "bindings": [ + "from:transmission_rpc.Client as Client", + "from:transmission_rpc.File as File", + "from:transmission_rpc.Torrent as Torrent", + "from:transmission_rpc.session.Session as Session", + "from:transmission_rpc.session.SessionStats as SessionStats", + "import:transmission_rpc as transmission_rpc" + ], + "fingerprint": "1652e661cb17dbadb039fdc4ab73d6d06e47eb118292ba3693313e45834959cf", + "kind": "network_sdk", + "source": "app.modules.transmission.transmission", + "target": "transmission_rpc", + "uses": [ + "Transmission.__login_transmission|call:Client", + "Transmission.__sync_incomplete_file_suffix|call:get_session", + "Transmission.__sync_incomplete_file_suffix|call:set_session" + ] + }, + { + "bindings": [ + "reexport:app.adapters.network.http.requests as requests" + ], + "fingerprint": "aecbe00404b2866c918c939bee5f1002045fc72aed4440c85e0811a4379913e4", + "kind": "raw_transport", + "source": "app.modules.trimemedia.api", + "target": "requests", + "uses": [ + "Api.__init__|call:Session", + "Api.close|call:close", + "Api.cookies|call:cookies.get_dict" + ] + }, + { + "bindings": [ + "from:requests.Session as Session" + ], + "fingerprint": "a266dd2b74f216b289682e5d83c0b5a7d0d08b5447d79c165659b6d7302656d6", + "kind": "raw_transport", + "source": "app.modules.ugreen.api", + "target": "requests", + "uses": [ + "Api.__init__|call:Session", + "Api._request_json|call:get", + "Api._request_json|call:post", + "Api.close|call:close", + "Api.export_session_state|call:cookies.get_dict", + "Api.import_session_state|call:cookies.update", + "Api.login|call:post", + "Api.logout|call:get" + ] + }, + { + "bindings": [ + "from:pywebpush.WebPushException as WebPushException", + "from:pywebpush.webpush as webpush" + ], + "fingerprint": "389c73b06150e3d5bcaf31f35a25178873d2ed38ef9a28354cb9bab691eeab76", + "kind": "network_sdk", + "source": "app.modules.webpush", + "target": "pywebpush", + "uses": [ + "WebPushModule.post_message|call:webpush" + ] + }, + { + "bindings": [ + "import:websocket as websocket" + ], + "fingerprint": "1bae78270eadce0571e2caaa111a5c0a9065ba2da97ebb26b8a5b76d3ed5eef6", + "kind": "raw_transport", + "source": "app.modules.wechat.wechatbot", + "target": "websocket", + "uses": [ + "WeChatBot._run_gateway|call:WebSocketApp", + "WeChatBot._run_gateway|call:run_forever", + "WeChatBot._send_raw|call:send", + "WeChatBot.stop|call:close" + ] + }, + { + "bindings": [ + "from:requests.Response as Response" + ], + "fingerprint": "9df3fd27b9696d45a72e7c8f67b5a9ad79a7371d1fe690bbaa17485bd1960d51", + "kind": "raw_transport", + "source": "app.modules.zspace.zspace", + "target": "requests", + "uses": [] + }, + { + "bindings": [ + "import:docker as docker" + ], + "fingerprint": "20a91ec521f7dfe6a0153dfd8ea49c4bac7f0a16b55f1c0655dfb33f54a01215", + "kind": "network_sdk", + "source": "app.runtime.state", + "target": "docker", + "uses": [ + "SystemHelper._check_restart_policy|call:DockerClient", + "SystemHelper._check_restart_policy|call:containers.get", + "SystemHelper._docker_api_restart|call:DockerClient", + "SystemHelper._docker_api_restart|call:containers.get" + ] + }, + { + "bindings": [ + "import:urllib3.fields as _urllib3_fields" + ], + "fingerprint": "cb6f0a314aeb1e2d3e76c240aa20460ac0c36d9f5c18c1a6ea3170f64dd3366b", + "kind": "raw_transport", + "source": "app.startup.lifecycle", + "target": "urllib3", + "uses": [] + }, + { + "bindings": [ + "import:socket as socket" + ], + "fingerprint": "2518de211c9ba32ccbc004cf58fe9c98837fe8ac95fe8ffb6ea82897b28d753f", + "kind": "protocol_operation", + "source": "app.testing.network_guard", + "target": "socket.getaddrinfo", + "uses": [ + "block_real_network._guarded_getaddrinfo|call:socket.getaddrinfo" + ] + } + ], + "registry": { + "network_sdks": [ + "boto3", + "botocore", + "cloakbrowser", + "ddgs", + "discord", + "docker", + "google.genai", + "langchain_anthropic", + "langchain_aws", + "langchain_deepseek", + "langchain_google_genai", + "langchain_openai", + "lark_oapi", + "openai", + "oss2", + "plexapi", + "pywebpush", + "qbittorrentapi", + "redis", + "slack_bolt", + "slack_sdk", + "smbclient", + "smbprotocol", + "telebot", + "transmission_rpc" + ], + "protocol_operations": [ + "asyncio.open_connection", + "socket.create_connection", + "socket.getaddrinfo", + "socket.gethostbyname", + "xmlrpc.client.ServerProxy" + ], + "raw_transports": [ + "requests", + "httpx", + "httpx2", + "aiohttp", + "urllib.request", + "urllib3", + "http.client", + "websocket", + "websockets", + "curl_cffi", + "cloudscraper", + "httplib2", + "pycurl" + ], + "reexports": [ + { + "source": "app.adapters.network.http.requests", + "target": "requests" + } + ] + }, + "scope": { + "excluded_roots": [ + "app.plugins" + ], + "line_numbers": false, + "root": "app", + "runtime_only": true + } + }, "edge_count": 6817, "edge_sha256": "e3d43fec9f7bc936ef5a2ffe7ba11ea054d1ba7d1c42e7101978480cd99a63fe", "edges": [ @@ -7838,7 +9100,7 @@ "app.workflow.actions.send_message", "app.workflow.actions.transfer_file" ], - "schema_version": 2, + "schema_version": 3, "scope": "MoviePilot host app excluding app/plugins", "strongly_connected_components": [ [ diff --git a/tests/fixtures/architecture/dependency-policy.json b/tests/fixtures/architecture/dependency-policy.json index 99f6b886a..69452db64 100644 --- a/tests/fixtures/architecture/dependency-policy.json +++ b/tests/fixtures/architecture/dependency-policy.json @@ -1,5 +1,5 @@ { - "schema_version": 2, + "schema_version": 3, "scope": { "dependency_kind": "static_runtime_imports", "excluded_roots": [ @@ -213,5 +213,212 @@ } ], "target_state": "empty" + }, + "direct_egress": { + "scope": { + "root": "app", + "excluded_roots": ["app.plugins"], + "runtime_only": true, + "line_numbers": false + }, + "registry": { + "raw_transports": [ + "requests", "httpx", "httpx2", "aiohttp", "urllib.request", "urllib3", + "http.client", "websocket", "websockets", "curl_cffi", "cloudscraper", + "httplib2", "pycurl" + ], + "network_sdks": [ + "boto3", "botocore", "cloakbrowser", "ddgs", "discord", "docker", + "google.genai", "langchain_anthropic", "langchain_aws", "langchain_deepseek", + "langchain_google_genai", "langchain_openai", "lark_oapi", "openai", "oss2", + "plexapi", "pywebpush", "qbittorrentapi", "redis", "slack_bolt", "slack_sdk", + "smbclient", "smbprotocol", "telebot", "transmission_rpc" + ], + "protocol_operations": [ + "asyncio.open_connection", "socket.create_connection", "socket.getaddrinfo", + "socket.gethostbyname", "xmlrpc.client.ServerProxy" + ], + "reexports": [ + { + "source": "app.adapters.network.http.requests", + "target": "requests" + } + ] + }, + "groups": [ + { + "classification": "temporary_debt", + "reason_code": "direct_http", + "tracking": "S2-L7", + "target_state": "empty", + "reason": "普通宿主 HTTP 与 SDK client factory 尚未收口;provider 的直接请求和注入 client 必须在同一迁移中处理。", + "facts": [ + {"source": "app.agent.llm.provider", "target": "httpx", "kind": "raw_transport", "fingerprint": "a68ad216d86b4b33aa8c35572c26f7993245c0c12dd512e962bcacb46d7f15fc"}, + {"source": "app.modules.filemanager.storages.alipan", "target": "requests", "kind": "raw_transport", "fingerprint": "82d84425f58ed9664a6e2a4ece53240198c95a0f9fee5d0afd0a11e3c459ecf6"}, + {"source": "app.modules.filemanager.storages.u115", "target": "httpx", "kind": "raw_transport", "fingerprint": "f6678109963f9973d5d15e38e5f5e8dca01ec5e0117598ad1ac877b841ff7961"}, + {"source": "app.modules.ugreen.api", "target": "requests", "kind": "raw_transport", "fingerprint": "a266dd2b74f216b289682e5d83c0b5a7d0d08b5447d79c165659b6d7302656d6"} + ] + }, + { + "classification": "temporary_debt", + "reason_code": "requestutils_session_bridge", + "tracking": "S2-L7", + "target_state": "empty", + "reason": "模块构造或接收 requests Session,并管理生命周期或桥接给 RequestUtils;迁移后不再直接拥有 transport。", + "facts": [ + {"source": "app.modules.bangumi.bangumi", "target": "requests", "kind": "raw_transport", "fingerprint": "e5baf8a89b8661025169e9eec8d3c13d30660f63b35d94ec4896cd97e7fde3bf"}, + {"source": "app.modules.douban.apiv2", "target": "requests", "kind": "raw_transport", "fingerprint": "6e871a27b88263676797edd65093d0b38db48f77fb5db2bb581b7b82e9b2977d"}, + {"source": "app.modules.imdb.api", "target": "requests", "kind": "raw_transport", "fingerprint": "3d05532e62d2cddf3bb05495196176b76aa02216c2b0d868cf9281d2257d9627"}, + {"source": "app.modules.indexer.parser", "target": "requests", "kind": "raw_transport", "fingerprint": "facdb091442bacba0fc0f631637643e95e18545bd7ab63f1d49d67a9a928d016"}, + {"source": "app.modules.musicbrainz", "target": "requests", "kind": "raw_transport", "fingerprint": "c54a10dd672fb0ebb32a536ae42c221ad434313466cc108d43e433dd84cac9a5"}, + {"source": "app.modules.plex.plex", "target": "requests", "kind": "raw_transport", "fingerprint": "93f20b9c01571553e9601f4b9f4ee9471073e65f938cb635320f72fa41ec3843"}, + {"source": "app.modules.trimemedia.api", "target": "requests", "kind": "raw_transport", "fingerprint": "aecbe00404b2866c918c939bee5f1002045fc72aed4440c85e0811a4379913e4"} + ] + }, + { + "classification": "temporary_debt", + "reason_code": "direct_dns", + "tracking": "S2-L6", + "target_state": "empty", + "reason": "SSRF 校验仍在 Application 直接执行 DNS I/O;迁移到注入的解析 Port 后清零。", + "facts": [ + {"source": "app.application.security.url", "target": "socket.getaddrinfo", "kind": "protocol_operation", "fingerprint": "89c563fdb640259584563fc718f7104a7b4dc6717e97b9c9e449f70f74a9532b"} + ] + }, + { + "classification": "approved_exception", + "reason_code": "canonical_transport", + "owner": "$source", + "reason": "精确 Adapter source 持有其 Redis、浏览器、HTTP 或 DNS transport;上层只能消费 Port 或统一 Facade。", + "facts": [ + {"source": "app.adapters.cache.redis", "target": "redis", "kind": "network_sdk", "fingerprint": "9d455a5298d4373ff18d74a9d498a3dd797bcb5f5543af9776c0c741c30362c7"}, + {"source": "app.adapters.network.browser", "target": "cloakbrowser", "kind": "network_sdk", "fingerprint": "15c1777b14eb9147d6cab9783f67577011714f9220600dda5db5269b59726173"}, + {"source": "app.adapters.network.doh", "target": "socket.getaddrinfo", "kind": "protocol_operation", "fingerprint": "4ff03419dfacc6bf582b7d4421dd5a0666a63f8ca79be2b1e625f4c8f4c96b71"}, + {"source": "app.adapters.network.doh", "target": "urllib.request", "kind": "raw_transport", "fingerprint": "6f5f5fd3da02a9e780ea5e7cc1e47bd962314a1a358f14b4ee698485f96ab52b"}, + {"source": "app.adapters.network.http", "target": "httpx2", "kind": "raw_transport", "fingerprint": "a70799e9930ff79cd28ebed92836c8108cd2d18bc668e0081f68533d84f69d0d"}, + {"source": "app.adapters.network.http", "target": "requests", "kind": "raw_transport", "fingerprint": "95fb4576cec363355f40aa1758d90fbb09242a2b1d029b7ab5ab65e2aaca12f8"}, + {"source": "app.adapters.network.ip", "target": "socket.gethostbyname", "kind": "protocol_operation", "fingerprint": "a43e8969e2f26546fcf925b258728b309f1b9b966ff3c7b48a64273b8c82d048"} + ] + }, + { + "classification": "approved_exception", + "reason_code": "transport_configuration", + "owner": "$source", + "reason": "统一 HTTP Adapter 仅配置 urllib3 警告策略,不创建 urllib3 网络连接。", + "facts": [ + {"source": "app.adapters.network.http", "target": "urllib3", "kind": "raw_transport", "fingerprint": "66cbd8ec4e7552bd458db0baada30f1953e6d0493793822d23a2199567bca98a"} + ] + }, + { + "classification": "approved_exception", + "reason_code": "sdk_transport", + "owner": "$source", + "reason": "命名 SDK 或显式注入的 SDK transport 由该精确 source 持有,不授权新增调用方。", + "facts": [ + {"source": "app.agent.llm.capability", "target": "openai", "kind": "network_sdk", "fingerprint": "60160ab01b60be2c9794b9724a50b90f8875c6a10748ff9392a1e4bc7d48c334"}, + {"source": "app.agent.llm.helper", "target": "google.genai", "kind": "network_sdk", "fingerprint": "d41688ecda4c5de296fabb0d0d25c79a7b0f29f67ccdd44569dc1110e1db9b86"}, + {"source": "app.agent.llm.helper", "target": "httpx", "kind": "raw_transport", "fingerprint": "caeccd432f48d9a23948dd5b9220ddbebc57d2b2936bfa499fc7d4876e935ea8"}, + {"source": "app.agent.llm.helper", "target": "langchain_anthropic", "kind": "network_sdk", "fingerprint": "c8d2c3166c4d42d61e7ef38a25080cac0f93422f47393df6dc19b329a7f72265"}, + {"source": "app.agent.llm.helper", "target": "langchain_aws", "kind": "network_sdk", "fingerprint": "51dd02f5e2305cce4e1f6f29b4ed47edf54dde9f6557781178a6edf96bfeeb6b"}, + {"source": "app.agent.llm.helper", "target": "langchain_deepseek", "kind": "network_sdk", "fingerprint": "bcf8b1ffae0995cfc33cfe26609cef8014cd2ce036b845c2a88c0cd878b1ebc4"}, + {"source": "app.agent.llm.helper", "target": "langchain_google_genai", "kind": "network_sdk", "fingerprint": "79ae88c63908da9ea36dcfc23c4ad5345bc5d1977b80cc2e0b89292d063ed8ee"}, + {"source": "app.agent.llm.helper", "target": "langchain_openai", "kind": "network_sdk", "fingerprint": "15775bf1694780d5cf516e6cc3d3b5174ebb25e3038433132d3071e6abceba52"}, + {"source": "app.agent.llm.helper", "target": "openai", "kind": "network_sdk", "fingerprint": "dbebd5e9fa38b2a525a5a2e5a92d855ba9d05762410590bfb544e665fe38f620"}, + {"source": "app.agent.llm.provider", "target": "boto3", "kind": "network_sdk", "fingerprint": "04ca4b5ecbda1531266d5f53817dde061dcfdd3293cabf9301fc8d8e4c281a88"}, + {"source": "app.agent.llm.provider", "target": "botocore", "kind": "network_sdk", "fingerprint": "fbeaa702703665ca60233a153aade0da7e49dfb6fe31f72e5593c883fb7d3f11"}, + {"source": "app.agent.llm.provider", "target": "google.genai", "kind": "network_sdk", "fingerprint": "b545cffb169b87f87606c17762b5d9da3f35372bd411c1f4ebba010b3d7c3ebb"}, + {"source": "app.agent.llm.provider", "target": "openai", "kind": "network_sdk", "fingerprint": "ffac3b355eb640f6421299cf838cb4e24866a39afabd9dcfd8d0f340925dc1a8"}, + {"source": "app.agent.tools.impl.search_web", "target": "ddgs", "kind": "network_sdk", "fingerprint": "377e73bb3be804b825d60ad6e792344ca633de107f8c4dbb45d50a34a4dfa04b"}, + {"source": "app.api.endpoints.message", "target": "pywebpush", "kind": "network_sdk", "fingerprint": "7d83ca0dc89dfb6af1cdd7bc90d922105de94222e4e3a44bba4f53bbd97bc31b"}, + {"source": "app.modules.discord.discord", "target": "discord", "kind": "network_sdk", "fingerprint": "e1486525fedbfe574aa47d60b483426c8c48e4e27fa35dbe34f6cd2f47d37c86"}, + {"source": "app.modules.feishu.feishu", "target": "lark_oapi", "kind": "network_sdk", "fingerprint": "99d16968a59932b5980f6d42ea2bbc8fa35ccdb1a27933beec29b4bdc093d78b"}, + {"source": "app.modules.filemanager.storages.smb", "target": "smbclient", "kind": "network_sdk", "fingerprint": "62e0585282ef206ac81b2bf9e93a58423fb058685edc0ef4ee592453bbf30376"}, + {"source": "app.modules.filemanager.storages.u115", "target": "oss2", "kind": "network_sdk", "fingerprint": "f7b89c8ae6dad2603f0a9e0caaa159769aef5b3581d7e728f62445b979366eae"}, + {"source": "app.modules.plex.plex", "target": "plexapi", "kind": "network_sdk", "fingerprint": "76c1334863dc6c6623ce6ad3415bec5ff92656f51c31e4d982285898f193e57e"}, + {"source": "app.modules.qbittorrent.qbittorrent", "target": "qbittorrentapi", "kind": "network_sdk", "fingerprint": "b2f5a27f0c54cf95ed42fe99848fd6c9c0641caca8bc4ff8006a411cb427066d"}, + {"source": "app.modules.slack.slack", "target": "slack_bolt", "kind": "network_sdk", "fingerprint": "be5dcb032ece8d8627abeb243f98143aaf60f26751ab6e5e098813d4048419e6"}, + {"source": "app.modules.slack.slack", "target": "slack_sdk", "kind": "network_sdk", "fingerprint": "7559f31e4172ad3bbbaf161e1164ea48b997c56d05c77b49b82626298c39aa14"}, + {"source": "app.modules.telegram.telegram", "target": "telebot", "kind": "network_sdk", "fingerprint": "78f5ab18bfd67ba4fa0f3c0fc4a1a561a7b1d9e81e335fb80de86edb480b84b8"}, + {"source": "app.modules.transmission.transmission", "target": "transmission_rpc", "kind": "network_sdk", "fingerprint": "1652e661cb17dbadb039fdc4ab73d6d06e47eb118292ba3693313e45834959cf"}, + {"source": "app.modules.webpush", "target": "pywebpush", "kind": "network_sdk", "fingerprint": "389c73b06150e3d5bcaf31f35a25178873d2ed38ef9a28354cb9bab691eeab76"} + ] + }, + { + "classification": "approved_exception", + "reason_code": "streaming_protocol", + "owner": "$source", + "reason": "产品长连接或 SCGI/XML-RPC transport 由该协议模块精确持有。", + "facts": [ + {"source": "app.modules.qqbot.gateway", "target": "websocket", "kind": "raw_transport", "fingerprint": "6f6b7d61f3a95e620e67c450d544f0aa077f087a188d98f32e4501d94c6b37ae"}, + {"source": "app.modules.rtorrent.rtorrent", "target": "socket.create_connection", "kind": "protocol_operation", "fingerprint": "878114d6b1bda091bd3b3d8aa819831d84381682feba78267592d2593295af90"}, + {"source": "app.modules.rtorrent.rtorrent", "target": "xmlrpc.client.ServerProxy", "kind": "protocol_operation", "fingerprint": "2ac64b5670c930bc28bb2135cc4b73891c1cae01a74a9586f74e302878c45c85"}, + {"source": "app.modules.wechat.wechatbot", "target": "websocket", "kind": "raw_transport", "fingerprint": "1bae78270eadce0571e2caaa111a5c0a9065ba2da97ebb26b8a5b76d3ed5eef6"} + ] + }, + { + "classification": "approved_exception", + "reason_code": "contained_vendor", + "owner": "$source", + "reason": "TMDB 移植客户端在 containment 内创建 Session,并把实际请求交给 RequestUtils。", + "facts": [ + {"source": "app.modules.themoviedb.tmdbv3api.tmdb", "target": "requests", "kind": "raw_transport", "fingerprint": "d605eb176a203b3f4d205c5d183469b3002426682eb9cb4e7408bb6013652484"} + ] + }, + { + "classification": "approved_exception", + "reason_code": "local_control_plane", + "owner": "$source", + "reason": "CLI 只调用本机 MoviePilot 控制面,维持精确运维入口。", + "facts": [ + {"source": "app.cli", "target": "urllib.request", "kind": "raw_transport", "fingerprint": "71b3e5be2a7d85fdc7a207d15c0e690d7ab96b335b882a3eaec03a1cb4ec1d1b"} + ] + }, + { + "classification": "approved_exception", + "reason_code": "diagnostic_probe", + "owner": "$source", + "reason": "doctor 按用户配置探测 PostgreSQL 与后端健康端点,可能指向远端,仅用于显式诊断。", + "facts": [ + {"source": "app.doctor.checks", "target": "socket.create_connection", "kind": "protocol_operation", "fingerprint": "dfb34d4710353029dcc06e0e0ce3b299bae1a7e71a5743de1d253ac26380f9b6"}, + {"source": "app.doctor.checks", "target": "urllib.request", "kind": "raw_transport", "fingerprint": "d8dd2279263ef58c3b4c59c70451533a4fc27d2ccd76960ca3aff29cd73f3354"} + ] + }, + { + "classification": "approved_exception", + "reason_code": "type_or_compat_only", + "owner": "$source", + "reason": "运行期 import 仅用于响应类型、协议解析或兼容补丁,uses 为空或不获取网络能力。", + "facts": [ + {"source": "app.adapters.external.market", "target": "httpx2", "kind": "raw_transport", "fingerprint": "d4a648e8188818c0465013fc63cc3e49899da4df38541344303c251896564b1f"}, + {"source": "app.adapters.external.market", "target": "requests", "kind": "raw_transport", "fingerprint": "ecc5368adfced20741e5ed8696008ea7555a4a81ceda6d7149e09f5f3d0ff7e3"}, + {"source": "app.modules.douban.apiv2", "target": "httpx2", "kind": "raw_transport", "fingerprint": "edc337b12976d978cad3f83d0fa6faf71b2af2d814926dff890d5acc94f0a0a6"}, + {"source": "app.modules.emby.emby", "target": "requests", "kind": "raw_transport", "fingerprint": "6c4c17fe170226ea1272f5859bc119c2775442cabe8ce2a9edcabaf9c37bec31"}, + {"source": "app.modules.filemanager.storages.smb", "target": "smbprotocol", "kind": "network_sdk", "fingerprint": "f02686afd99c59820dffa7b4c2627a0be1ad62980c98a9cd33b7564697842b22"}, + {"source": "app.modules.jellyfin.jellyfin", "target": "requests", "kind": "raw_transport", "fingerprint": "5c46d09ca9a4bcc0bae21ca5d554ef09baa3f901c562b27c5f5ee1439c7746b5"}, + {"source": "app.modules.rtorrent.rtorrent", "target": "http.client", "kind": "raw_transport", "fingerprint": "6adc93b3bc479dfe81197554c7977930a60abc51714f14ef29298eb40730aaf7"}, + {"source": "app.modules.telegram.compat", "target": "urllib3", "kind": "raw_transport", "fingerprint": "18862bbdb252b59573f57ea776b5d64bfb775e7739603fecd23cc8f2c38a7e0d"}, + {"source": "app.modules.zspace.zspace", "target": "requests", "kind": "raw_transport", "fingerprint": "9df3fd27b9696d45a72e7c8f67b5a9ad79a7371d1fe690bbaa17485bd1960d51"}, + {"source": "app.startup.lifecycle", "target": "urllib3", "kind": "raw_transport", "fingerprint": "cb6f0a314aeb1e2d3e76c240aa20460ac0c36d9f5c18c1a6ea3170f64dd3366b"} + ] + }, + { + "classification": "approved_exception", + "reason_code": "daemon_control_plane", + "owner": "$source", + "reason": "runtime state 连接配置指定的 Docker daemon 控制面,并由该进程能力 owner 收口。", + "facts": [ + {"source": "app.runtime.state", "target": "docker", "kind": "network_sdk", "fingerprint": "20a91ec521f7dfe6a0153dfd8ea49c4bac7f0a16b55f1c0655dfb33f54a01215"} + ] + }, + { + "classification": "approved_exception", + "reason_code": "test_network_guard", + "owner": "$source", + "reason": "测试守卫包装 getaddrinfo 以拒绝真实外联,不执行新的 DNS 解析策略。", + "facts": [ + {"source": "app.testing.network_guard", "target": "socket.getaddrinfo", "kind": "protocol_operation", "fingerprint": "2518de211c9ba32ccbc004cf58fe9c98837fe8ac95fe8ffb6ea82897b28d753f"} + ] + } + ] } } diff --git a/tests/fixtures/architecture/mypy-baseline.json b/tests/fixtures/architecture/mypy-baseline.json index 9cb172ad5..76c498ce8 100644 --- a/tests/fixtures/architecture/mypy-baseline.json +++ b/tests/fixtures/architecture/mypy-baseline.json @@ -1462,8 +1462,7 @@ }, "app/chain/media.py": { "arg-type": 34, - "assignment": 16, - "call-overload": 1, + "assignment": 15, "comparison-overlap": 3, "no-any-return": 3, "no-untyped-def": 3, diff --git a/tests/test_architecture_adapter_imports.py b/tests/test_architecture_adapter_imports.py index 6ae594e38..6dc128d69 100644 --- a/tests/test_architecture_adapter_imports.py +++ b/tests/test_architecture_adapter_imports.py @@ -255,7 +255,7 @@ def test_current_direct_adapter_imports_match_temporary_debt_policy() -> None: adapter_policy = policy["direct_adapter_imports"] entries = adapter_policy["entries"] - assert policy["schema_version"] == 2 + assert policy["schema_version"] == 3 assert adapter_policy["classification"] == "temporary_debt" assert adapter_policy["target_state"] == "empty" assert _adapter_policy_scope_errors(adapter_policy["scope"], contract["scope"]) == [] diff --git a/tests/test_architecture_baseline_cli.py b/tests/test_architecture_baseline_cli.py index 0de00126d..9742b59b8 100644 --- a/tests/test_architecture_baseline_cli.py +++ b/tests/test_architecture_baseline_cli.py @@ -451,7 +451,10 @@ def test_architecture_write_host_only_updates_host_files( dependency_path = tmp_path / "dependency.json" runtime_path = tmp_path / "runtime.json" transaction_path = tmp_path / "transaction.json" + configuration_path = tmp_path / "configuration.json" + policy_path = tmp_path / "dependency-policy.json" plugin_path = tmp_path / "plugin.json" + policy_path.write_text('{"manual": true}\n', encoding="utf-8") monkeypatch.setattr( architecture_baseline, "DEPENDENCY_BASELINE_PATH", @@ -467,6 +470,16 @@ def test_architecture_write_host_only_updates_host_files( "TRANSACTION_BASELINE_PATH", transaction_path, ) + monkeypatch.setattr( + architecture_baseline, + "CONFIGURATION_BASELINE_PATH", + configuration_path, + ) + monkeypatch.setattr( + architecture_baseline, + "DEPENDENCY_POLICY_PATH", + policy_path, + ) monkeypatch.setattr(architecture_baseline, "PLUGIN_BASELINE_PATH", plugin_path) monkeypatch.setattr( architecture_baseline, @@ -483,6 +496,11 @@ def test_architecture_write_host_only_updates_host_files( "collect_transaction_debt_baseline", lambda: {"scope": "host-transaction"}, ) + monkeypatch.setattr( + architecture_baseline, + "collect_configuration_debt_baseline", + lambda: {"scope": "host-configuration"}, + ) assert architecture_baseline.main(["--write-host"]) == 0 @@ -491,12 +509,18 @@ def test_architecture_write_host_only_updates_host_files( assert json.loads(transaction_path.read_text()) == { "scope": "host-transaction" } + assert json.loads(configuration_path.read_text()) == { + "scope": "host-configuration" + } + assert json.loads(policy_path.read_text()) == {"manual": True} assert not plugin_path.exists() output = capsys.readouterr().out assert "即将写入" in output assert "dependency.json" in output assert "runtime.json" in output assert "transaction.json" in output + assert "configuration.json" in output + assert "dependency-policy.json" not in output def test_architecture_write_plugins_only_updates_plugin_file( diff --git a/tests/test_architecture_ci.py b/tests/test_architecture_ci.py index f54ecb195..43146266b 100644 --- a/tests/test_architecture_ci.py +++ b/tests/test_architecture_ci.py @@ -32,6 +32,7 @@ def test_unit_test_workflow_has_independent_host_architecture_gate(): 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_egress.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 915dd858c..49d40db5e 100644 --- a/tests/test_architecture_contract_baseline.py +++ b/tests/test_architecture_contract_baseline.py @@ -64,7 +64,7 @@ def test_dependency_baseline_records_nonempty_host_graph() -> None: baseline_path = BASELINE_ROOT / "dependency-baseline.json" baseline = json.loads(baseline_path.read_text(encoding="utf-8")) - assert baseline["schema_version"] == 2 + assert baseline["schema_version"] == 3 assert baseline["module_count"] == len(baseline["modules"]) assert baseline["edge_count"] == len(baseline["edges"]) assert baseline["module_count"] > 0 @@ -78,6 +78,19 @@ def test_dependency_baseline_records_nonempty_host_graph() -> None: } assert direct_imports["source_count"] == len(direct_imports["sources"]) assert direct_imports["target_count"] == len(direct_imports["targets"]) + direct_egress = baseline["direct_egress"] + assert direct_egress["count"] == len(direct_egress["entries"]) + assert sum(direct_egress["counts_by_kind"].values()) == direct_egress["count"] + assert set(direct_egress["counts_by_kind"]) == { + "raw_transport", + "network_sdk", + "protocol_operation", + } + assert set(direct_egress["application_chain_counts"]) == { + "app.application", + "app.chain", + } + assert all("line" not in entry for entry in direct_egress["entries"]) 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 c09430d14..e290fe8af 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"] == 2 + assert policy["schema_version"] == 3 assert policy["scope"] == { "dependency_kind": "static_runtime_imports", "excluded_roots": ["app/plugins"], diff --git a/tests/test_architecture_egress.py b/tests/test_architecture_egress.py new file mode 100644 index 000000000..8121a28d3 --- /dev/null +++ b/tests/test_architecture_egress.py @@ -0,0 +1,925 @@ +"""宿主 direct egress 事实与人工政策契约。""" + +import json +from pathlib import Path + +from scripts.architecture.baseline import collect_dependency_baseline +from scripts.architecture.egress import collect_direct_egress + +PROJECT_ROOT = Path(__file__).parents[1] +DEPENDENCY_POLICY_PATH = ( + PROJECT_ROOT / "tests" / "fixtures" / "architecture" / "dependency-policy.json" +) +FROZEN_EGRESS_EDGES_BY_REASON = { + "direct_http": { + ("app.agent.llm.provider", "httpx"), + ("app.modules.filemanager.storages.alipan", "requests"), + ("app.modules.filemanager.storages.u115", "httpx"), + ("app.modules.ugreen.api", "requests"), + }, + "requestutils_session_bridge": { + ("app.modules.bangumi.bangumi", "requests"), + ("app.modules.douban.apiv2", "requests"), + ("app.modules.imdb.api", "requests"), + ("app.modules.indexer.parser", "requests"), + ("app.modules.musicbrainz", "requests"), + ("app.modules.plex.plex", "requests"), + ("app.modules.trimemedia.api", "requests"), + }, + "direct_dns": { + ("app.application.security.url", "socket.getaddrinfo"), + }, + "canonical_transport": { + ("app.adapters.cache.redis", "redis"), + ("app.adapters.network.browser", "cloakbrowser"), + ("app.adapters.network.doh", "socket.getaddrinfo"), + ("app.adapters.network.doh", "urllib.request"), + ("app.adapters.network.http", "httpx2"), + ("app.adapters.network.http", "requests"), + ("app.adapters.network.ip", "socket.gethostbyname"), + }, + "transport_configuration": { + ("app.adapters.network.http", "urllib3"), + }, + "sdk_transport": { + ("app.agent.llm.capability", "openai"), + ("app.agent.llm.helper", "google.genai"), + ("app.agent.llm.helper", "httpx"), + ("app.agent.llm.helper", "langchain_anthropic"), + ("app.agent.llm.helper", "langchain_aws"), + ("app.agent.llm.helper", "langchain_deepseek"), + ("app.agent.llm.helper", "langchain_google_genai"), + ("app.agent.llm.helper", "langchain_openai"), + ("app.agent.llm.helper", "openai"), + ("app.agent.llm.provider", "boto3"), + ("app.agent.llm.provider", "botocore"), + ("app.agent.llm.provider", "google.genai"), + ("app.agent.llm.provider", "openai"), + ("app.agent.tools.impl.search_web", "ddgs"), + ("app.api.endpoints.message", "pywebpush"), + ("app.modules.discord.discord", "discord"), + ("app.modules.feishu.feishu", "lark_oapi"), + ("app.modules.filemanager.storages.smb", "smbclient"), + ("app.modules.filemanager.storages.u115", "oss2"), + ("app.modules.plex.plex", "plexapi"), + ("app.modules.qbittorrent.qbittorrent", "qbittorrentapi"), + ("app.modules.slack.slack", "slack_bolt"), + ("app.modules.slack.slack", "slack_sdk"), + ("app.modules.telegram.telegram", "telebot"), + ("app.modules.transmission.transmission", "transmission_rpc"), + ("app.modules.webpush", "pywebpush"), + }, + "streaming_protocol": { + ("app.modules.qqbot.gateway", "websocket"), + ("app.modules.rtorrent.rtorrent", "socket.create_connection"), + ("app.modules.rtorrent.rtorrent", "xmlrpc.client.ServerProxy"), + ("app.modules.wechat.wechatbot", "websocket"), + }, + "contained_vendor": { + ("app.modules.themoviedb.tmdbv3api.tmdb", "requests"), + }, + "local_control_plane": { + ("app.cli", "urllib.request"), + }, + "diagnostic_probe": { + ("app.doctor.checks", "socket.create_connection"), + ("app.doctor.checks", "urllib.request"), + }, + "type_or_compat_only": { + ("app.adapters.external.market", "httpx2"), + ("app.adapters.external.market", "requests"), + ("app.modules.douban.apiv2", "httpx2"), + ("app.modules.emby.emby", "requests"), + ("app.modules.filemanager.storages.smb", "smbprotocol"), + ("app.modules.jellyfin.jellyfin", "requests"), + ("app.modules.rtorrent.rtorrent", "http.client"), + ("app.modules.telegram.compat", "urllib3"), + ("app.modules.zspace.zspace", "requests"), + ("app.startup.lifecycle", "urllib3"), + }, + "daemon_control_plane": { + ("app.runtime.state", "docker"), + }, + "test_network_guard": { + ("app.testing.network_guard", "socket.getaddrinfo"), + }, +} +FROZEN_EGRESS_REASON_BY_EDGE = { + edge: reason + for reason, edges in FROZEN_EGRESS_EDGES_BY_REASON.items() + for edge in edges +} +FROZEN_EGRESS_FINGERPRINT_BY_EDGE = { + ("app.adapters.cache.redis", "redis"): "9d455a5298d4373ff18d74a9d498a3dd797bcb5f5543af9776c0c741c30362c7", + ("app.adapters.external.market", "httpx2"): "d4a648e8188818c0465013fc63cc3e49899da4df38541344303c251896564b1f", + ("app.adapters.external.market", "requests"): "ecc5368adfced20741e5ed8696008ea7555a4a81ceda6d7149e09f5f3d0ff7e3", + ("app.adapters.network.browser", "cloakbrowser"): "15c1777b14eb9147d6cab9783f67577011714f9220600dda5db5269b59726173", + ("app.adapters.network.doh", "socket.getaddrinfo"): "4ff03419dfacc6bf582b7d4421dd5a0666a63f8ca79be2b1e625f4c8f4c96b71", + ("app.adapters.network.doh", "urllib.request"): "6f5f5fd3da02a9e780ea5e7cc1e47bd962314a1a358f14b4ee698485f96ab52b", + ("app.adapters.network.http", "httpx2"): "a70799e9930ff79cd28ebed92836c8108cd2d18bc668e0081f68533d84f69d0d", + ("app.adapters.network.http", "requests"): "95fb4576cec363355f40aa1758d90fbb09242a2b1d029b7ab5ab65e2aaca12f8", + ("app.adapters.network.http", "urllib3"): "66cbd8ec4e7552bd458db0baada30f1953e6d0493793822d23a2199567bca98a", + ("app.adapters.network.ip", "socket.gethostbyname"): "a43e8969e2f26546fcf925b258728b309f1b9b966ff3c7b48a64273b8c82d048", + ("app.agent.llm.capability", "openai"): "60160ab01b60be2c9794b9724a50b90f8875c6a10748ff9392a1e4bc7d48c334", + ("app.agent.llm.helper", "google.genai"): "d41688ecda4c5de296fabb0d0d25c79a7b0f29f67ccdd44569dc1110e1db9b86", + ("app.agent.llm.helper", "httpx"): "caeccd432f48d9a23948dd5b9220ddbebc57d2b2936bfa499fc7d4876e935ea8", + ("app.agent.llm.helper", "langchain_anthropic"): "c8d2c3166c4d42d61e7ef38a25080cac0f93422f47393df6dc19b329a7f72265", + ("app.agent.llm.helper", "langchain_aws"): "51dd02f5e2305cce4e1f6f29b4ed47edf54dde9f6557781178a6edf96bfeeb6b", + ("app.agent.llm.helper", "langchain_deepseek"): "bcf8b1ffae0995cfc33cfe26609cef8014cd2ce036b845c2a88c0cd878b1ebc4", + ("app.agent.llm.helper", "langchain_google_genai"): "79ae88c63908da9ea36dcfc23c4ad5345bc5d1977b80cc2e0b89292d063ed8ee", + ("app.agent.llm.helper", "langchain_openai"): "15775bf1694780d5cf516e6cc3d3b5174ebb25e3038433132d3071e6abceba52", + ("app.agent.llm.helper", "openai"): "dbebd5e9fa38b2a525a5a2e5a92d855ba9d05762410590bfb544e665fe38f620", + ("app.agent.llm.provider", "boto3"): "04ca4b5ecbda1531266d5f53817dde061dcfdd3293cabf9301fc8d8e4c281a88", + ("app.agent.llm.provider", "botocore"): "fbeaa702703665ca60233a153aade0da7e49dfb6fe31f72e5593c883fb7d3f11", + ("app.agent.llm.provider", "google.genai"): "b545cffb169b87f87606c17762b5d9da3f35372bd411c1f4ebba010b3d7c3ebb", + ("app.agent.llm.provider", "httpx"): "a68ad216d86b4b33aa8c35572c26f7993245c0c12dd512e962bcacb46d7f15fc", + ("app.agent.llm.provider", "openai"): "ffac3b355eb640f6421299cf838cb4e24866a39afabd9dcfd8d0f340925dc1a8", + ("app.agent.tools.impl.search_web", "ddgs"): "377e73bb3be804b825d60ad6e792344ca633de107f8c4dbb45d50a34a4dfa04b", + ("app.api.endpoints.message", "pywebpush"): "7d83ca0dc89dfb6af1cdd7bc90d922105de94222e4e3a44bba4f53bbd97bc31b", + ("app.application.security.url", "socket.getaddrinfo"): "89c563fdb640259584563fc718f7104a7b4dc6717e97b9c9e449f70f74a9532b", + ("app.cli", "urllib.request"): "71b3e5be2a7d85fdc7a207d15c0e690d7ab96b335b882a3eaec03a1cb4ec1d1b", + ("app.doctor.checks", "socket.create_connection"): "dfb34d4710353029dcc06e0e0ce3b299bae1a7e71a5743de1d253ac26380f9b6", + ("app.doctor.checks", "urllib.request"): "d8dd2279263ef58c3b4c59c70451533a4fc27d2ccd76960ca3aff29cd73f3354", + ("app.modules.bangumi.bangumi", "requests"): "e5baf8a89b8661025169e9eec8d3c13d30660f63b35d94ec4896cd97e7fde3bf", + ("app.modules.discord.discord", "discord"): "e1486525fedbfe574aa47d60b483426c8c48e4e27fa35dbe34f6cd2f47d37c86", + ("app.modules.douban.apiv2", "httpx2"): "edc337b12976d978cad3f83d0fa6faf71b2af2d814926dff890d5acc94f0a0a6", + ("app.modules.douban.apiv2", "requests"): "6e871a27b88263676797edd65093d0b38db48f77fb5db2bb581b7b82e9b2977d", + ("app.modules.emby.emby", "requests"): "6c4c17fe170226ea1272f5859bc119c2775442cabe8ce2a9edcabaf9c37bec31", + ("app.modules.feishu.feishu", "lark_oapi"): "99d16968a59932b5980f6d42ea2bbc8fa35ccdb1a27933beec29b4bdc093d78b", + ("app.modules.filemanager.storages.alipan", "requests"): "82d84425f58ed9664a6e2a4ece53240198c95a0f9fee5d0afd0a11e3c459ecf6", + ("app.modules.filemanager.storages.smb", "smbclient"): "62e0585282ef206ac81b2bf9e93a58423fb058685edc0ef4ee592453bbf30376", + ("app.modules.filemanager.storages.smb", "smbprotocol"): "f02686afd99c59820dffa7b4c2627a0be1ad62980c98a9cd33b7564697842b22", + ("app.modules.filemanager.storages.u115", "httpx"): "f6678109963f9973d5d15e38e5f5e8dca01ec5e0117598ad1ac877b841ff7961", + ("app.modules.filemanager.storages.u115", "oss2"): "f7b89c8ae6dad2603f0a9e0caaa159769aef5b3581d7e728f62445b979366eae", + ("app.modules.imdb.api", "requests"): "3d05532e62d2cddf3bb05495196176b76aa02216c2b0d868cf9281d2257d9627", + ("app.modules.indexer.parser", "requests"): "facdb091442bacba0fc0f631637643e95e18545bd7ab63f1d49d67a9a928d016", + ("app.modules.jellyfin.jellyfin", "requests"): "5c46d09ca9a4bcc0bae21ca5d554ef09baa3f901c562b27c5f5ee1439c7746b5", + ("app.modules.musicbrainz", "requests"): "c54a10dd672fb0ebb32a536ae42c221ad434313466cc108d43e433dd84cac9a5", + ("app.modules.plex.plex", "plexapi"): "76c1334863dc6c6623ce6ad3415bec5ff92656f51c31e4d982285898f193e57e", + ("app.modules.plex.plex", "requests"): "93f20b9c01571553e9601f4b9f4ee9471073e65f938cb635320f72fa41ec3843", + ("app.modules.qbittorrent.qbittorrent", "qbittorrentapi"): "b2f5a27f0c54cf95ed42fe99848fd6c9c0641caca8bc4ff8006a411cb427066d", + ("app.modules.qqbot.gateway", "websocket"): "6f6b7d61f3a95e620e67c450d544f0aa077f087a188d98f32e4501d94c6b37ae", + ("app.modules.rtorrent.rtorrent", "http.client"): "6adc93b3bc479dfe81197554c7977930a60abc51714f14ef29298eb40730aaf7", + ("app.modules.rtorrent.rtorrent", "socket.create_connection"): "878114d6b1bda091bd3b3d8aa819831d84381682feba78267592d2593295af90", + ("app.modules.rtorrent.rtorrent", "xmlrpc.client.ServerProxy"): "2ac64b5670c930bc28bb2135cc4b73891c1cae01a74a9586f74e302878c45c85", + ("app.modules.slack.slack", "slack_bolt"): "be5dcb032ece8d8627abeb243f98143aaf60f26751ab6e5e098813d4048419e6", + ("app.modules.slack.slack", "slack_sdk"): "7559f31e4172ad3bbbaf161e1164ea48b997c56d05c77b49b82626298c39aa14", + ("app.modules.telegram.compat", "urllib3"): "18862bbdb252b59573f57ea776b5d64bfb775e7739603fecd23cc8f2c38a7e0d", + ("app.modules.telegram.telegram", "telebot"): "78f5ab18bfd67ba4fa0f3c0fc4a1a561a7b1d9e81e335fb80de86edb480b84b8", + ("app.modules.themoviedb.tmdbv3api.tmdb", "requests"): "d605eb176a203b3f4d205c5d183469b3002426682eb9cb4e7408bb6013652484", + ("app.modules.transmission.transmission", "transmission_rpc"): "1652e661cb17dbadb039fdc4ab73d6d06e47eb118292ba3693313e45834959cf", + ("app.modules.trimemedia.api", "requests"): "aecbe00404b2866c918c939bee5f1002045fc72aed4440c85e0811a4379913e4", + ("app.modules.ugreen.api", "requests"): "a266dd2b74f216b289682e5d83c0b5a7d0d08b5447d79c165659b6d7302656d6", + ("app.modules.webpush", "pywebpush"): "389c73b06150e3d5bcaf31f35a25178873d2ed38ef9a28354cb9bab691eeab76", + ("app.modules.wechat.wechatbot", "websocket"): "1bae78270eadce0571e2caaa111a5c0a9065ba2da97ebb26b8a5b76d3ed5eef6", + ("app.modules.zspace.zspace", "requests"): "9df3fd27b9696d45a72e7c8f67b5a9ad79a7371d1fe690bbaa17485bd1960d51", + ("app.runtime.state", "docker"): "20a91ec521f7dfe6a0153dfd8ea49c4bac7f0a16b55f1c0655dfb33f54a01215", + ("app.startup.lifecycle", "urllib3"): "cb6f0a314aeb1e2d3e76c240aa20460ac0c36d9f5c18c1a6ea3170f64dd3366b", + ("app.testing.network_guard", "socket.getaddrinfo"): "2518de211c9ba32ccbc004cf58fe9c98837fe8ac95fe8ffb6ea82897b28d753f", +} + + +def _module(tmp_path: Path, name: str, content: str) -> dict[str, Path]: + """创建供 direct egress collector 使用的独立模块。""" + path = tmp_path / f"{name.replace('.', '_')}.py" + path.write_text(content, encoding="utf-8") + return {name: path} + + +def _entries_by_target(value: dict[str, object]) -> dict[str, dict[str, object]]: + """把单 source 合成事实按 target 建索引。""" + return {entry["target"]: entry for entry in value["entries"]} + + +def _policy_facts(groups: list[dict[str, object]]) -> list[dict[str, str]]: + """展开按语义分组保存的人工 egress fact 引用。""" + return [fact for group in groups for fact in group["facts"]] + + +def _policy_differences( + facts: list[dict[str, object]], + reviewed: list[dict[str, str]], +) -> tuple[list[str], list[str]]: + """按完整事实指纹返回未审查事实和陈旧人工 policy。""" + actual = {str(fact["fingerprint"]) for fact in facts} + expected = {fact["fingerprint"] for fact in reviewed} + return sorted(actual - expected), sorted(expected - actual) + + +def _classification_differences( + facts: list[dict[str, object]], + groups: list[dict[str, object]], +) -> tuple[list[tuple[str, str]], list[tuple[str, str]], list[tuple[str, str]]]: + """返回新增边、陈旧分类和与初始语义不符的分类。""" + actual = {(str(fact["source"]), str(fact["target"])) for fact in facts} + reviewed = { + (str(fact["source"]), str(fact["target"])): str(group["reason_code"]) + for group in groups + for fact in group["facts"] + } + unexpected = sorted(actual - FROZEN_EGRESS_REASON_BY_EDGE.keys()) + stale = sorted(reviewed.keys() - actual) + misclassified = sorted( + edge + for edge in actual & reviewed.keys() & FROZEN_EGRESS_REASON_BY_EDGE.keys() + if reviewed[edge] != FROZEN_EGRESS_REASON_BY_EDGE[edge] + ) + return unexpected, stale, misclassified + + +def _fingerprint_differences( + facts: list[dict[str, object]], +) -> list[tuple[str, str]]: + """返回调用面已超过初始上界的 direct egress 边。""" + actual = { + (str(fact["source"]), str(fact["target"])): str(fact["fingerprint"]) + for fact in facts + } + return sorted( + edge + for edge, fingerprint in actual.items() + if FROZEN_EGRESS_FINGERPRINT_BY_EDGE.get(edge) != fingerprint + ) + + +def test_egress_collector_distinguishes_bindings_and_runtime_uses( + tmp_path: Path, +) -> None: + """类型导入、真实调用、lazy import、re-export 与 SDK 必须可区分。""" + modules = _module( + tmp_path, + "app.sample", + ''' +from typing import TYPE_CHECKING +import requests as req +from requests import Response +import httpx +from urllib import request as urlrequest +from app.adapters.network.http import requests as bridged +from google import genai + +if TYPE_CHECKING: + import cloudscraper + +EXAMPLE = "requests.get('https://docstring.invalid')" + +def run(): + import aiohttp + session = req.Session() + session.get("https://example.com") + client = httpx.AsyncClient() + client.stream("GET", "https://example.com") + urlrequest.urlopen("https://example.com") + bridged.Session() + genai.Client() + aiohttp.ClientSession() + unknown.get("https://ignored.example") +''', + ) + + value = collect_direct_egress(modules) + entries = _entries_by_target(value) + + assert set(entries) == { + "aiohttp", + "google.genai", + "httpx", + "requests", + "urllib.request", + } + assert entries["requests"]["bindings"] == [ + "from:requests.Response as Response", + "import:requests as req", + "reexport:app.adapters.network.http.requests as bridged", + ] + assert entries["requests"]["uses"] == [ + "run|call:Session", + "run|call:Session", + "run|call:get", + ] + assert entries["httpx"]["uses"] == [ + "run|call:AsyncClient", + "run|call:stream", + ] + assert entries["urllib.request"]["uses"] == ["run|call:urlopen"] + assert entries["google.genai"]["kind"] == "network_sdk" + assert entries["aiohttp"]["uses"] == ["run|call:ClientSession"] + assert all("line" not in entry for entry in value["entries"]) + + +def test_egress_collector_preserves_type_only_runtime_import(tmp_path: Path) -> None: + """用于响应类型的运行期 import 必须保留,但不能伪报为外呼操作。""" + value = collect_direct_egress( + _module(tmp_path, "app.sample", "from requests import Response\n") + ) + + assert value["entries"] == [ + { + "source": "app.sample", + "target": "requests", + "kind": "raw_transport", + "bindings": ["from:requests.Response as Response"], + "uses": [], + "fingerprint": value["entries"][0]["fingerprint"], + } + ] + + +def test_egress_collector_tracks_aliases_contexts_and_protocol_operations( + tmp_path: Path, +) -> None: + """Client 别名、上下文实例、socket 与 XML-RPC 获取都进入稳定 uses。""" + value = collect_direct_egress( + _module( + tmp_path, + "app.sample", + ''' +import httpx +import requests +import socket +import xmlrpc.client +from urllib.parse import urlparse + +def sync_call(flag): + client_cls = requests.Session if flag else requests.Session + client = client_cls() + client.post("https://example.com") + socket.create_connection(("localhost", 1)) + xmlrpc.client.ServerProxy("http://localhost") + urlparse("https://not-egress.example") + +async def async_call(): + async with httpx.AsyncClient() as client: + await client.get("https://example.com") +''', + ) + ) + entries = _entries_by_target(value) + + assert entries["requests"]["uses"] == [ + "sync_call|call:__call__", + "sync_call|call:post", + ] + assert entries["httpx"]["uses"] == [ + "async_call|call:AsyncClient", + "async_call|call:get", + ] + assert entries["socket.create_connection"]["kind"] == "protocol_operation" + assert entries["xmlrpc.client.ServerProxy"]["kind"] == "protocol_operation" + assert "urllib.request" not in entries + + +def test_egress_collector_keeps_lexical_scopes_and_merges_branches( + tmp_path: Path, +) -> None: + """函数 lazy import 不得泄漏,分支与 self client 必须保留可证明来源。""" + value = collect_direct_egress( + _module( + tmp_path, + "app.sample", + ''' +import requests + +def first(): + import httpx as client + client.get("https://example.com") + +def second(client): + client.get("https://ignored.example") + +def branch(flag): + if flag: + requester = requests.request + else: + requester = unknown + requester("GET", "https://example.com") + +class Service: + def __init__(self): + self.client = requests.Session() + + def run(self): + self.client.get("https://example.com") +''', + ) + ) + entries = _entries_by_target(value) + + assert entries["httpx"]["uses"] == ["first|call:get"] + assert entries["requests"]["uses"] == [ + "Service.__init__|call:Session", + "Service.run|call:get", + "branch|call:request", + ] + + +def test_egress_collector_preserves_wildcard_chains_and_duplicate_calls( + tmp_path: Path, +) -> None: + """高风险 wildcard、链式 client 和重复操作都必须改变事实 identity。""" + value = collect_direct_egress( + _module( + tmp_path, + "app.sample", + ''' +from requests import * +import requests + +def run(): + requests.Session().get("https://example.com/one") + requests.get("https://example.com/two") + requests.get("https://example.com/three") +''', + ) + ) + entry = _entries_by_target(value)["requests"] + + assert entry["bindings"] == [ + "from:requests.* as *", + "import:requests as requests", + ] + assert entry["uses"] == [ + "run|call:Session", + "run|call:get", + "run|call:get", + "run|call:get", + ] + + +def test_egress_collector_tracks_annotations_class_order_and_union_branches( + tmp_path: Path, +) -> None: + """注入参数、后置初始化与双 transport 分支都必须保留 operation。""" + value = collect_direct_egress( + _module( + tmp_path, + "app.sample", + ''' +import httpx +import requests + +async def injected(client: httpx.AsyncClient): + await client.get("https://example.com") + +class Service: + def run(self): + self.client.post("https://example.com") + + def __init__(self): + try: + self.client = requests.Session() + except Exception: + self.client = None + +def branch(flag): + if flag: + client = requests.Session() + else: + client = httpx.Client() + client.get("https://example.com") +''', + ) + ) + entries = _entries_by_target(value) + + assert entries["requests"]["uses"] == [ + "Service.__init__|call:Session", + "Service.run|call:post", + "branch|call:Session", + "branch|call:get", + ] + assert entries["httpx"]["uses"] == [ + "branch|call:Client", + "branch|call:get", + "injected|call:get", + ] + + +def test_egress_collector_tracks_dynamic_and_late_bound_modules( + tmp_path: Path, +) -> None: + """注册目标的字面量动态导入与后置模块别名不能绕过门禁。""" + value = collect_direct_egress( + _module( + tmp_path, + "app.sample", + ''' +import importlib +import requests + +def late_bound(): + transport.post("https://example.com") + +def dynamic(): + importlib.import_module("httpx").get("https://example.com") + +def builtin_dynamic(): + __import__("httpx").post("https://example.com") + +transport = requests +''', + ) + ) + entries = _entries_by_target(value) + + assert entries["requests"]["uses"] == ["late_bound|call:post"] + assert entries["httpx"]["bindings"] == [ + "dynamic:__import__(httpx)", + "dynamic:importlib.import_module(httpx)" + ] + assert entries["httpx"]["uses"] == [ + "builtin_dynamic|call:dynamic-import", + "builtin_dynamic|call:post", + "dynamic|call:dynamic-import", + "dynamic|call:get", + ] + + +def test_egress_collector_respects_local_shadow_scopes(tmp_path: Path) -> None: + """lambda、for、comprehension 与 except-as 局部名不得污染 import provenance。""" + value = collect_direct_egress( + _module( + tmp_path, + "app.sample", + ''' +import requests + +def run(): + for requests in []: + requests.get("https://ignored.example") + [requests.get("https://ignored.example") for requests in []] + (lambda requests: requests.get("https://ignored.example"))(object()) + try: + raise RuntimeError + except RuntimeError as requests: + requests.get("https://ignored.example") +''', + ) + ) + + assert _entries_by_target(value)["requests"]["uses"] == [] + + +def test_egress_collector_stops_at_response_operations(tmp_path: Path) -> None: + """直接请求进入事实,但响应 json/text 解析不是新的 egress operation。""" + value = collect_direct_egress( + _module( + tmp_path, + "app.sample", + ''' +import requests + +def run(): + requests.get("https://example.com").json() +''', + ) + ) + + assert _entries_by_target(value)["requests"]["uses"] == ["run|call:get"] + + +def test_egress_collector_tracks_local_wrapper_injections(tmp_path: Path) -> None: + """本地包装函数接收的可证明 client 必须传播,未知调用不得伪造来源。""" + value = collect_direct_egress( + _module( + tmp_path, + "app.sample", + ''' +import httpx +import requests + +def helper(client): + client.get("https://example.com") + +def run(): + requests_client = requests.Session() + httpx_client = httpx.Client() + helper(requests_client) + helper(httpx_client) + helper(object()) +''', + ) + ) + entries = _entries_by_target(value) + + assert entries["requests"]["uses"] == [ + "helper|call:get", + "run|call:Session", + ] + assert entries["httpx"]["uses"] == [ + "helper|call:get", + "run|call:Client", + ] + + +def test_egress_collector_handles_runtime_else_and_final_module_binding( + tmp_path: Path, +) -> None: + """TYPE_CHECKING else 是运行期路径,函数使用模块最终绑定。""" + value = collect_direct_egress( + _module( + tmp_path, + "app.sample", + ''' +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import httpx as transport +else: + import requests as transport + +captured = transport +transport = object() + +def run(): + captured.get("https://example.com") + transport.get("https://ignored.example") +''', + ) + ) + + assert _entries_by_target(value)["requests"]["uses"] == ["run|call:get"] + + +def test_egress_collector_merges_class_and_zero_iteration_paths( + tmp_path: Path, +) -> None: + """类 client 清空与零次循环都不得抹掉已证明的可达能力。""" + value = collect_direct_egress( + _module( + tmp_path, + "app.sample", + ''' +import requests + +class Service: + def connect(self): + client = requests.Session() + self.client = client + + def close(self): + self.client = None + + def run(self): + self.client.get("https://example.com/class") + +def loop(): + client = requests.Session() + for client in []: + pass + client.post("https://example.com/loop") +''', + ) + ) + + assert _entries_by_target(value)["requests"]["uses"] == [ + "Service.connect|call:Session", + "Service.run|call:get", + "loop|call:Session", + "loop|call:post", + ] + + +def test_egress_collector_preserves_annotated_optional_client(tmp_path: Path) -> None: + """None 默认值不得抹掉注解 client 经实例属性传递的真实调用。""" + value = collect_direct_egress( + _module( + tmp_path, + "app.sample", + ''' +import httpx + +class Service: + def __init__(self, client: httpx.AsyncClient = None): + self.client = client + + async def run(self): + await self.client.get("https://example.com") +''', + ) + ) + + assert _entries_by_target(value)["httpx"]["uses"] == [ + "Service.run|call:get" + ] + + +def test_current_egress_facts_are_complete_and_self_consistent() -> None: + """当前宿主 egress identity 必须完整、可收缩、排序且统计自洽。""" + value = collect_dependency_baseline()["direct_egress"] + entries = value["entries"] + application_entries = [ + entry + for entry in entries + if str(entry["source"]).startswith("app.application.") + ] + chain_entries = [ + entry + for entry in entries + if str(entry["source"]).startswith("app.chain.") + ] + + assert value["count"] == len(entries) + assert sum(value["counts_by_kind"].values()) == value["count"] + assert set(value["counts_by_kind"]) == { + "raw_transport", + "network_sdk", + "protocol_operation", + } + assert value["application_chain_counts"] == { + "app.application": len(application_entries), + "app.chain": len(chain_entries), + } + assert { + (entry["source"], entry["target"]) + for entry in application_entries + } <= FROZEN_EGRESS_EDGES_BY_REASON["direct_dns"] + assert chain_entries == [] + assert entries == sorted( + entries, + key=lambda entry: (entry["source"], entry["target"], entry["kind"]), + ) + assert all(entry["target"] != "aiohttp" for entry in entries) + assert all(len(entry["fingerprint"]) == 64 for entry in entries) + assert all(set(entry) == { + "source", + "target", + "kind", + "bindings", + "uses", + "fingerprint", + } for entry in entries) + assert all( + "*" not in binding + for entry in entries + for binding in entry["bindings"] + ) + assert '"line"' not in json.dumps(value) + + +def test_current_egress_facts_match_exact_policy() -> None: + """现存事实必须逐条分类,且 registry/scope 不得与 collector 漂移。""" + facts = collect_dependency_baseline()["direct_egress"] + policy = json.loads(DEPENDENCY_POLICY_PATH.read_text(encoding="utf-8")) + egress_policy = policy["direct_egress"] + groups = egress_policy["groups"] + reviewed = _policy_facts(groups) + facts_by_fingerprint = { + fact["fingerprint"]: fact + for fact in facts["entries"] + } + + assert policy["schema_version"] == 3 + assert egress_policy["scope"] == facts["scope"] + assert egress_policy["registry"] == facts["registry"] + assert len(reviewed) == len({fact["fingerprint"] for fact in reviewed}) == len( + facts["entries"] + ) + assert all(set(fact) == {"source", "target", "kind", "fingerprint"} for fact in reviewed) + assert all("*" not in fact["source"] + fact["target"] for fact in reviewed) + assert all(len(fact["fingerprint"]) == 64 for fact in reviewed) + assert all( + { + key: facts_by_fingerprint[fact["fingerprint"]][key] + for key in ("source", "target", "kind", "fingerprint") + } + == fact + for fact in reviewed + ) + assert _policy_differences(facts["entries"], reviewed) == ([], []) + assert set(FROZEN_EGRESS_FINGERPRINT_BY_EDGE) == set( + FROZEN_EGRESS_REASON_BY_EDGE + ) + assert _fingerprint_differences(facts["entries"]) == [] + assert _classification_differences(facts["entries"], groups) == ([], [], []) + + +def test_egress_policy_enforces_debt_and_exception_schemas() -> None: + """债务必须绑定清零叶,精确例外必须有 owner 与业务理由。""" + policy = json.loads(DEPENDENCY_POLICY_PATH.read_text(encoding="utf-8")) + groups = policy["direct_egress"]["groups"] + debt_tracking = { + "direct_http": "S2-L7", + "requestutils_session_bridge": "S2-L7", + "direct_dns": "S2-L6", + } + exception_reasons = { + "canonical_transport", + "transport_configuration", + "sdk_transport", + "streaming_protocol", + "contained_vendor", + "local_control_plane", + "diagnostic_probe", + "type_or_compat_only", + "daemon_control_plane", + "test_network_guard", + } + + for group in groups: + if group["classification"] == "temporary_debt": + assert set(group) == { + "classification", + "reason_code", + "tracking", + "target_state", + "reason", + "facts", + } + assert group["reason_code"] in debt_tracking + assert group["tracking"] == debt_tracking[group["reason_code"]] + assert group["target_state"] == "empty" + assert str(group["reason"]).strip() + else: + assert group["classification"] == "approved_exception" + assert set(group) == { + "classification", + "reason_code", + "owner", + "reason", + "facts", + } + assert group["reason_code"] in exception_reasons + assert group["owner"] == "$source" + assert str(group["reason"]).strip() + + +def test_egress_policy_rejects_classification_swaps() -> None: + """事实即使仍被覆盖,也不能在债务与例外 reason 间互换。""" + facts = [ + {"source": "app.agent.llm.provider", "target": "httpx"}, + {"source": "app.agent.llm.capability", "target": "openai"}, + ] + swapped = [ + { + "reason_code": "sdk_transport", + "facts": [{"source": "app.agent.llm.provider", "target": "httpx"}], + }, + { + "reason_code": "direct_http", + "facts": [{"source": "app.agent.llm.capability", "target": "openai"}], + }, + ] + + assert _classification_differences(facts, swapped) == ( + [], + [], + [ + ("app.agent.llm.capability", "openai"), + ("app.agent.llm.provider", "httpx"), + ], + ) + + +def test_egress_policy_rejects_same_edge_surface_growth() -> None: + """同一 source/target 的调用面变化不能靠刷新 policy 绕过。""" + facts = [ + { + "source": "app.agent.llm.provider", + "target": "httpx", + "fingerprint": "f" * 64, + } + ] + + assert _fingerprint_differences(facts) == [ + ("app.agent.llm.provider", "httpx") + ] + + +def test_egress_policy_rejects_add_remove_replacement_and_stale_entries() -> None: + """新增、事实变化和删除后未清 policy 都必须产生精确差异。""" + original = [ + { + "source": "app.sample", + "target": "requests", + "kind": "raw_transport", + "fingerprint": "a" * 64, + } + ] + reviewed = [ + { + "source": "app.sample", + "target": "requests", + "kind": "raw_transport", + "fingerprint": "a" * 64, + } + ] + added = [ + *original, + { + "source": "app.other", + "target": "aiohttp", + "kind": "raw_transport", + "fingerprint": "b" * 64, + }, + ] + replacement = [{**original[0], "fingerprint": "c" * 64}] + + assert _policy_differences(original, reviewed) == ([], []) + assert _policy_differences(added, reviewed) == (["b" * 64], []) + assert _policy_differences([], reviewed) == ([], ["a" * 64]) + assert _policy_differences(replacement, reviewed) == ( + ["c" * 64], + ["a" * 64], + ) + assert _policy_differences([], []) == ([], []) diff --git a/tests/test_plugin_identity_transitions.py b/tests/test_plugin_identity_transitions.py index 76721f622..d5dce4488 100644 --- a/tests/test_plugin_identity_transitions.py +++ b/tests/test_plugin_identity_transitions.py @@ -288,6 +288,34 @@ def test_bind_online_commits_legacy_and_local_first_bindings(identity_store) -> assert local_bound.revision == 2 +def test_bind_online_preserves_legacy_unknown_payload_binding(identity_store) -> None: + """存量身份可先固化默认在线来源,且不伪造尚未应用的载荷事实。""" + legacy = identity_store.compare_and_set( + _legacy_identity("LegacyInventoryPlugin"), + expected_revision=None, + ) + target = replace( + legacy, + trusted_source_type=TrustedPluginSourceType.OFFICIAL, + trusted_source_key=OFFICIAL_SOURCE, + binding_basis=PluginBindingBasis.OFFICIAL_DEFAULT, + updated_at=NOW + timedelta(seconds=1), + bound_at=NOW + timedelta(seconds=1), + ) + + bound = identity_store.bind_online( + target, + expected_revision=legacy.revision, + ) + + assert bound.trusted_source_type is TrustedPluginSourceType.OFFICIAL + assert bound.trusted_source_key == OFFICIAL_SOURCE + assert bound.binding_basis is PluginBindingBasis.OFFICIAL_DEFAULT + assert bound.payload_source_type is PluginPayloadSourceType.UNKNOWN + assert bound.payload_applied_at is None + assert bound.revision == 2 + + def test_bind_online_rejects_bound_identity_and_stale_revision(identity_store) -> None: """首次在线绑定不能覆盖已有可信来源,也不能使用失效 revision。""" bound = identity_store.compare_and_set(_identity(), expected_revision=None)