diff --git a/app/application/plugin/catalog.py b/app/application/plugin/catalog.py index 156e0b511..248af6eab 100644 --- a/app/application/plugin/catalog.py +++ b/app/application/plugin/catalog.py @@ -16,6 +16,7 @@ from app.application.plugin.identity import ( normalize_physical_plugin_id, ) from app.application.plugin.inventory import normalize_github_plugin_source +from app.domain.plugin import check_plugin_runtime_compatibility from app.schemas.plugin import ( Plugin, PluginSourceBindingStatus, @@ -44,12 +45,26 @@ class _CatalogLoadRequest: def apply_declared_metadata_fallback( plugins: Sequence[Plugin], identities: Mapping[str, PluginIdentity], + *, + free_threaded: bool = False, ) -> list[Plugin]: - """用已提交快照补齐加载失败插件,不覆盖真实运行态字段。""" + """用已提交快照补齐加载失败插件,不覆盖真实运行态字段。 + + :param free_threaded: 当前是否运行在 free-threaded 解释器;已安装卡片的运行时 + 不兼容提示只能来自安装时提交的声明快照,市场候选那条标注路径要联网才有。 + """ result: list[Plugin] = [] for plugin in plugins: identity = identities.get((plugin.id or "").lower()) updates: dict[str, object] = {} + if identity is not None and identity.declared_metadata is not None: + compatible, message = check_plugin_runtime_compatibility( + identity.declared_metadata.to_json().get("runtime"), + free_threaded=free_threaded, + ) + if not compatible: + updates["runtime_compatible"] = False + updates["runtime_message"] = message if plugin.installed and not plugin.is_instance: if identity is None: updates["source_binding_status"] = PluginSourceBindingStatus.BINDING_REQUIRED @@ -469,14 +484,16 @@ class PluginCatalogQuery: online_candidates: Callable[[bool], Awaitable[list[Plugin]]], process_plugins: Callable[[list[Plugin], list[Plugin]], list[Plugin]], identities: Callable[[list[str]], Awaitable[list[PluginIdentity]]], + free_threaded: Callable[[], bool], ) -> None: - """保存运行态目录和来源身份读取窄端口。""" + """保存运行态目录、来源身份读取和解释器变体判定窄端口。""" self._installed_plugins = installed_plugins self._local_plugins = local_plugins self._local_repo_plugins = local_repo_plugins self._online_candidates = online_candidates self._process_plugins = process_plugins self._identities = identities + self._free_threaded = free_threaded async def query( self, *, state: str = "all", force: bool = False @@ -554,6 +571,7 @@ class PluginCatalogQuery: return apply_declared_metadata_fallback( plugins, {identity.normalized_plugin_id: identity for identity in identities}, + free_threaded=self._free_threaded(), ) def _prefer_bound_update( diff --git a/app/application/plugin/release.py b/app/application/plugin/release.py index ef8c00144..76cb27aaa 100644 --- a/app/application/plugin/release.py +++ b/app/application/plugin/release.py @@ -46,6 +46,7 @@ class PluginReleaseService: has_release_cache: ReleaseCacheProbe, releases: ReleaseLoader, refresh_releases: ReleaseRefresher, + free_threaded: Callable[[], bool], ) -> None: """保存源身份归一、运行态、来源身份和市场读取窄端口。""" self._source_plugin_id = source_plugin_id @@ -59,6 +60,7 @@ class PluginReleaseService: self._has_release_cache = has_release_cache self._releases = releases self._refresh_releases = refresh_releases + self._free_threaded = free_threaded async def history(self, plugin_id: str, *, force: bool = True) -> Plugin | None: """按可信绑定仓库读取单个已安装插件的更新说明。 @@ -80,6 +82,7 @@ class PluginReleaseService: installed_plugin = apply_declared_metadata_fallback( [installed_plugin], {identity.normalized_plugin_id: identity}, + free_threaded=self._free_threaded(), )[0] local_plugin = next( diff --git a/app/runtime/extensions/plugin/loader.py b/app/runtime/extensions/plugin/loader.py index 289347451..fcbdaee5d 100644 --- a/app/runtime/extensions/plugin/loader.py +++ b/app/runtime/extensions/plugin/loader.py @@ -4,14 +4,14 @@ from __future__ import annotations import importlib import importlib.util -import json import sys import threading import traceback -from collections.abc import Callable +from collections.abc import Callable, Mapping from pathlib import Path from typing import Any, Optional +from app.domain.plugin import check_plugin_runtime_compatibility from app.foundation.environment import is_free_threaded_runtime from app.runtime.settings import get_runtime_setting from app.schemas.plugin import PluginInstance, PluginRuntimeStatus @@ -20,6 +20,15 @@ from app.schemas.plugin import PluginInstance, PluginRuntimeStatus PluginImportPreparer = Callable[..., None] PluginImportScanner = Callable[..., None] PluginValidator = Callable[[Any], bool] +# 按物理插件 ID 读取安装时提交的运行时声明;未建立声明的存量安装返回空映射 +PluginRuntimeDeclarationReader = Callable[[str], Mapping[str, bool]] + + +def _undeclared_runtime(_: str) -> Mapping[str, bool]: + """默认声明读取端口:无声明即保持历史插件可加载。""" + return {} + + PluginRuntimeStatusWriter = Callable[[str, PluginRuntimeStatus], None] @@ -36,12 +45,14 @@ class PluginLoader: import_scanner: PluginImportScanner, log: Any, runtime_status_writer: Optional[PluginRuntimeStatusWriter] = None, + runtime_declaration: PluginRuntimeDeclarationReader = _undeclared_runtime, ) -> None: - """保存插件目录、导入前置能力、状态回写端口和日志端口。""" + """保存插件目录、导入前置能力、状态回写端口、声明读取端口和日志端口。""" self._plugins_root = plugins_root self._import_preparer = import_preparer self._import_scanner = import_scanner self._runtime_status_writer = runtime_status_writer + self._runtime_declaration = runtime_declaration self._logger = log def load( @@ -85,7 +96,7 @@ class PluginLoader: f"跳过插件目录:{plugin_dir.name}(缺少__init__.py)" ) continue - if not self._is_runtime_compatible(plugin_dir): + if not self._is_runtime_compatible(plugin_dir.name): self._logger.warning( f"跳过插件 {plugin_dir.name}:声明与当前运行时不兼容" ) @@ -135,7 +146,7 @@ class PluginLoader: f"虚拟插件实例 {instance.instance_id} 的源码不存在:{source_dir}" ) return [] - if not self._is_runtime_compatible(source_dir): + if not self._is_runtime_compatible(instance.source_plugin_id): self._logger.warning( f"跳过虚拟插件实例 {instance.instance_id}:声明与当前运行时不兼容" ) @@ -194,7 +205,7 @@ class PluginLoader: 两者在卡片上是完全不同的提示。 :param plugin_id: 物理插件 ID,大小写不敏感 """ - return self._is_runtime_compatible(self._plugins_root / plugin_id.lower()) + return self._is_runtime_compatible(plugin_id) def _mark_incompatible_runtime(self, plugin_id: str) -> None: """把运行时不兼容记成插件卡片可见的状态。 @@ -210,20 +221,23 @@ class PluginLoader: PluginRuntimeStatus.INCOMPATIBLE_RUNTIME, ) - @staticmethod - def _is_runtime_compatible(plugin_dir: Path) -> bool: - """按载荷自身 package 声明执行运行时兼容门禁,缺失声明时保持兼容。""" - package_file = plugin_dir / "package.json" - try: - package = json.loads(package_file.read_text(encoding="utf-8")) - except (FileNotFoundError, OSError, UnicodeDecodeError, json.JSONDecodeError): - return True - if not isinstance(package, dict): - return True + def _is_runtime_compatible(self, plugin_id: str) -> bool: + """按安装时提交的声明快照执行运行时兼容门禁,缺失声明时保持兼容。 + + 判据来自 ``PluginIdentity.declared_metadata`` 里随载荷一起提交的 package + 运行时声明,而不是运行目录中的文件:插件目录里同名的 ``package.json`` 是 + 模块联邦组件的 npm manifest,既不承载代际位,也不会被安装流程写入。 + :param plugin_id: 物理插件 ID,大小写不敏感 + """ + declaration = self._runtime_declaration(plugin_id.lower()) version_flag = get_runtime_setting("VERSION_FLAG") - if version_flag and package.get(version_flag) is False: + if version_flag and declaration.get(version_flag) is False: return False - return not (is_free_threaded_runtime() and package.get("v3t") is False) + compatible, _ = check_plugin_runtime_compatibility( + declaration, + free_threaded=is_free_threaded_runtime(), + ) + return compatible def _execute_instance_module( self, diff --git a/app/runtime/extensions/plugin/runtime.py b/app/runtime/extensions/plugin/runtime.py index f1d1376f3..e55f0e577 100644 --- a/app/runtime/extensions/plugin/runtime.py +++ b/app/runtime/extensions/plugin/runtime.py @@ -19,7 +19,10 @@ from app.runtime.extensions.plugin.contracts import supports_plugin_hook from app.runtime.extensions.plugin.database import PluginDatabase from app.runtime.extensions.plugin.dependency import PluginDependencyService from app.runtime.extensions.plugin.lifecycle import PluginLifecycle -from app.runtime.extensions.plugin.loader import PluginLoader +from app.runtime.extensions.plugin.loader import ( + PluginLoader, + PluginRuntimeDeclarationReader, +) from app.runtime.extensions.plugin.loglevel import PluginLogLevelControl from app.runtime.extensions.plugin.metadata import PluginMetadataMapper from app.runtime.extensions.plugin.monitor import PluginMonitorController @@ -106,6 +109,8 @@ class PluginRuntimeEnvironment: # 原子写入端口,不经过按实例逐行读写的实例表端口 set_default_target: Callable[[str, str], bool] clear_default_target: Callable[[str], None] + # 运行时兼容门禁的唯一判据来源:安装时随载荷提交的 package 运行时声明快照 + runtime_declaration: PluginRuntimeDeclarationReader @dataclass(frozen=True, slots=True) @@ -165,6 +170,7 @@ def build_plugin_runtime( # 运行时不兼容的插件不进入 lifecycle 遍历,状态只能由加载器自己落记, # 否则启动期全量加载会把它们跳过得无声无息 runtime_status_writer=registry.set_runtime_status, + runtime_declaration=environment.runtime_declaration, log=environment.logger, ) tools = PluginToolCatalog(max_attempts=tool_build_max_attempts) diff --git a/app/startup/initializers/plugins.py b/app/startup/initializers/plugins.py index f41dc6b4e..257d24f8a 100644 --- a/app/startup/initializers/plugins.py +++ b/app/startup/initializers/plugins.py @@ -1,6 +1,6 @@ import asyncio import uuid -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import datetime, timezone from pathlib import Path from typing import Any, cast @@ -80,6 +80,7 @@ from app.application.plugin.transaction import ( ) from app.application.scheduling import update_plugin_job from app.application.site.sites import SitesHelper # pylint: disable=import-error,no-name-in-module +from app.db.adapters.pluginidentity import TransactionalPluginIdentityStore from app.db.models.plugininstance import PluginInstance as PluginInstanceRecord from app.db.oper.plugindata import PluginDataOper from app.db.oper.plugininstance import PluginInstanceOper @@ -90,6 +91,7 @@ from app.db.plugin.registry import ( ) from app.db.session import SessionFactory from app.db.uow import SqlAlchemyUnitOfWork +from app.foundation.environment import is_free_threaded_runtime from app.foundation.version import compare_version from app.runtime.cache import async_fresh from app.runtime.compat.diagnostics import ( @@ -201,6 +203,21 @@ def _delete_plugin_data(plugin_id: str) -> None: session.close() +def _read_plugin_runtime_declaration(plugin_id: str) -> Mapping[str, bool]: + """读取插件安装时随载荷提交的 package 运行时声明,供加载门禁判定代际与变体。 + + 声明快照是运行目录唯一可离线信任的来源:插件目录里不存在主程序写入的 package + 文件,市场索引又要联网才能取。存量安装可能还没有身份行或声明快照,这里一律回落 + 成空映射,保持"未声明即兼容"的历史语义。 + """ + identity = TransactionalPluginIdentityStore(SessionFactory).get(plugin_id) + declared = identity.declared_metadata if identity else None + if declared is None: + return {} + runtime = declared.to_json().get("runtime") + return runtime if isinstance(runtime, dict) else {} + + def _read_plugin_log_level(instance_id: str) -> LogLevelOverride: """从实例行读取日志等级覆盖与失效时间。""" session = SessionFactory() @@ -419,6 +436,7 @@ def build_plugin_runtime_graph(host: PluginRuntimeHost) -> PluginRuntime: logger=logger, set_default_target=_set_plugin_default_target, clear_default_target=_clear_plugin_default_target, + runtime_declaration=_read_plugin_runtime_declaration, ), tool_build_max_attempts=PluginManager.AGENT_TOOLS_BUILD_MAX_ATTEMPTS, ) @@ -514,6 +532,7 @@ def configure_plugin_services() -> None: has_release_cache=market_transport.async_has_plugin_release_cache, releases=market_transport.async_get_plugin_release_versions, refresh_releases=refresh_plugin_releases, + free_threaded=is_free_threaded_runtime, ) ) configure_plugin_catalog_query( @@ -524,6 +543,7 @@ def configure_plugin_services() -> None: online_candidates=plugin_manager.async_get_online_plugin_candidates, process_plugins=plugin_manager.process_plugins_list, identities=persistence.list_identities, + free_threaded=is_free_threaded_runtime, ) ) configure_plugin_rating_service( diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 0f1b68fbd..3aadb2b57 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -761,7 +761,7 @@ flowchart LR | 指标 | 当前值 | |---|---:| | Python 模块 | 1044 | -| 内部导入边 | 8,865 | +| 内部导入边 | 8,872 | | 非平凡 SCC | 1(精确 containment 的 TMDB 移植包环) | | Application / Chain 具体 Adapter 直连 | 0 / 0 | | Direct egress | 53(债务已清零,53 条精确 containment) | diff --git a/docs/refactor/optimization-checklist.md b/docs/refactor/optimization-checklist.md index 213cf8317..29a4c497e 100644 --- a/docs/refactor/optimization-checklist.md +++ b/docs/refactor/optimization-checklist.md @@ -94,7 +94,7 @@ ARCH-201 至 ARCH-204 均达到实现、验证、提交、推送和远端门禁 | 指标 | 当前值 | 解释 | |---|---:|---| -| 宿主 Python 模块 / 内部依赖边 | 1044 / 8,865 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档,分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 | +| 宿主 Python 模块 / 内部依赖边 | 1044 / 8,872 | `dependency-baseline.json` 当前快照;系统设置合同按 `app.application.settings` 包组织,系统设置 API 端点独立按 `app.api.endpoints.settings` 归档,分类、下载资源归类、订阅搜索、整理恢复、Agent 计划、工具视觉、终端生命周期与终端作用域模块的受控依赖 | | 非平凡 SCC | 1 | 仅保留精确 containment 的 29 模块 TMDB 移植包环 | | 跨层 DB 边界债务 | 0 | Application、Chain、API、Agent、Runtime、Workflow 到 DB 的受控债务均为零 | | Model/Oper 事务债务 | 0 | 自建 Session、自动事务装饰器、直接 commit/rollback 等基线均为零 | diff --git a/tests/conftest.py b/tests/conftest.py index 28ad84007..50827bdd5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -227,6 +227,7 @@ def configure_plugin_system_services(): from app.runtime.extensions.service import ServiceConfigHelper from app.startup.initializers.plugins import ( _clear_plugin_default_target, + _read_plugin_runtime_declaration, _set_plugin_default_target, ) @@ -262,6 +263,7 @@ def configure_plugin_system_services(): logger=plugin_manager_module.logger, set_default_target=_set_plugin_default_target, clear_default_target=_clear_plugin_default_target, + runtime_declaration=_read_plugin_runtime_declaration, ), tool_build_max_attempts=PluginManager.AGENT_TOOLS_BUILD_MAX_ATTEMPTS, ) diff --git a/tests/fixtures/architecture/dependency-baseline.json b/tests/fixtures/architecture/dependency-baseline.json index 2d8d1209e..e613696e0 100644 --- a/tests/fixtures/architecture/dependency-baseline.json +++ b/tests/fixtures/architecture/dependency-baseline.json @@ -1084,8 +1084,8 @@ "runtime_only": true } }, - "edge_count": 8865, - "edge_sha256": "6641acd38c2aec65ba3f527ce822ef687ed693bc8109c38b375bbd89499cdd68", + "edge_count": 8872, + "edge_sha256": "6a3f2d8501b1c8dbfc0b4c11f3ba49495ede25a209ffd2a9fee970749d169389", "edges": [ "app -> app.foundation", "app -> app.foundation.environment", @@ -3744,6 +3744,8 @@ "app.application.plugin.catalog -> app.application.plugin", "app.application.plugin.catalog -> app.application.plugin.identity", "app.application.plugin.catalog -> app.application.plugin.inventory", + "app.application.plugin.catalog -> app.domain", + "app.application.plugin.catalog -> app.domain.plugin", "app.application.plugin.catalog -> app.schemas", "app.application.plugin.catalog -> app.schemas.plugin", "app.application.plugin.config -> app.schemas", @@ -8482,6 +8484,8 @@ "app.runtime.extensions.plugin.lifecycle -> app.runtime.observability", "app.runtime.extensions.plugin.lifecycle -> app.schemas", "app.runtime.extensions.plugin.lifecycle -> app.schemas.plugin", + "app.runtime.extensions.plugin.loader -> app.domain", + "app.runtime.extensions.plugin.loader -> app.domain.plugin", "app.runtime.extensions.plugin.loader -> app.foundation", "app.runtime.extensions.plugin.loader -> app.foundation.environment", "app.runtime.extensions.plugin.loader -> app.runtime", @@ -9638,6 +9642,8 @@ "app.startup.initializers.plugins -> app.application.scheduling", "app.startup.initializers.plugins -> app.application.site", "app.startup.initializers.plugins -> app.db", + "app.startup.initializers.plugins -> app.db.adapters", + "app.startup.initializers.plugins -> app.db.adapters.pluginidentity", "app.startup.initializers.plugins -> app.db.models", "app.startup.initializers.plugins -> app.db.models.plugininstance", "app.startup.initializers.plugins -> app.db.oper", @@ -9648,6 +9654,7 @@ "app.startup.initializers.plugins -> app.db.session", "app.startup.initializers.plugins -> app.db.uow", "app.startup.initializers.plugins -> app.foundation", + "app.startup.initializers.plugins -> app.foundation.environment", "app.startup.initializers.plugins -> app.foundation.version", "app.startup.initializers.plugins -> app.runtime", "app.startup.initializers.plugins -> app.runtime.cache", diff --git a/tests/test_plugin_clone_experience.py b/tests/test_plugin_clone_experience.py index 5e2f7984e..f09cb5078 100644 --- a/tests/test_plugin_clone_experience.py +++ b/tests/test_plugin_clone_experience.py @@ -178,6 +178,7 @@ def _build_world( logger=_logger(), set_default_target=lambda _plugin_id, _instance_id: True, clear_default_target=lambda _plugin_id: None, + runtime_declaration=lambda _plugin_id: {}, ) runtime = build_plugin_runtime( SimpleNamespace( diff --git a/tests/test_plugin_declared_metadata.py b/tests/test_plugin_declared_metadata.py index 4c845beec..04c168f34 100644 --- a/tests/test_plugin_declared_metadata.py +++ b/tests/test_plugin_declared_metadata.py @@ -205,3 +205,41 @@ def test_declared_metadata_fallback_is_batch_safe_and_preserves_runtime_fields() assert result[1].plugin_version == "loaded-version" assert result[1].runtime_status is PluginRuntimeStatus.READY assert result[2].plugin_name == "MissingPlugin" + + +def test_installed_card_marks_runtime_incompatibility_from_declared_snapshot() -> None: + """已安装卡片的 v3t 不兼容提示必须能离线得出。 + + 市场候选那条标注路径要联网才有结果,而切到 v3t 的用户首先看到的是"我的插件"。 + 安装时提交的声明快照是运行目录唯一可离线信任的来源,这里钉住它被消费。 + """ + blocked = _identity( + _metadata({"name": "Blocked", "v3": True, "v3t": False}), + plugin_id="BlockedPlugin", + ) + allowed = _identity( + _metadata({"name": "Allowed", "v3": True}), + plugin_id="AllowedPlugin", + ) + plugins = [ + Plugin(id="BlockedPlugin", plugin_name="Blocked", installed=True), + Plugin(id="AllowedPlugin", plugin_name="Allowed", installed=True), + ] + + on_free_threaded = apply_declared_metadata_fallback( + plugins, + {"blockedplugin": blocked, "allowedplugin": allowed}, + free_threaded=True, + ) + assert on_free_threaded[0].runtime_compatible is False + assert "v3t" in (on_free_threaded[0].runtime_message or "") + assert on_free_threaded[1].runtime_compatible is not False + + # 标准 V3 上同一份声明不得产生任何提示 + on_standard = apply_declared_metadata_fallback( + plugins, + {"blockedplugin": blocked, "allowedplugin": allowed}, + free_threaded=False, + ) + assert on_standard[0].runtime_compatible is not False + assert on_standard[0].runtime_message is None diff --git a/tests/test_plugin_endpoint.py b/tests/test_plugin_endpoint.py index 7c232e840..fe28df739 100644 --- a/tests/test_plugin_endpoint.py +++ b/tests/test_plugin_endpoint.py @@ -96,6 +96,7 @@ def _catalog_query(plugin_manager: MagicMock, persistence: MagicMock) -> PluginC online_candidates=plugin_manager.async_get_online_plugin_candidates, process_plugins=plugin_manager.process_plugins_list, identities=persistence.list_identities, + free_threaded=lambda: False, ) @@ -352,6 +353,7 @@ def _release_service( has_release_cache=plugin_helper.async_has_plugin_release_cache, releases=plugin_helper.async_get_plugin_release_versions, refresh_releases=plugin_helper.async_get_plugin_release_versions, + free_threaded=lambda: False, ) diff --git a/tests/test_plugin_runtime_enablement_wiring.py b/tests/test_plugin_runtime_enablement_wiring.py index 7ad5e7832..f1d58f306 100644 --- a/tests/test_plugin_runtime_enablement_wiring.py +++ b/tests/test_plugin_runtime_enablement_wiring.py @@ -95,6 +95,7 @@ def _build_runtime( logger=_logger(), set_default_target=lambda _plugin_id, _instance_id: True, clear_default_target=lambda _plugin_id: None, + runtime_declaration=lambda _plugin_id: {}, ) runtime = build_plugin_runtime( _host(), diff --git a/tests/test_plugin_virtual_instances.py b/tests/test_plugin_virtual_instances.py index f3910ed12..73e1ac79d 100644 --- a/tests/test_plugin_virtual_instances.py +++ b/tests/test_plugin_virtual_instances.py @@ -623,7 +623,11 @@ def test_loader_runtime_gate_only_rejects_explicit_incompatible_declarations( tmp_path, monkeypatch, ): - """运行目录缺少声明或 runtime 为空时保持历史插件可加载。""" + """未建立声明或声明为空时保持历史插件可加载,只有显式 false 才拒绝。 + + 判据来自安装时提交的声明快照。运行目录里的 ``package.json`` 是模块联邦组件的 + npm manifest,主程序既不写它也不读它,这里一并钉住:它的存在不能影响门禁。 + """ from app.runtime.extensions.plugin import loader as loader_module monkeypatch.setattr( @@ -633,19 +637,34 @@ def test_loader_runtime_gate_only_rejects_explicit_incompatible_declarations( ) monkeypatch.setattr(loader_module, "is_free_threaded_runtime", lambda: True) - missing = tmp_path / "missing" - missing.mkdir() - assert PluginLoader._is_runtime_compatible(missing) + declarations = { + "empty": {}, + "rejected": {"v3": True, "v3t": False}, + "legacygeneration": {"v3": False}, + "federated": {}, + } + loader = PluginLoader( + plugins_root=tmp_path, + import_preparer=lambda **_kwargs: None, + import_scanner=lambda **_kwargs: None, + runtime_declaration=lambda plugin_id: declarations.get(plugin_id, {}), + log=_logger(), + ) - empty = tmp_path / "empty" - empty.mkdir() - (empty / "package.json").write_text('{"runtime": {}}', encoding="utf-8") - assert PluginLoader._is_runtime_compatible(empty) + # 未建立身份或声明快照的存量安装 + assert loader.is_runtime_compatible("missing") + assert loader.is_runtime_compatible("empty") + assert not loader.is_runtime_compatible("rejected") + assert not loader.is_runtime_compatible("legacygeneration") - rejected = tmp_path / "rejected" - rejected.mkdir() - (rejected / "package.json").write_text('{"v3t": false}', encoding="utf-8") - assert not PluginLoader._is_runtime_compatible(rejected) + # 插件自带的 npm manifest 不参与判定 + federated = tmp_path / "federated" + federated.mkdir() + (federated / "package.json").write_text( + '{"name": "moviepilot-federated-plugin", "v3t": false}', + encoding="utf-8", + ) + assert loader.is_runtime_compatible("federated") def test_loader_marks_incompatible_runtime_status_when_skipping( @@ -669,7 +688,6 @@ def test_loader_marks_incompatible_runtime_status_when_skipping( plugin_dir = tmp_path / "rejectedplugin" plugin_dir.mkdir() (plugin_dir / "__init__.py").write_text("", encoding="utf-8") - (plugin_dir / "package.json").write_text('{"v3t": false}', encoding="utf-8") statuses: dict[str, PluginRuntimeStatus] = {} loader = PluginLoader( @@ -677,6 +695,9 @@ def test_loader_marks_incompatible_runtime_status_when_skipping( import_preparer=lambda **_kwargs: None, import_scanner=lambda **_kwargs: None, runtime_status_writer=statuses.__setitem__, + runtime_declaration=lambda plugin_id: ( + {"v3t": False} if plugin_id == "rejectedplugin" else {} + ), log=_logger(), )