From 363203d971a520754c9b414b9acd2f2e932a42bc Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 23:48:11 +0900 Subject: [PATCH 1/7] =?UTF-8?q?chore(PLAN66):=20=E5=90=8D=E5=89=8D?= =?UTF-8?q?=E3=81=AE=E5=BD=A2=E3=81=AE=E7=9F=A5=E3=82=89=E3=81=9B=EF=BC=88?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=201=20=E6=9C=AC=E7=9B=AE=EF=BC=89=E3=81=AE?= =?UTF-8?q?=E4=BD=9C=E6=A5=AD=E3=82=92=E5=A7=8B=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #203 Co-Authored-By: Claude Opus 5 (1M context) From 5d62f5f73b5c0b703e017fb6de5552ec0d332f96 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 23:53:17 +0900 Subject: [PATCH 2/7] =?UTF-8?q?docs(PLAN66):=20=E5=AE=9F=E8=A3=85=201=20?= =?UTF-8?q?=E6=9C=AC=E7=9B=AE=EF=BC=88=E7=9F=A5=E3=82=89=E3=81=9B=EF=BC=89?= =?UTF-8?q?=E3=81=AE=E8=A8=88=E7=94=BB=E3=82=92=E7=BD=AE=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #203 Co-Authored-By: Claude Opus 5 (1M context) --- .../PLAN66_project-name-validation-impl1.md | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 issues/PLAN66_project-name-validation-impl1.md diff --git a/issues/PLAN66_project-name-validation-impl1.md b/issues/PLAN66_project-name-validation-impl1.md new file mode 100644 index 00000000..58683815 --- /dev/null +++ b/issues/PLAN66_project-name-validation-impl1.md @@ -0,0 +1,98 @@ +# PLAN66 実装 1 本目: 名前の形に合わないプロジェクトを、作られた時点で知らせる + +## 関連リンク + +- 課題: devbasex/devbase#203 +- 要求と受け入れ条件: `issues/PLAN66_project-name-validation.md` +- 設計: `issues/PLAN66_project-name-validation-design.md`(設計 PR #230。弾かずに警告に留める案を承認済み) +- release PR: #212(base は `release/v3.7.0`) +- この計画が扱うのは設計の「実装の分け方」の **1 本目(知らせ。F1・F2)だけ**である。2 本目(スナップショットの + 名前を `utils/names` の述語へ寄せる。F3・決定 5・6)はこの Pull Request のマージ後に別に出す + +## モード + +`standard`(要求の文書の判定のまま。出力が増えるだけで、終了コードと作られるものは変えない)。 + +## 目的と非目的 + +達成したい状態: + +- `devbase plugin install` / `update` / `sync` が、名前の形に合わない名前を `projects/` に載せる直前に + 1 行知らせる(プラグインのプロジェクト・合成した別名・`projects/` 直下の実ディレクトリ) +- `devbase env import` が、名前の形に合わないプロジェクト名を取り込むとき、保存先に応じた文で 1 行知らせる + (`--dry-run` でも出す) +- 名前の形の説明文を `utils/names.NAME_FORM_HINT` の 1 か所に置く + +やらないこと: + +- 名前を弾く・載せない・整える(決定 1)。終了コードと作られる symlink・ディレクトリは変えない +- `discover_projects` と `_collect_project_candidates` での検査(決定 2)・`.` 始まりの除外の変更(決定 8) +- 下流の検証(`bin/devbase`・`cli.py`・`commands/container.py`)の変更(決定 3) +- `env/secret_store.py`・`env/bundle.py`・`_PROJECT_ENV_RE` の変更(決定 4。G4 の束 #188 との重なりを避ける) +- `snapshot/manager.py` の変更と確定仕様「運用」の 2 つ目の箇条書き(2 本目) + +## 受け入れ条件 + +要求の文書の番号をそのまま使う。この Pull Request が満たすのは 1〜9・13 の 1 つ目・14 の Added・15〜17 である。 + +- [ ] 1・2・3・3-2・4・5(同期): `tests/plugin/test_repos_core.py` に新しいテストクラスを足す +- [ ] 6・7・8(import の平文): `tests/env/test_io_import.py` へテストを足す +- [ ] 9(import の age): `tests/cli/test_env_bundle_backend.py` へテストを足す +- [ ] 13 の 1 つ目: `docs/specifications/cli-argument-resolution.md` の「運用」の 1 つ目の箇条書き +- [ ] 14 の Added: `CHANGELOG.md` の `[Unreleased]` +- [ ] 15・16: 既存のテストを変更せずに通す +- [ ] 17: `uv run --locked pytest tests/ -q` が exit=0 + +## 修正対象 + +- `lib/devbase/utils/names.py`(`NAME_FORM_HINT` を足す) +- `lib/devbase/plugin/syncer.py`(`_warn_unusable_name` を足し、`sync_projects` の 2 か所と `_link_loser_projects` から呼ぶ) +- `lib/devbase/env/_import_merge.py`(`project_name_of` を足す) +- `lib/devbase/env/io_import.py`(`import_bundle` の `_build_plans` の直後、`--dry-run` の判定より前に知らせる) +- `tests/plugin/test_repos_core.py`・`tests/env/test_io_import.py`・`tests/cli/test_env_bundle_backend.py` +- `docs/specifications/cli-argument-resolution.md`・`CHANGELOG.md` + +## タスク分解 + +### Task 1: 同期の知らせ(F1) + +- **対象ファイル:** `lib/devbase/utils/names.py`・`lib/devbase/plugin/syncer.py`・`tests/plugin/test_repos_core.py` +- **変更内容:** `NAME_FORM_HINT` を足す。`_warn_unusable_name(name, source, base=None)` を足し、出所ごとに + 案内を選ぶ(設計「警告の文」の表の 4 行)。`sync_projects` で `sorted(real_projects)` の名前ごとと、winner の + symlink の直前に呼ぶ。`_link_loser_projects` で別名の symlink の直前に、元のプロジェクト名を `base` として呼ぶ。 + `verbose` に依存させない +- **満たす受け入れ条件:** 1・2・3・3-2・4・5・15 +- **進め方:** 失敗するテスト → 通す最小実装 → 整理 + +### Task 2: import の知らせ(F2) + +- **対象ファイル:** `lib/devbase/env/_import_merge.py`・`lib/devbase/env/io_import.py`・`tests/env/test_io_import.py`・`tests/cli/test_env_bundle_backend.py` +- **変更内容:** `project_name_of(arcname)` を足す。`import_bundle` で `plans` を回し、形に合わない名前に 1 行知らせる。 + 保存先が `projects/<名前>/.env`(`plan.ref is None` かつ `plan.target` がそのパス)なら「この import が + `projects/<名前>/` を作る」文、それ以外(age・サーバ backend)は保存先を名指しして `projects/` に何も作らない文 +- **満たす受け入れ条件:** 6・7・8・9・16 +- **進め方:** 失敗するテスト → 通す最小実装 → 整理 + +### Task 3: 確定仕様と CHANGELOG + +- **対象ファイル:** `docs/specifications/cli-argument-resolution.md`・`CHANGELOG.md` +- **変更内容:** 「運用」の 1 つ目に、知らせが出ること・4 つの出所・弾かないことを足す。CHANGELOG の Added に F1・F2 +- **満たす受け入れ条件:** 13 の 1 つ目・14 の Added +- **進め方:** 文書のためテスト駆動を適用しない + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `sync_projects(verbose=False)` を数える用途(`updater`)でも警告が出る | 設計の決定どおり(黙る経路を作らない)。`updater` の差分計算は `discover_projects` を使い、`sync_projects` の出力には触れない | +| 警告の文が長く、`caplog` の件数が他の WARNING と混ざる | テストは名前の形の行を定型の先頭文で絞って数える | +| 触る範囲は 4 ファイルで、どれもテストが厚い | 実装の後の構造改善で足りる | + +## 切り戻し手順 + +コードの差分は知らせの追加だけで、データ・スキーマを持たない。この Pull Request の revert で完全に戻る。 + +## 完了の定義 + +- [ ] 受け入れ条件 1〜9 がテストで確かめられ、15〜17 の全件が exit=0 +- [ ] Draft の Pull Request の本文に Test plan と実行結果を載せる From f00493d5c9ca898906e25f5304c8e4bfd5bf0fcc Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 23:53:17 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat(PLAN66):=20=E3=83=97=E3=83=A9=E3=82=B0?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=81=AE=E5=90=8C=E6=9C=9F=E3=81=8C=E5=90=8D?= =?UTF-8?q?=E5=89=8D=E3=81=AE=E5=BD=A2=E3=81=AB=E5=90=88=E3=82=8F=E3=81=AA?= =?UTF-8?q?=E3=81=84=E5=90=8D=E5=89=8D=E3=82=92=20projects/=20=E3=81=AB?= =?UTF-8?q?=E8=BC=89=E3=81=9B=E3=82=8B=E7=9B=B4=E5=89=8D=E3=81=AB=E7=9F=A5?= =?UTF-8?q?=E3=82=89=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit symlink は今と同じく張り、戻り値も変えない(決定 1)。検査は winner の symlink の直前・ 別名の symlink の直前・実ディレクトリの採取の直後の 3 か所に置き、discover_projects と _collect_project_candidates には置かない(決定 2)。別名の案内は元の名前と の どちらが原因かで分ける。名前の形の説明文は utils/names.NAME_FORM_HINT に置く(決定 7)。 Refs #203 Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/plugin/syncer.py | 51 +++++++++++ lib/devbase/utils/names.py | 4 + tests/plugin/test_repos_core.py | 145 ++++++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+) diff --git a/lib/devbase/plugin/syncer.py b/lib/devbase/plugin/syncer.py index 22fb9afb..f48a6d1e 100644 --- a/lib/devbase/plugin/syncer.py +++ b/lib/devbase/plugin/syncer.py @@ -5,6 +5,7 @@ from typing import Optional from devbase.log import get_logger +from devbase.utils.names import NAME_FORM_HINT, is_single_segment_name from .registry import PluginRegistry from .models import InstalledPlugin, PluginInfo @@ -100,6 +101,50 @@ def _collect_project_candidates( return candidates +#: ``_warn_unusable_name`` の ``source`` に渡す、``projects/`` 直下の実ディレクトリの出所 +_SOURCE_REAL_DIRECTORY = "projects/ 直下の実ディレクトリ" + + +def _warn_unusable_name(name: str, source: str, base: Optional[str] = None) -> None: + """``projects/`` に載る名前が名前の形に合わなければ、警告を 1 行出す (PLAN66)。 + + 弾かない (決定 1)。symlink を張るかどうかは呼び出し側が今と同じく決め、この関数の + 結果で分岐させないため戻り値を持たない。``verbose`` にも依存させない (知らせることが + 唯一の効果なので、黙る経路を作らない)。 + + Args: + name: ``projects/`` に載る名前 + source: 出所。プラグインのプロジェクトと別名ではプラグイン名、実ディレクトリでは + ``_SOURCE_REAL_DIRECTORY``。末尾の案内の選択にも使う + base: 別名 (``.``) のときだけ渡す元のプロジェクト名 + """ + if is_single_segment_name(name): + return + if source == _SOURCE_REAL_DIRECTORY: + origin = source + advice = f"projects/{name} 自身を改名すれば直ります(プラグインとは関係しません)。" + elif base is None: + origin = f"プラグイン {source}" + advice = f"プラグイン {source} の projects/{name} を改名すれば直ります。" + elif not is_single_segment_name(base): + # 別名の元の名前の側が形に合わない。winner の分として元の名前の知らせも出ている + origin = f"プラグイン {source} の別名" + advice = f"プラグイン {source} の projects/{base} を改名すれば直ります。" + else: + # 元の名前は形に合うので、合わない文字は devbase が合成した の側にある + owner = name[len(base) + 1:] + origin = f"プラグイン {source} の別名" + advice = ( + f"'{owner}' は devbase が別名に付け足す部分です。--link で入れたプラグインなら" + "元パスの末尾のディレクトリ名、repos/ 由来ならその置き場のディレクトリ名を" + f"変えてください。プラグイン側の projects/{base} を改名しても直りません。") + logger.warning( + "プロジェクト名として使えない形の名前が projects/ に載ります: '%s'(出所: %s)。" + "この名前では、名前を指定した操作(devbase up %s など)ができません(%s)。" + "projects/%s の中で名前なしに打てば動きます。%s", + name, origin, name, NAME_FORM_HINT, name, advice) + + def _link_loser_projects( projects_dir: Path, proj_name: str, @@ -126,6 +171,7 @@ def _link_loser_projects( if verbose: logger.warning(" Skip: %s (symlink already exists)", suffix_name) continue + _warn_unusable_name(suffix_name, loser_plugin.name, base=proj_name) suffix_link.symlink_to(_make_relative_target(loser_plugin, proj_name)) created += 1 return created @@ -151,6 +197,10 @@ def sync_projects(registry: PluginRegistry, verbose: bool = True) -> int: entry.name for entry in projects_dir.iterdir() if not entry.is_symlink() and entry.is_dir() } + # 実ディレクトリは同期が作らないが、ここが唯一それを列挙する場所 (決定 2)。 + # set のままだと警告の順が実行ごとに変わるため並べる + for name in sorted(real_projects): + _warn_unusable_name(name, _SOURCE_REAL_DIRECTORY) for entry in projects_dir.iterdir(): if entry.is_symlink(): @@ -186,6 +236,7 @@ def sync_projects(registry: PluginRegistry, verbose: bool = True) -> int: proj_name, _extract_owner(loser_plugin), ) + _warn_unusable_name(proj_name, winner_plugin.name) link_path = projects_dir / proj_name link_path.symlink_to(_make_relative_target(winner_plugin, proj_name)) created += 1 diff --git a/lib/devbase/utils/names.py b/lib/devbase/utils/names.py index f36fffae..b1f68b8f 100644 --- a/lib/devbase/utils/names.py +++ b/lib/devbase/utils/names.py @@ -23,6 +23,10 @@ _SINGLE_SEGMENT_NAME_RE = re.compile(SINGLE_SEGMENT_NAME_PATTERN) +#: 名前の形を利用者へ説明する文 (PLAN66 決定 7)。知らせの文に埋め込むため末尾に句点を +#: 置かない。この module はログを出さない (副作用を持たない契約) ので、出すのは呼び出し側。 +NAME_FORM_HINT = "英数字で始まり、英数字・'.'・'-'・'_' だけからなる名前" + def is_single_segment_name(value: str) -> bool: """``value`` が親ディレクトリの直下の 1 つの名前の形か (``re.fullmatch``)。 diff --git a/tests/plugin/test_repos_core.py b/tests/plugin/test_repos_core.py index 558b81b0..74ba4202 100644 --- a/tests/plugin/test_repos_core.py +++ b/tests/plugin/test_repos_core.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import os import subprocess import textwrap @@ -725,6 +726,150 @@ def test_link_plugin_collision_uses_source_basename(self, registry, devbase_root assert suffix_link.is_symlink() +# 名前の形の知らせ (PLAN66 受け入れ条件 1〜5)。弾かずに警告に留める (決定 1)。 +_NAME_FORM_WARNING = "プロジェクト名として使えない形の名前" + + +def _name_form_warnings(caplog) -> list[str]: + return [r.getMessage() for r in caplog.records + if r.levelno == logging.WARNING and _NAME_FORM_WARNING in r.getMessage()] + + +def _install_repo_plugin(registry, devbase_root, projects, priority=0, + owner_repo="testorg/testrepo", name="p1"): + url = f"https://github.com/{owner_repo}.git" + _make_repo_dir(devbase_root, owner_repo, [ + {"name": name, "path": name, "projects": projects, "priority": priority}, + ]) + _register_repo(registry, owner_repo, url, [{"name": name, "path": name}]) + registry.add(InstalledPlugin( + name=name, version="1.0.0", source=url, + installed_at=registry.now_iso(), + path=f"repos/github.com--{owner_repo.replace('/', '--')}/{name}", + )) + + +def _install_link_plugin(registry, devbase_root, source_dirname, projects, name="p2"): + """``--link`` で入れたプラグイン。別名の は元パスの basename になる""" + local_plugin = devbase_root / source_dirname / name + local_plugin.mkdir(parents=True) + (local_plugin / "plugin.yml").write_text(f"name: {name}\nversion: 1.0.0\npriority: 0\n") + for proj in projects: + (local_plugin / "projects" / proj).mkdir(parents=True) + plugins_dir = devbase_root / "plugins" + plugins_dir.mkdir(exist_ok=True) + (plugins_dir / name).symlink_to(local_plugin) + registry.add(InstalledPlugin( + name=name, version="1.0.0", source=str(devbase_root / source_dirname), + installed_at=registry.now_iso(), + path=f"plugins/{name}", + linked=True, + )) + + +class TestSyncProjectsNameForm: + def test_unusable_plugin_project_is_linked_and_warned_once( + self, registry, devbase_root, caplog): + """1: 張る数は今と同じで、形に合わない名前だけが 1 回知らされる""" + _install_repo_plugin(registry, devbase_root, ["_foo", "ok-name"]) + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 2 + assert (devbase_root / "projects" / "_foo").is_symlink() + assert (devbase_root / "projects" / "ok-name").is_symlink() + warnings = _name_form_warnings(caplog) + assert len(warnings) == 1 + [message] = warnings + assert "'_foo'" in message + assert "ok-name" not in message + assert "名前を指定した操作" in message + assert "名前なしに打てば動きます" in message + assert "プラグイン p1" in message + assert "projects/_foo を改名" in message + + def test_usable_names_are_not_warned(self, registry, devbase_root, caplog): + """2: 名前の形に合う名前だけなら、名前の形の警告は 1 行も出ない""" + _install_repo_plugin(registry, devbase_root, ["ok-name", "carmo_ai", "a.b"]) + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 3 + assert _name_form_warnings(caplog) == [] + + def test_alias_with_unusable_owner_points_at_the_owner( + self, registry, devbase_root, caplog): + """3: 別名の の側が原因なら、プラグイン側の改名を促さない""" + _install_repo_plugin(registry, devbase_root, ["carmo"], priority=10) + _install_link_plugin(registry, devbase_root, "my plugin", ["carmo"]) + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 2 + assert (devbase_root / "projects" / "carmo.my plugin").is_symlink() + warnings = _name_form_warnings(caplog) + assert len(warnings) == 1 + [message] = warnings + assert "'carmo.my plugin'" in message + assert "'my plugin'" in message + assert "元パス" in message + assert "projects/carmo を改名しても直りません" in message + + def test_alias_with_unusable_base_points_at_the_plugin( + self, registry, devbase_root, caplog): + """3-2: 元の名前の側が原因なら、別名もプラグイン側の改名を案内する""" + _install_repo_plugin(registry, devbase_root, ["_foo"], priority=10) + _install_link_plugin(registry, devbase_root, "my-local-repo", ["_foo"]) + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 2 + assert (devbase_root / "projects" / "_foo").is_symlink() + assert (devbase_root / "projects" / "_foo.my-local-repo").is_symlink() + warnings = _name_form_warnings(caplog) + assert len(warnings) == 2 + winner = [m for m in warnings if "'_foo'" in m] + alias = [m for m in warnings if "'_foo.my-local-repo'" in m] + assert len(winner) == 1 and len(alias) == 1 + assert "プラグイン p2 の projects/_foo を改名" in alias[0] + assert "元パス" not in alias[0] + + def test_unusable_real_directory_is_kept_and_warned_once( + self, registry, devbase_root, caplog): + """4: 実ディレクトリは残り、知らせは 1 回。同じ名前のプラグインの分は重ねない""" + _install_repo_plugin(registry, devbase_root, ["_foo", "ok-name"]) + (devbase_root / "projects" / "_foo").mkdir() + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 1 + real = devbase_root / "projects" / "_foo" + assert real.is_dir() and not real.is_symlink() + warnings = _name_form_warnings(caplog) + assert len(warnings) == 1 + [message] = warnings + assert "'_foo'" in message + assert "実ディレクトリ" in message + assert "プラグイン p1" not in message + + def test_dot_directories_stay_excluded_without_warning( + self, registry, devbase_root, caplog): + """5: `.` 始まりは今と同じく載らず、名前の形の警告も出ない""" + _install_repo_plugin(registry, devbase_root, [".hidden", "ok-name"]) + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 1 + assert not (devbase_root / "projects" / ".hidden").exists() + assert _name_form_warnings(caplog) == [] + + class TestExtractOwner: def test_repos_based(self): plugin = InstalledPlugin( From 8a0a66260d60ccf8cebd6b768ecb2c7129520240 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 23:54:12 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat(PLAN66):=20env=20import=20=E3=81=8C?= =?UTF-8?q?=E5=90=8D=E5=89=8D=E3=81=AE=E5=BD=A2=E3=81=AB=E5=90=88=E3=82=8F?= =?UTF-8?q?=E3=81=AA=E3=81=84=E3=83=97=E3=83=AD=E3=82=B8=E3=82=A7=E3=82=AF?= =?UTF-8?q?=E3=83=88=E5=90=8D=E3=82=92=E5=8F=96=E3=82=8A=E8=BE=BC=E3=82=80?= =?UTF-8?q?=E3=81=A8=E3=81=8D=E4=BF=9D=E5=AD=98=E5=85=88=E3=81=AB=E5=BF=9C?= =?UTF-8?q?=E3=81=98=E3=81=A6=E7=9F=A5=E3=82=89=E3=81=9B=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit import は今と同じく通し、書庫の名前の規則(_PROJECT_ENV_RE)も変えない(決定 4)。 知らせは _build_plans の直後、--dry-run の判定より前に出す。平文の projects//.env では作ることと改名の案内を、age・サーバ backend では保存先を名指しして projects/ に 何も作らないことを書く。env/secret_store.py は触らない。 Refs #203 Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/env/_import_merge.py | 9 ++++ lib/devbase/env/io_import.py | 30 +++++++++++ tests/cli/test_env_bundle_backend.py | 36 +++++++++++++ tests/env/test_io_import.py | 77 ++++++++++++++++++++++++++++ 4 files changed, 152 insertions(+) diff --git a/lib/devbase/env/_import_merge.py b/lib/devbase/env/_import_merge.py index 2fd5e08c..68ce0bcd 100644 --- a/lib/devbase/env/_import_merge.py +++ b/lib/devbase/env/_import_merge.py @@ -71,6 +71,15 @@ class Plan: before: Optional[bytes] = None +def project_name_of(arcname: str) -> Optional[str]: + """メンバー名 ``env/projects//.env`` のプロジェクト名 (それ以外は ``None``)。 + + 例外を投げない。メンバーの妥当性は :func:`filter_members` が既に見ている。 + """ + m = _PROJECT_ENV_RE.match(arcname) + return m.group(1) if m else None + + def target_for(arcname: str, devbase_root: Path) -> Path: """バンドル内 arcname を ``devbase_root`` 配下の書き出し先 Path に解決する""" if arcname == 'env/global.env': diff --git a/lib/devbase/env/io_import.py b/lib/devbase/env/io_import.py index 9a0a7f47..5ab0f84d 100644 --- a/lib/devbase/env/io_import.py +++ b/lib/devbase/env/io_import.py @@ -24,6 +24,7 @@ from devbase.errors import DevbaseError from devbase.log import get_logger +from devbase.utils.names import NAME_FORM_HINT, is_single_segment_name from devbase.env import _import_atomic as _atomic from devbase.env import _import_merge as _merge @@ -241,6 +242,33 @@ def _build_plans( return plans, sources_reference +def _warn_unusable_project_names(plans: List[_merge.Plan], root: Path) -> None: + """名前の形に合わないプロジェクト名を取り込む計画に、保存先に応じた文で 1 行ずつ知らせる。 + + 書庫の名前の規則は先頭の ``_`` を許すため import は通す (PLAN66 決定 4)。 + ``projects//`` を作るのはファイル backend の平文のときだけで、age とサーバの + backend は ``projects/`` に何も作らない。作らない保存先で「作る」と書かないよう、文は + ``plan.target`` と ``plan.ref`` から選ぶ。 + """ + for plan in plans: + name = _merge.project_name_of(plan.arcname) + if name is None or is_single_segment_name(name): + continue + usage = (f"この名前では、名前を指定した操作(devbase up {name} など)が" + f"できません({NAME_FORM_HINT})。") + if plan.ref is None and plan.target == root / 'projects' / name / '.env': + detail = (f"この import が projects/{name}/ を作ります。{usage}" + f"projects/{name} の中で名前なしに打てば動きます。" + f"projects/{name} を改名すれば直ります。") + else: + where = plan.target if plan.ref is None else f"サーバの{plan.ref.label()}" + detail = (f"保存先は {where} で、projects/ には何も作られません。" + f"この名前でプロジェクトを作っても、{usage}") + logger.warning( + "プロジェクト名として使えない形の名前を取り込みます: '%s'(出所: 書庫)。%s", + name, detail) + + def import_bundle(devbase_root: Path, opts: ImportOptions) -> int: """import 本体。CLI ハンドラから呼ばれる""" _validate_options(opts) @@ -277,6 +305,8 @@ def import_bundle(devbase_root: Path, opts: ImportOptions) -> int: _refuse_other_group_projects(store, filtered, group) plans, sources_reference = _build_plans(filtered, devbase_root, opts, store=store, group=group) + # --dry-run でも出す。書き込む前に何が起きるかを知らせるため + _warn_unusable_project_names(plans, store.root) _merge.log_plans(plans, opts.dry_run) if sources_reference is not None and not opts.merge_metadata: diff --git a/tests/cli/test_env_bundle_backend.py b/tests/cli/test_env_bundle_backend.py index 94ff00c8..8efe48de 100644 --- a/tests/cli/test_env_bundle_backend.py +++ b/tests/cli/test_env_bundle_backend.py @@ -318,6 +318,42 @@ def test_import_into_an_explicit_age_backend_encrypts_new_references(tmp_path, m assert not (root / '.env').exists() +def test_import_of_unusable_name_into_age_names_the_store_not_projects(tmp_path, monkeypatch, + bundle_keys, caplog): + """PLAN66 受け入れ条件 9: age の保存先では ``projects/`` を作らず、知らせは保存先を名指しする""" + import logging + + from devbase.env import agekeys, backend_config as bc + + root = tmp_path / 'root' + root.mkdir() + monkeypatch.setenv(agekeys.KEY_FILE_ENV, str(tmp_path / 'age' / 'keys.txt')) + monkeypatch.setenv('HOME', str(tmp_path / 'home')) + agekeys.generate_key_file() + bc.save(root, bc.BackendConfig(backend='age')) + pub, key = bundle_keys + src = make_bundle(tmp_path, pub, {'env/projects/_foo/.env': b'FOO=plain-foo\n'}) + + with caplog.at_level(logging.WARNING): + assert import_bundle(root, ImportOptions( + source=str(src), identities=[str(key)], include_global=False, + include_metadata=False)) == 0 + + assert not (root / 'projects' / '_foo').exists() + stored = root / 'secrets' / 'projects' / '_foo.env.age' + assert stored.read_bytes().startswith(b'age-encryption.org/') + warnings = [r.getMessage() for r in caplog.records + if r.levelno == logging.WARNING + and 'プロジェクト名として使えない形の名前' in r.getMessage()] + assert len(warnings) == 1 + [message] = warnings + assert "'_foo'" in message + assert str(stored) in message + assert 'projects/ には何も作られません' in message + assert 'を作ります' not in message + assert '改名' not in message + + # --------------------------------------------------------------------------- # グループ別の置き場 (PLAN56 受け入れ条件 13・決定 12・13) # --------------------------------------------------------------------------- diff --git a/tests/env/test_io_import.py b/tests/env/test_io_import.py index a658dca8..3cdd83ae 100644 --- a/tests/env/test_io_import.py +++ b/tests/env/test_io_import.py @@ -1,5 +1,6 @@ """import のサーバ更新後にローカル確定が失敗する経路の現状固定。""" +import logging import os from pathlib import Path @@ -49,3 +50,79 @@ def fail_first_metadata_replace(src, dst, *args, **kwargs): assert SecretStore(openbao_root).load(SecretRef.for_global()) == old_values assert sources.read_bytes() == original_metadata assert list(openbao_root.rglob('*.import.tmp')) == [] + + +# --------------------------------------------------------------------------- +# 名前の形の知らせ (PLAN66 受け入れ条件 6〜8)。import は今と同じく通す (決定 1・4) +# --------------------------------------------------------------------------- + +_NAME_FORM_WARNING = 'プロジェクト名として使えない形の名前' + + +def _name_form_warnings(caplog): + return [r.getMessage() for r in caplog.records + if r.levelno == logging.WARNING and _NAME_FORM_WARNING in r.getMessage()] + + +def _write_project_bundle(tmp_path, names): + src = tmp_path / 'incoming.dbenv' + src.write_bytes(bundle.pack([ + bundle.BundleEntry(arcname=f'env/projects/{name}/.env', + origin=f'env/projects/{name}/.env', data=b'KEY=value\n') + for name in names + ])) + return src + + +def _project_options(src, **kwargs): + return ImportOptions(source=str(src), include_global=False, include_metadata=False, + **kwargs) + + +def test_import_creates_unusable_project_and_warns_once(tmp_path, caplog): + """6: 今と同じく 2 つのディレクトリを作って 0 で終わり、``_foo`` だけを 1 回知らせる""" + root = tmp_path / 'root' + root.mkdir() + src = _write_project_bundle(tmp_path, ['_foo', 'ok-name']) + + with caplog.at_level(logging.WARNING): + assert import_bundle(root, _project_options(src)) == 0 + + assert (root / 'projects' / '_foo' / '.env').read_bytes() == b'KEY=value\n' + assert (root / 'projects' / 'ok-name' / '.env').read_bytes() == b'KEY=value\n' + warnings = _name_form_warnings(caplog) + assert len(warnings) == 1 + [message] = warnings + assert "'_foo'" in message + assert 'ok-name' not in message + assert 'projects/_foo/ を作ります' in message + assert '名前なしに打てば動きます' in message + assert 'projects/_foo を改名' in message + + +def test_import_dry_run_warns_without_writing(tmp_path, caplog): + """7: ``--dry-run`` では書き込まないが、知らせは出す""" + root = tmp_path / 'root' + root.mkdir() + src = _write_project_bundle(tmp_path, ['_foo', 'ok-name']) + + with caplog.at_level(logging.WARNING): + assert import_bundle(root, _project_options(src, dry_run=True)) == 0 + + assert not (root / 'projects').exists() + warnings = _name_form_warnings(caplog) + assert len(warnings) == 1 + assert "'_foo'" in warnings[0] + + +def test_import_of_usable_names_does_not_warn(tmp_path, caplog): + """8: 名前の形に合う名前だけなら、名前の形の警告は 1 行も出ない""" + root = tmp_path / 'root' + root.mkdir() + src = _write_project_bundle(tmp_path, ['ok-name', 'carmo_ai']) + + with caplog.at_level(logging.WARNING): + assert import_bundle(root, _project_options(src)) == 0 + + assert (root / 'projects' / 'ok-name' / '.env').is_file() + assert _name_form_warnings(caplog) == [] From 788d7273e5f64b524a19874fc3d62681d5051944 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 23:54:26 +0900 Subject: [PATCH 5/7] =?UTF-8?q?docs(PLAN66):=20=E7=A2=BA=E5=AE=9A=E4=BB=95?= =?UTF-8?q?=E6=A7=98=E3=81=AE=E3=80=8C=E9=81=8B=E7=94=A8=E3=80=8D=E3=81=A8?= =?UTF-8?q?=20CHANGELOG=20=E3=81=AB=E5=90=8D=E5=89=8D=E3=81=AE=E5=BD=A2?= =?UTF-8?q?=E3=81=AE=E7=9F=A5=E3=82=89=E3=81=9B=E3=82=92=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #203 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 9 +++++++++ docs/specifications/cli-argument-resolution.md | 11 ++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f0a5aaa6..7d132985 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ ## [Unreleased] +### Added +- **名前の形に合わないプロジェクト(`_foo` など)が `projects/` に載る時点で、警告を 1 行出すように + しました(PLAN66 / #203)。** `devbase plugin install` / `update` / `sync` は、プラグインの + プロジェクト・衝突のときに合成する別名 `<名前>.`・`projects/` 直下の実ディレクトリの + 名前を見ます。`devbase env import` は取り込むプロジェクト名を見て、保存先が `projects/` の外 + (age・サーバ backend)ならそのことも知らせます(`--dry-run` でも出ます)。知らせるだけで弾かず、 + 作られる symlink・ディレクトリと終了コードは変わりません。そうした名前は名前を指定した操作 + (`devbase up _foo` など)ができず、そのディレクトリの中で名前なしに打てば動きます。 + ## [3.6.0] - 2026-09-19 ### Added diff --git a/docs/specifications/cli-argument-resolution.md b/docs/specifications/cli-argument-resolution.md index 5adf34df..1859a40b 100644 --- a/docs/specifications/cli-argument-resolution.md +++ b/docs/specifications/cli-argument-resolution.md @@ -299,7 +299,16 @@ Python 側の `_resolve_project_name` は同じ結果になるよう、`chdir` ## 運用 - 名前の形に合わないプロジェクト(`_` で始まる名前など)は、名前の指定(CLI の `[name]` と - `devbase list` の一覧)から操作できない。そのディレクトリの中で名前なしに打てば動く + `devbase list` の一覧)から操作できない。そのディレクトリの中で名前なしに打てば動く。 + **そうした名前が `projects/` に載る時点で、警告が 1 行出る**(PLAN66)。出所は 4 つある。 + プラグインの同期が張る symlink(`plugin install` / `update` / `sync`)、同期が衝突のときに + 合成する別名 `<名前>.`、`devbase env import` が作る実ディレクトリ、手で作った + 実ディレクトリ(同期のたびに知らせる)。**知らせは出すが弾かない。** 同期と import は + 今と同じものを作り、終了コードも変えない(弾くと、名前なしに打つ使い方まで失うため)。 + `env import` の保存先が `projects/` の外(age・サーバ backend)のときは、`projects/` に何も + 作らないことと保存先を知らせる。判定は `utils/names.is_single_segment_name`、名前の形の + 説明文は `utils/names.NAME_FORM_HINT` の 1 か所にあり、`.` 始まりの名前は今と同じく + 同期の対象にならず知らせも出ない - 名前の検証はリポジトリの中で 1 つに寄せていない。`env/bundle.py` の `is_valid_project_name` (先頭の `_` を許す。`env` の export / import の書庫の中の名前)、`env/secret_store.py` の `_validate_project_name`(機密の保存先のファイル名)、`snapshot/manager.py` の `_VALID_NAME_RE` From 570a2fe5e4542a9139ce83eafb9dc0e1a5469079 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 00:10:53 +0900 Subject: [PATCH 6/7] Test: characterize project name extraction, server import, and sync aliases Add characterization coverage for R1-001, R1-002, and R1-003 without changing production code. Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- tests/cli/test_env_bundle_backend.py | 25 +++++++++++++++++++++++++ tests/env/test_io_import.py | 17 +++++++++++++++++ tests/plugin/test_repos_core.py | 21 +++++++++++++++++++++ 3 files changed, 63 insertions(+) diff --git a/tests/cli/test_env_bundle_backend.py b/tests/cli/test_env_bundle_backend.py index 8efe48de..929558a8 100644 --- a/tests/cli/test_env_bundle_backend.py +++ b/tests/cli/test_env_bundle_backend.py @@ -318,6 +318,31 @@ def test_import_into_an_explicit_age_backend_encrypts_new_references(tmp_path, m assert not (root / '.env').exists() +def test_import_of_unusable_name_into_server_names_the_store_not_projects( + openbao_root, openbao, bundle_keys, tmp_path, caplog): + """形に合わない名前もサーバへ保存し、保存先を1回知らせる現状を固定する。""" + import logging + + pub, key = bundle_keys + src = make_bundle(tmp_path, pub, {'env/projects/_foo/.env': b'FOO=plain-foo\n'}) + + with caplog.at_level(logging.WARNING): + assert import_bundle(openbao_root, ImportOptions( + source=str(src), identities=[str(key)], include_global=False, + include_metadata=False)) == 0 + + assert not (openbao_root / 'projects' / '_foo').exists() + assert openbao.get('team/projects/_foo') == {'FOO': 'plain-foo'} + warnings = [r.getMessage() for r in caplog.records + if r.levelno == logging.WARNING + and 'プロジェクト名として使えない形の名前' in r.getMessage()] + assert len(warnings) == 1 + [message] = warnings + assert "'_foo'" in message + assert "サーバのプロジェクト '_foo'" in message + assert 'projects/ には何も作られません' in message + + def test_import_of_unusable_name_into_age_names_the_store_not_projects(tmp_path, monkeypatch, bundle_keys, caplog): """PLAN66 受け入れ条件 9: age の保存先では ``projects/`` を作らず、知らせは保存先を名指しする""" diff --git a/tests/env/test_io_import.py b/tests/env/test_io_import.py index 3cdd83ae..a4b58155 100644 --- a/tests/env/test_io_import.py +++ b/tests/env/test_io_import.py @@ -7,10 +7,27 @@ import pytest from devbase.env import bundle +from devbase.env._import_merge import project_name_of from devbase.env.io_import import ImportError as EnvImportError, ImportOptions, import_bundle from devbase.env.secret_store import SecretRef, SecretStore +@pytest.mark.parametrize(('arcname', 'expected'), [ + ('env/projects/web/.env', 'web'), + ('env/projects/_foo/.env', '_foo'), + ('env/global.env', None), + ('env/sources.yml', None), + ('projects/web/.env', None), + ('env/projects/web/nested/.env', None), + ('env/projects/web', None), + ('env/projects//.env', None), + ('env/projects/../.env', None), +]) +def test_project_name_of_current_member_paths(arcname, expected): + """メンバー名からの抽出と、形式外パスを例外なく無視する現状を固定する。""" + assert project_name_of(arcname) == expected + + def test_import_restores_server_and_metadata_when_local_commit_fails( openbao_root, openbao, monkeypatch, ): diff --git a/tests/plugin/test_repos_core.py b/tests/plugin/test_repos_core.py index 74ba4202..a0be1bcb 100644 --- a/tests/plugin/test_repos_core.py +++ b/tests/plugin/test_repos_core.py @@ -799,6 +799,27 @@ def test_usable_names_are_not_warned(self, registry, devbase_root, caplog): assert count == 3 assert _name_form_warnings(caplog) == [] + def test_alias_with_usable_base_and_owner_is_linked_without_warning( + self, registry, devbase_root, caplog): + """競合時に正常な名前の別名リンクを警告なしで作る現状を固定する。""" + _install_repo_plugin(registry, devbase_root, ["carmo"], priority=10) + _install_link_plugin(registry, devbase_root, "my-local-repo", ["carmo"]) + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 2 + winner = devbase_root / "projects" / "carmo" + alias = devbase_root / "projects" / "carmo.my-local-repo" + assert winner.is_symlink() + assert alias.is_symlink() + assert winner.resolve() == ( + devbase_root / "repos" / "github.com--testorg--testrepo" + / "p1" / "projects" / "carmo") + assert alias.resolve() == devbase_root / "my-local-repo" / "p2" / "projects" / "carmo" + assert winner.is_dir() and alias.is_dir() + assert _name_form_warnings(caplog) == [] + def test_alias_with_unusable_owner_points_at_the_owner( self, registry, devbase_root, caplog): """3: 別名の の側が原因なら、プラグイン側の改名を促さない""" From 7687bfb2bcf9ae21d52310883029225305d1728e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Wed, 23 Sep 2026 00:37:55 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix(PLAN66):=20projects/=20=E7=9B=B4?= =?UTF-8?q?=E4=B8=8B=E3=81=AE=20.=20=E5=A7=8B=E3=81=BE=E3=82=8A=E3=81=AE?= =?UTF-8?q?=E5=AE=9F=E3=83=87=E3=82=A3=E3=83=AC=E3=82=AF=E3=83=88=E3=83=AA?= =?UTF-8?q?=E3=81=AB=E5=90=8D=E5=89=8D=E3=81=AE=E5=BD=A2=E3=81=AE=E8=AD=A6?= =?UTF-8?q?=E5=91=8A=E3=82=92=E5=87=BA=E3=81=95=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync_projects の実ディレクトリの知らせが .vscode などにも出ていた。 決定 8 のとおり . 始まりはプロジェクトとして扱わず、知らせも出さない。 回帰テスト test_dot_real_directories_are_not_warned を追加。 Co-Authored-By: Claude Opus 5 (1M context) --- lib/devbase/plugin/syncer.py | 4 +++- tests/plugin/test_repos_core.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/devbase/plugin/syncer.py b/lib/devbase/plugin/syncer.py index f48a6d1e..61f0b183 100644 --- a/lib/devbase/plugin/syncer.py +++ b/lib/devbase/plugin/syncer.py @@ -198,9 +198,11 @@ def sync_projects(registry: PluginRegistry, verbose: bool = True) -> int: if not entry.is_symlink() and entry.is_dir() } # 実ディレクトリは同期が作らないが、ここが唯一それを列挙する場所 (決定 2)。 + # `.` 始まり (.vscode など) はプロジェクトとして扱わず知らせも出さない (決定 8)。 # set のままだと警告の順が実行ごとに変わるため並べる for name in sorted(real_projects): - _warn_unusable_name(name, _SOURCE_REAL_DIRECTORY) + if not name.startswith('.'): + _warn_unusable_name(name, _SOURCE_REAL_DIRECTORY) for entry in projects_dir.iterdir(): if entry.is_symlink(): diff --git a/tests/plugin/test_repos_core.py b/tests/plugin/test_repos_core.py index a0be1bcb..58e92bf4 100644 --- a/tests/plugin/test_repos_core.py +++ b/tests/plugin/test_repos_core.py @@ -890,6 +890,20 @@ def test_dot_directories_stay_excluded_without_warning( assert not (devbase_root / "projects" / ".hidden").exists() assert _name_form_warnings(caplog) == [] + def test_dot_real_directories_are_not_warned( + self, registry, devbase_root, caplog): + """5-2: `projects/` 直下の `.` 始まりの実ディレクトリも警告しない (決定 8)""" + _install_repo_plugin(registry, devbase_root, ["ok-name"]) + (devbase_root / "projects" / ".vscode").mkdir(parents=True) + + with caplog.at_level(logging.WARNING): + count = sync_projects(registry, verbose=False) + + assert count == 1 + real = devbase_root / "projects" / ".vscode" + assert real.is_dir() and not real.is_symlink() + assert _name_form_warnings(caplog) == [] + class TestExtractOwner: def test_repos_based(self):