diff --git a/CHANGELOG.md b/CHANGELOG.md index 58ab1e6..28f8c19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ ## [Unreleased] +### Added +- **`plugin.yml` の `requires.devbase` をインストール時に検証**するようにしました。要件を + 満たさない Plugin は `devbase plugin install` が中止します。これまでは値を読むだけで + 比較しておらず、`project.yml` 形式の Plugin を 2.x へ入れられてしまい、`devbase up` の + 段階で初めて失敗していました。既存のインストールに触れる前に検証するため、入れ替えに + 失敗しても既存の Plugin は壊れません。解釈できない書式は警告に留めて続行し、 + `DEVBASE_IGNORE_PLUGIN_REQUIRES=1` で検証を無効化できます。 +- **`devbase plugin update` でも `requires.devbase` を確認**するようにしました。`git pull` で + 要求が上がって満たさなくなった Plugin は警告で知らせます(更新は既に済んでいるため中止はしません)。 + ## [3.0.0] - 2026-08-23 プロジェクト設定を `project.yml` へ移行する破壊的変更を含みます。プラグイン側の diff --git a/docs/plugin-dev/plugin-yml-reference.md b/docs/plugin-dev/plugin-yml-reference.md index 88b52fd..94a39fd 100644 --- a/docs/plugin-dev/plugin-yml-reference.md +++ b/docs/plugin-dev/plugin-yml-reference.md @@ -130,12 +130,44 @@ requires: | 値 | 意味 | |----|------| | `">=3.0.0"` | devbase 3.0.0 以上が必要 | +| `">=3.0.0,<4.0.0"` | 範囲指定(カンマ区切りは AND) | +| `"==3.0.0"` / `"3.0.0"` | 一致(演算子を省略すると `==` 扱い) | | 省略 | バージョン要件なし(どの版でも導入を試みる) | +使える演算子は `>=` / `<=` / `>` / `<` / `==` / `!=` です。版数の要素数は自由で、 +桁数が違う場合は短い方を `0` で埋めて比較します(`">=3.0"` と `3.0.0` は等しい、 +`">=3.0.0.1"` を `3.0.0` は満たさない)。比較は数値で行うため `10.0.0` は `3.0.0` より新しく扱われます。 + **devbase 3.0.0 以降のPluginは `">=3.0.0"` を指定してください。** プロジェクト設定を `projects//project.yml` で記述する形式は devbase 3.0.0 で導入されたもので、 -2.x 系の devbase は `project.yml` を読めません。要件を書かないまま 2.x へ導入されると、 -インストールは成功するのに `devbase up` の段階で初めて失敗します。 +2.x 系の devbase は `project.yml` を読めません。 + +### インストール時の検証 + +`devbase plugin install` は、要件を満たさない Plugin のインストールを**中止**します。 + +``` +Error: プラグイン 'carmo-web' は devbase >=3.0.0 を要求していますが、現在の devbase は 2.2.0 です。 +devbase を更新してから再度インストールしてください (検証を飛ばす場合は DEVBASE_IGNORE_PLUGIN_REQUIRES=1)。 +``` + +- 既存のインストールに触れる**前**に検証するため、入れ替えに失敗しても既に入っている Plugin は壊れません +- 解釈できない書式(`"^3.0.0"` など)や版数のときは、**警告を出して検証せずに続行**します。独自記法を書いた Plugin をインストール不能にするより実害が小さいためです +- 検証側の判断が誤っているときは `DEVBASE_IGNORE_PLUGIN_REQUIRES=1` で無効化できます + +> **必ずクォートしてください。** `devbase: 3.10` のようにクォート無しで書くと YAML が数値 +> (float の `3.1`)として読み、元の表記へ戻せません。この場合は誤った版で比較せず、 +> 警告を出して**検証をスキップ**します(`devbase: ">=3.10"` と書けば検証されます)。 + +### 更新時の警告 + +`devbase plugin update`(`git pull`)で Plugin 側の `requires.devbase` が上がることがあります。 +更新自体は既に済んでいて中止できないため、要件を満たさなくなった Plugin は**警告**で知らせます。 + +``` +WARNING プラグイン 'carmo-web' は devbase >=4.0.0 を要求していますが、現在の devbase は 3.0.0 です。 +devbase 本体を更新してください (この警告を止める場合は DEVBASE_IGNORE_PLUGIN_REQUIRES=1)。 +``` > `requires.devbase` を上げるのは、**Plugin が `project.yml` 形式へ移行したタイミング**です。 > 本体の版数と一緒に自動では上がりません。 diff --git a/issues/PLAN32_multi-repo-project.md b/issues/PLAN32_multi-repo-project.md index d8d25f1..256efcd 100644 --- a/issues/PLAN32_multi-repo-project.md +++ b/issues/PLAN32_multi-repo-project.md @@ -360,7 +360,23 @@ plugin repo の PR は本体 release PR の merge 直後に merge する (flag d - **フックへの値の受け渡し (#110)**: 計画に無かった。結合検証で、`GIT_REPO` / `WORK_DIR` を `source ./env` で読んでいた `pre-up` / `deploy` が壊れることが判明したため、devbase 側から `DEVBASE_PRIMARY_DIR` / `DEVBASE_PRIMARY_URL` / `DEVBASE_WORK_DIR` / `DEVBASE_REPO_DIRS` を渡す形にした。plugin リポジトリ側の該当フック 2 本も追随させた。 - **確定仕様の置き場所**: `docs/user/project-yml.md` を恒久リファレンスとした (plan-to-spec 相当)。 +### リリース後に実施したこと (2026-08-23) + +plan の範囲外で、結合検証と運用の中で出てきた対応。 + +| 内容 | 結果 | +|---|---| +| pilot の実機起動確認 | `uttaro-system` (3 repo) / `project-trygroup-prd` (2 repo・scale=2 の両インスタンス) で確認済み。AC9 充足 | +| 統合で消えたプロジェクトの後片付け | `projects/` の symlink・残留ディレクトリ・停止済みコンテナを削除済み | +| 全プロジェクトのイメージ更新 | 32 件を再ビルドし、稼働中コンテナを新 entrypoint で作り直した | +| v3.0.0 のタグとリリース | 作成済み (CHANGELOG のリンク切れも解消) | +| `requires.devbase` の検証 | 宣言だけで比較していなかったため、`plugin install` 時に検証する実装を追加 | +| 関連リポジトリのさらなる集約 | `carmo-ai` (4 repo) / `bi-tools` (3 repo) / `ai-plugins` (2 repo) へ統合し、重複していた `predict_contract` を削除。dev コンテナ 18 → 13 | +| `uttarov2` → `uttaro-system` へ改名 | 本体リポジトリの移行に合わせてプロジェクト名を実態へ | +| `carmo-screening` の `init.sh` | 7.5 か月壊れたまま誰も困っていなかったため削除 (volareinc/carmo-screening#314) | + ### 残作業 -- 統合した pilot (`uttarov2` / `project-trygroup-prd`) の実機起動確認。`devbase build --no-cache` → `devbase up` が必要で、稼働中コンテナの再起動を伴う。 -- 統合で消えたプロジェクト (`uttarov2-doc` / `uttarov2migration` / `project-trygroup-prd-customer`) の後片付け。`projects/` のシンボリックリンクと、生成物だけが残ったディレクトリ、および停止済みコンテナが残っている。 +- `devbase plugin sync` を実行すると、`devbase-samples` に定義を残した `tmllib` / `devbase` の + プロジェクト symlink が復活する。使わないプロジェクトが一覧に出るだけで実害は無い。 + 気になる場合は該当プラグインをローカルから uninstall する。 diff --git a/lib/devbase/plugin/installer.py b/lib/devbase/plugin/installer.py index 22cbe13..2511c74 100644 --- a/lib/devbase/plugin/installer.py +++ b/lib/devbase/plugin/installer.py @@ -14,6 +14,10 @@ RegistryInfo, RegistryEntry, ) from .registry import PluginRegistry +from .requirements import ( + check_devbase_requirement, + warn_unmet_devbase_requirement, +) from .syncer import sync_projects, load_plugin_info logger = get_logger("devbase.plugin.installer") @@ -252,6 +256,12 @@ def _link_plugin( plugins_dir: Path, ) -> None: """Create a symlink for a local plugin (--link install only)""" + # 互換性は既存インストールに触れる前に確かめる。後から落とすと、入れ替えの + # ために消した既存プラグインが戻らないまま失敗する。 + info = load_plugin_info(plugin_path) + check_devbase_requirement(info) + version = info.version if info else '0.1.0' + dest = plugins_dir / name if dest.exists() or dest.is_symlink(): logger.warning("Removing existing plugin '%s'", name) @@ -262,9 +272,6 @@ def _link_plugin( dest.symlink_to(plugin_path.resolve()) - info = load_plugin_info(plugin_path) - version = info.version if info else '0.1.0' - registry.add(InstalledPlugin( name=name, version=version, @@ -437,12 +444,24 @@ def _register_repo_plugin( plugin_path: Path, source_url: str, repo_local_path: str, + enforce_requirements: bool = True, ) -> None: - """Register a plugin from repos/ (no file copy, just metadata).""" + """Register a plugin from repos/ (no file copy, just metadata). + + ``enforce_requirements=False`` にすると ``requires.devbase`` 違反を警告に + 留める。update 由来の呼び出し (プラグイン分割の移行) は旧登録を削除した + あとに呼ばれるため、ここで例外にすると移行先が登録されないまま旧登録も + 失われる。git pull は済んでおり止めても整合は取れないので、警告で知らせて + 登録は進める。 + """ if not plugin_path.is_dir(): raise PluginError(f"Plugin directory not found: {plugin_path}") info = load_plugin_info(plugin_path) + if enforce_requirements: + check_devbase_requirement(info) + else: + warn_unmet_devbase_requirement(info) version = info.version if info else '0.1.0' # Use the actual plugin_path relative to devbase_root so that diff --git a/lib/devbase/plugin/requirements.py b/lib/devbase/plugin/requirements.py new file mode 100644 index 0000000..2074e37 --- /dev/null +++ b/lib/devbase/plugin/requirements.py @@ -0,0 +1,167 @@ +"""``plugin.yml`` の ``requires.devbase`` を devbase 本体の版と突き合わせる。 + +プラグインは devbase の機能に依存する。例えば PLAN32 (devbase 3.0.0) 以降の +``project.yml`` 形式のプロジェクト定義は 2.x では読めず、インストールできて +しまうと ``devbase up`` の段階で初めて失敗する。要求を宣言だけで終わらせず、 +インストール時に確かめて先に止める。 + +対応する書式は PEP 440 の部分集合:: + + ">=3.0.0" # 以上 + ">=3.0.0,<4.0.0" # 範囲 (カンマ区切りは AND) + "==3.0.0" / "3.0.0" # 一致 (演算子なしは == 扱い) + ">" / "<" / "<=" / "!=" + +解釈できない書式や版番号は**エラーにしない**。独自記法を書いたプラグインを +インストール不能にするより、検証できなかったことを警告で知らせて先へ進める方が +実害が小さい (依存が本当に足りなければ後段で失敗する)。 +""" + +from __future__ import annotations + +import os +import re +from typing import List, Optional, Tuple + +from devbase.errors import PluginError +from devbase.log import get_logger +from devbase.plugin.models import PluginInfo + +logger = get_logger(__name__) + +#: 検証を丸ごと無効化する環境変数 (検証側の誤りで作業が詰まらないための逃げ道) +IGNORE_ENV = "DEVBASE_IGNORE_PLUGIN_REQUIRES" + +_CLAUSE = re.compile(r'^\s*(>=|<=|==|!=|>|<)?\s*([0-9][0-9.]*)\s*$') + +_OPERATORS = { + ">=": lambda a, b: a >= b, + "<=": lambda a, b: a <= b, + ">": lambda a, b: a > b, + "<": lambda a, b: a < b, + "==": lambda a, b: a == b, + "!=": lambda a, b: a != b, +} + + +def check_devbase_requirement(info: Optional[PluginInfo], + current_version: Optional[str] = None) -> None: + """``requires.devbase`` を満たしていなければ :class:`PluginError` を送出する。 + + Args: + info: ``plugin.yml`` の内容 (``None`` や要求未指定なら何もしない) + current_version: 比較に使う devbase の版 (既定は動作中の devbase) + """ + unmet = _unmet_requirement(info, current_version) + if unmet is None: + return + spec, current_version = unmet + raise PluginError( + f"プラグイン '{info.name}' は devbase {spec} を要求していますが、" + f"現在の devbase は {current_version} です。" + "devbase を更新してから再度インストールしてください " + f"(検証を飛ばす場合は {IGNORE_ENV}=1)。" + ) + + +def warn_unmet_devbase_requirement(info: Optional[PluginInfo], + current_version: Optional[str] = None) -> None: + """要件違反を **警告だけ** で知らせる (中止できない場面向け)。 + + ``devbase plugin update`` は git pull 済みの作業ツリーを追認するだけなので、 + 要求が上がっていても止められない。気づけないまま ``devbase up`` で失敗するより、 + 更新の場で本体の更新を促す方がよい。 + """ + unmet = _unmet_requirement(info, current_version) + if unmet is None: + return + spec, current_version = unmet + logger.warning( + "プラグイン '%s' は devbase %s を要求していますが、現在の devbase は " + "%s です。devbase 本体を更新してください " + "(この警告を止める場合は %s=1)。", + info.name, spec, current_version, IGNORE_ENV) + + +def _unmet_requirement(info: Optional[PluginInfo], + current_version: Optional[str]) -> Optional[Tuple[str, str]]: + """要件を満たさないとき ``(要求, 現在の版)`` を返す。満たす/検証不能なら ``None``。""" + if info is None or info.requires_devbase is None: + return None + # 文字列でない値は YAML が数値化した結果で元の表記を復元できない + # (`devbase: 3.10` → 3.1)。誤判定を避けるため検証しない。 + # 通常は load_plugin_info が警告つきで落としているのでここには来ない。 + if not isinstance(info.requires_devbase, str): + logger.warning( + "プラグイン '%s' の requires.devbase (%r) が文字列ではないため" + "検証せずに続行します", info.name, info.requires_devbase) + return None + spec = info.requires_devbase.strip() + if not spec: + return None + if os.environ.get(IGNORE_ENV, "").strip().lower() not in ("", "0", "false", "no"): + logger.warning( + "%s が設定されているため、プラグイン '%s' の requires.devbase (%s) を" + "検証しませんでした", IGNORE_ENV, info.name, spec) + return None + + if current_version is None: + from devbase import __version__ + current_version = __version__ + + clauses = _parse_spec(spec) + if clauses is None: + logger.warning( + "プラグイン '%s' の requires.devbase (%s) を解釈できないため、" + "互換性を検証せずに続行します", info.name, spec) + return None + + current = _parse_version(current_version) + if current is None: + logger.warning( + "devbase の版 '%s' を解釈できないため、プラグイン '%s' の " + "requires.devbase (%s) を検証せずに続行します", + current_version, info.name, spec) + return None + + for operator, required in clauses: + left, right = _align(current, required) + if not _OPERATORS[operator](left, right): + return spec, current_version + return None + + +def _parse_spec(spec: str) -> Optional[List[Tuple[str, tuple]]]: + """``">=3.0.0,<4.0.0"`` を ``[(">=", (3,0,0)), ("<", (4,0,0))]`` にする。""" + clauses = [] + for part in spec.split(","): + matched = _CLAUSE.match(part) + if not matched: + return None + version = _parse_version(matched.group(2)) + if version is None: + return None + clauses.append((matched.group(1) or "==", version)) + return clauses or None + + +def _parse_version(version: str) -> Optional[tuple]: + """``"3.0.0.1"`` → ``(3, 0, 0, 1)``。数値以外が混ざっていたら ``None``。 + + 要素は切り捨てない。``">=3.0.0.1"`` を ``3.0.0`` が満たすと誤判定しないよう、 + 桁合わせは比較時に :func:`_align` で行う。 + """ + parts = str(version).strip().split(".") + if not all(part.isdigit() for part in parts): + return None + return tuple(int(part) for part in parts) + + +def _align(a: tuple, b: tuple) -> Tuple[tuple, tuple]: + """短い方を 0 で埋めて桁数を揃える (``">=3.0"`` と ``"3.0.0"`` の比較用)。""" + width = max(len(a), len(b)) + return a + (0,) * (width - len(a)), b + (0,) * (width - len(b)) + + +__all__ = ["IGNORE_ENV", "check_devbase_requirement", + "warn_unmet_devbase_requirement"] diff --git a/lib/devbase/plugin/syncer.py b/lib/devbase/plugin/syncer.py index 78eb26d..22fb9af 100644 --- a/lib/devbase/plugin/syncer.py +++ b/lib/devbase/plugin/syncer.py @@ -24,10 +24,31 @@ def load_plugin_info(plugin_dir: Path) -> Optional[PluginInfo]: version=data.get('version', '0.1.0'), description=data.get('description', ''), priority=data.get('priority', 0), - requires_devbase=data.get('requires', {}).get('devbase') if isinstance(data.get('requires'), dict) else None, + requires_devbase=_requires_devbase(data), ) +def _requires_devbase(data: dict) -> Optional[str]: + """requires.devbase を文字列で返す。文字列でなければ None (検証不能)。 + + `devbase: 3.10` とクォート無しで書かれると YAML が float の 3.1 にしてしまい、 + 元の版を復元できない (str へ寄せると "3.1" となり 3.10 とは別物になる)。 + 誤った版で比較するより、検証できないものとして扱い、クォートを促す。 + """ + requires = data.get('requires') + if not isinstance(requires, dict) or requires.get('devbase') is None: + return None + value = requires['devbase'] + if not isinstance(value, str): + logger.warning( + "plugin.yml の requires.devbase (%r) が文字列ではないため検証できません。" + "YAML が数値として解釈しており元の表記を復元できません " + '(例: 3.10 → 3.1)。クォートして書いてください: devbase: ">=3.10"', + value) + return None + return value + + def discover_projects(plugin_dir: Path) -> list[str]: """Discover project directories within a plugin""" projects_dir = plugin_dir / 'projects' diff --git a/lib/devbase/plugin/updater.py b/lib/devbase/plugin/updater.py index cdb46ec..d052f8e 100644 --- a/lib/devbase/plugin/updater.py +++ b/lib/devbase/plugin/updater.py @@ -10,6 +10,7 @@ from .models import InstalledPlugin, RegistryInfo from .registry import PluginRegistry from .repo_manager import _git_pull +from .requirements import warn_unmet_devbase_requirement from .syncer import sync_projects, discover_projects logger = get_logger("devbase.plugin.updater") @@ -93,9 +94,12 @@ def _migrate_removed_plugin( if entry and repo_local_path: from .installer import _register_repo_plugin plugin_path = clone_dir / entry.path.rstrip('/') + # 旧登録は上で削除済み。ここで例外にすると移行先が登録されないまま + # 旧登録も失われるため、要件違反は警告に留める。 _register_repo_plugin( registry, entry.name, plugin_path, plugin.source, repo_local_path, + enforce_requirements=False, ) return True @@ -179,6 +183,9 @@ def _update_repo_plugins( from .syncer import load_plugin_info info = load_plugin_info(plugin_path) version = info.version if info else '0.1.0' + # pull で requires.devbase が上がることがある。更新はもう済んでいて + # 止められないので、気づけるように警告だけ出す。 + warn_unmet_devbase_requirement(info) rel_path = str(plugin_path.relative_to(registry.devbase_root)) registry.add(InstalledPlugin( diff --git a/tests/plugin/test_requirements.py b/tests/plugin/test_requirements.py new file mode 100644 index 0000000..ecb7169 --- /dev/null +++ b/tests/plugin/test_requirements.py @@ -0,0 +1,329 @@ +"""plugin.yml の requires.devbase の検証 (インストール時の互換チェック)""" + +from __future__ import annotations + +import pytest + +from devbase.errors import PluginError +from devbase.plugin.models import PluginInfo +from devbase.plugin.requirements import ( + check_devbase_requirement, + warn_unmet_devbase_requirement, +) + + +def info(requires=None, name="carmo-web") -> PluginInfo: + return PluginInfo(name=name, version="1.0.0", requires_devbase=requires) + + +# --------------------------------------------------------------------------- +# 満たしている場合は何も起きない +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("spec,current", [ + (">=3.0.0", "3.0.0"), + (">=3.0.0", "3.1.2"), + (">=3.0.0", "10.0.0"), # 数値として比較する (文字列比較だと 10 < 3 になる) + (">=3.0", "3.0.0"), # 桁数が違っても比較できる + (">3.0.0", "3.0.1"), + ("<=3.0.0", "3.0.0"), + ("<4.0.0", "3.9.9"), + ("==3.0.0", "3.0.0"), + ("3.0.0", "3.0.0"), # 演算子なしは == 扱い + (">=3.0.0,<4.0.0", "3.5.0"), + (">= 3.0.0", "3.0.0"), # 空白を許す + (">=3.0.0.1", "3.0.0.1"), # 4 要素以上でも比較できる + (">=3.0.0.1", "3.0.1"), +]) +def test_satisfied_requirements_pass(spec, current): + check_devbase_requirement(info(spec), current_version=current) + + +@pytest.mark.parametrize("requires", [None, "", " "]) +def test_missing_requirement_is_not_checked(requires): + """requires を書いていないプラグインは従来どおり素通しする""" + check_devbase_requirement(info(requires), current_version="1.0.0") + + +def test_no_plugin_info_is_not_checked(): + check_devbase_requirement(None, current_version="1.0.0") + + +# --------------------------------------------------------------------------- +# 満たしていない場合はエラー +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("spec,current", [ + (">=3.0.0", "2.2.0"), + (">3.0.0", "3.0.0"), + ("<3.0.0", "3.0.0"), + ("==3.0.0", "3.0.1"), + (">=3.0.0,<4.0.0", "4.0.0"), + (">=3.0.0.1", "3.0.0"), # 4 要素目を切り捨てると見逃す + ("<3.0.0.1", "3.0.0.2"), +]) +def test_unsatisfied_requirement_raises(spec, current): + with pytest.raises(PluginError) as excinfo: + check_devbase_requirement(info(spec), current_version=current) + + message = str(excinfo.value) + assert "carmo-web" in message # どのプラグインか + assert spec in message # 要求 + assert current in message # 現在の版 + + +def test_error_message_explains_how_to_proceed(): + with pytest.raises(PluginError, match="devbase"): + check_devbase_requirement(info(">=3.0.0"), current_version="2.2.0") + + +# --------------------------------------------------------------------------- +# 解釈できない指定は落とさず警告に留める +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("spec", ["~=3.0", "^3.0.0", "3.x", "latest", ">=abc"]) +def test_unparsable_spec_warns_and_passes(spec, caplog): + """独自記法でインストールを止めない (検証できないことを知らせるだけ)""" + check_devbase_requirement(info(spec), current_version="3.0.0") + + assert any("requires" in r.message.lower() or "解釈" in r.message + for r in caplog.records) + + +def test_unparsable_current_version_warns_and_passes(caplog): + check_devbase_requirement(info(">=3.0.0"), current_version="dev") + + assert caplog.records + + +# --------------------------------------------------------------------------- +# 迂回手段 +# --------------------------------------------------------------------------- + +def test_env_override_skips_the_check(monkeypatch): + """検証自体が誤っているときに詰まらないための逃げ道""" + monkeypatch.setenv("DEVBASE_IGNORE_PLUGIN_REQUIRES", "1") + + check_devbase_requirement(info(">=3.0.0"), current_version="2.2.0") + + +def test_current_version_defaults_to_the_running_devbase(): + """引数を省略したら devbase 自身の版を見る""" + from devbase import __version__ + + check_devbase_requirement(info(f">={__version__}")) + + with pytest.raises(PluginError): + check_devbase_requirement(info(f">{__version__}")) + + +# --------------------------------------------------------------------------- +# インストール経路への組み込み +# --------------------------------------------------------------------------- + +def write_plugin(tmp_path, requires: str): + plugin = tmp_path / "sample-plugin" + (plugin / "projects").mkdir(parents=True) + (plugin / "plugin.yml").write_text( + f'name: sample-plugin\nversion: "1.0.0"\nrequires:\n devbase: "{requires}"\n') + return plugin + + +def test_link_install_refuses_an_incompatible_plugin(tmp_path, monkeypatch): + from devbase.plugin import installer + + monkeypatch.setattr("devbase.__version__", "2.2.0", raising=False) + plugin = write_plugin(tmp_path, ">=99.0.0") + plugins_dir = tmp_path / "plugins" + plugins_dir.mkdir() + + with pytest.raises(PluginError, match="sample-plugin"): + installer._link_plugin(None, "sample-plugin", plugin, "local", plugins_dir) + + assert list(plugins_dir.iterdir()) == [] + + +def test_link_install_keeps_the_existing_plugin_when_refused(tmp_path, monkeypatch): + """入れ替えに失敗しても、既に入っているプラグインを壊さない""" + from devbase.plugin import installer + + plugin = write_plugin(tmp_path, ">=99.0.0") + plugins_dir = tmp_path / "plugins" + plugins_dir.mkdir() + existing = plugins_dir / "sample-plugin" + existing.mkdir() + (existing / "plugin.yml").write_text('name: sample-plugin\n') + + with pytest.raises(PluginError): + installer._link_plugin(None, "sample-plugin", plugin, "local", plugins_dir) + + assert (existing / "plugin.yml").exists() + + +# --------------------------------------------------------------------------- +# plugin.yml の型ゆれ (クォート無しの版は YAML が数値にする) +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("requires", [3.0, 3, ">=3.0"]) +def test_numeric_requirement_does_not_crash(requires): + """`devbase: 3.0` とクォート無しで書かれても AttributeError にしない""" + check_devbase_requirement(info(requires), current_version="3.0.0") + + +def test_numeric_requirement_is_not_compared(caplog): + """数値は YAML が変換した結果で元の表記を復元できないため検証しない""" + check_devbase_requirement(info(4.0), current_version="3.0.0") + + assert caplog.records + + +@pytest.mark.parametrize("written,parsed", [("3.0", 3.0), ("3.10", 3.1)]) +def test_unquoted_requirement_is_reported_instead_of_guessed(tmp_path, caplog, + written, parsed): + """`devbase: 3.10` は float 3.1 になり 3.10 へ戻せない。誤判定せず案内する""" + from devbase.plugin.syncer import load_plugin_info + + plugin = tmp_path / "sample-plugin" + plugin.mkdir() + (plugin / "plugin.yml").write_text( + f'name: sample-plugin\nversion: "1.0.0"\nrequires:\n devbase: {written}\n') + + loaded = load_plugin_info(plugin) + + assert loaded.requires_devbase is None + assert any("クォート" in r.message for r in caplog.records) + # 検証されないので、どの版でもインストールは止まらない + check_devbase_requirement(loaded, current_version="3.1.0") + + +def test_quoted_requirement_is_compared(tmp_path): + """クォートしてあれば従来どおり検証される""" + from devbase.plugin.syncer import load_plugin_info + + plugin = tmp_path / "sample-plugin" + plugin.mkdir() + (plugin / "plugin.yml").write_text( + 'name: sample-plugin\nversion: "1.0.0"\nrequires:\n devbase: ">=3.10"\n') + + loaded = load_plugin_info(plugin) + + assert loaded.requires_devbase == ">=3.10" + check_devbase_requirement(loaded, current_version="3.10.0") + with pytest.raises(PluginError): + check_devbase_requirement(loaded, current_version="3.1.0") + + +# --------------------------------------------------------------------------- +# 迂回手段の値ゆれ +# --------------------------------------------------------------------------- + +@pytest.mark.parametrize("value", ["0", "false", "FALSE", "No", "no", " ", ""]) +def test_env_override_is_case_insensitive_and_off_by_default(value, monkeypatch): + """FALSE / No のような書き方で検証が黙って無効にならない""" + monkeypatch.setenv("DEVBASE_IGNORE_PLUGIN_REQUIRES", value) + + with pytest.raises(PluginError): + check_devbase_requirement(info(">=3.0.0"), current_version="2.2.0") + + +# --------------------------------------------------------------------------- +# 更新時は止められないので警告に留める +# --------------------------------------------------------------------------- + +def test_warn_variant_does_not_raise(caplog): + warn_unmet_devbase_requirement(info(">=99.0.0"), current_version="3.0.0") + + assert any("carmo-web" in r.getMessage() for r in caplog.records) + + +def test_warn_variant_is_silent_when_satisfied(caplog): + warn_unmet_devbase_requirement(info(">=3.0.0"), current_version="3.0.0") + + assert not caplog.records + + +def test_update_warns_when_the_pulled_plugin_needs_a_newer_devbase( + tmp_path, monkeypatch, caplog): + """git pull で要求が上がっても更新自体は通し、警告で気づけるようにする""" + import yaml + + from devbase.plugin.models import InstalledPlugin + from devbase.plugin.registry import PluginRegistry + from devbase.plugin.updater import _update_repo_plugins + + monkeypatch.setattr("devbase.__version__", "2.2.0", raising=False) + (tmp_path / "projects").mkdir() + registry = PluginRegistry(tmp_path) + + url = "https://github.com/testorg/testrepo.git" + clone_dir = tmp_path / "repos" / "github.com--testorg--testrepo" + plugin_dir = clone_dir / "sample-plugin" + plugin_dir.mkdir(parents=True) + (clone_dir / "registry.yml").write_text(yaml.dump({ + "name": "testrepo", + "plugins": [{"name": "sample-plugin", "path": "sample-plugin", + "description": ""}], + })) + (plugin_dir / "plugin.yml").write_text( + 'name: sample-plugin\nversion: "2.0.0"\nrequires:\n devbase: ">=99.0.0"\n') + + registry.add(InstalledPlugin( + name="sample-plugin", version="1.0.0", source=url, + installed_at=registry.now_iso(), + path="repos/github.com--testorg--testrepo/sample-plugin", + )) + + errors = _update_repo_plugins(registry, url, clone_dir) + + assert errors == [] # 更新は止めない + assert registry.get("sample-plugin").version == "2.0.0" + assert any("99.0.0" in r.getMessage() for r in caplog.records) + + +def test_split_migration_keeps_the_registry_when_requirements_are_unmet( + tmp_path, monkeypatch, caplog): + """プラグイン分割の移行は旧登録を消してから移行先を登録する。 + + ここで例外にすると、移行先が登録されないまま旧登録も失われる。git pull は + 済んでいて止めても整合は取れないので、警告に留めて登録を進める。 + """ + import yaml + + from devbase.plugin.models import ( + InstalledPlugin, RegisteredRepository, RegistryEntry, RegistryInfo, + ) + from devbase.plugin.registry import PluginRegistry + from devbase.plugin.updater import _migrate_removed_plugin + + monkeypatch.setattr("devbase.__version__", "2.2.0", raising=False) + (tmp_path / "projects").mkdir() + registry = PluginRegistry(tmp_path) + + url = "https://github.com/testorg/testrepo.git" + clone_dir = tmp_path / "repos" / "github.com--testorg--testrepo" + new_plugin_dir = clone_dir / "new-plugin" + (new_plugin_dir / "projects" / "shared-project").mkdir(parents=True) + (new_plugin_dir / "plugin.yml").write_text( + 'name: new-plugin\nversion: "1.0.0"\nrequires:\n devbase: ">=99.0.0"\n') + (clone_dir / "registry.yml").write_text(yaml.dump({ + "name": "testrepo", + "plugins": [{"name": "new-plugin", "path": "new-plugin", "description": ""}], + })) + + registry.add_repository(RegisteredRepository( + name="testrepo", url=url, + local_path="repos/github.com--testorg--testrepo")) + old = InstalledPlugin( + name="old-plugin", version="1.0.0", source=url, + installed_at=registry.now_iso(), + path="repos/github.com--testorg--testrepo/old-plugin") + registry.add(old) + + reg_info = RegistryInfo(name="testrepo", plugins=[ + RegistryEntry(name="new-plugin", path="new-plugin")]) + + assert _migrate_removed_plugin(registry, old, clone_dir, reg_info, + pre_pull_projects={"shared-project"}) is True + + assert registry.get("new-plugin") is not None # 移行先が登録される + assert any("99.0.0" in r.getMessage() for r in caplog.records)