Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@

## [Unreleased]

### Added
- **名前の形に合わないプロジェクト(`_foo` など)が `projects/` に載る時点で、警告を 1 行出すように
しました(PLAN66 / #203)。** `devbase plugin install` / `update` / `sync` は、プラグインの
プロジェクト・衝突のときに合成する別名 `<名前>.<owner>`・`projects/` 直下の実ディレクトリの
名前を見ます。`devbase env import` は取り込むプロジェクト名を見て、保存先が `projects/` の外
(age・サーバ backend)ならそのことも知らせます(`--dry-run` でも出ます)。知らせるだけで弾かず、
作られる symlink・ディレクトリと終了コードは変わりません。そうした名前は名前を指定した操作
(`devbase up _foo` など)ができず、そのディレクトリの中で名前なしに打てば動きます。

## [3.6.0] - 2026-09-19

### Added
Expand Down
11 changes: 10 additions & 1 deletion docs/specifications/cli-argument-resolution.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,16 @@ Python 側の `_resolve_project_name` は同じ結果になるよう、`chdir`
## 運用

- 名前の形に合わないプロジェクト(`_` で始まる名前など)は、名前の指定(CLI の `[name]` と
`devbase list` の一覧)から操作できない。そのディレクトリの中で名前なしに打てば動く
`devbase list` の一覧)から操作できない。そのディレクトリの中で名前なしに打てば動く。
**そうした名前が `projects/` に載る時点で、警告が 1 行出る**(PLAN66)。出所は 4 つある。
プラグインの同期が張る symlink(`plugin install` / `update` / `sync`)、同期が衝突のときに
合成する別名 `<名前>.<owner>`、`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`
Expand Down
98 changes: 98 additions & 0 deletions issues/PLAN66_project-name-validation-impl1.md
Original file line number Diff line number Diff line change
@@ -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 と実行結果を載せる
9 changes: 9 additions & 0 deletions lib/devbase/env/_import_merge.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ class Plan:
before: Optional[bytes] = None


def project_name_of(arcname: str) -> Optional[str]:
"""メンバー名 ``env/projects/<name>/.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':
Expand Down
30 changes: 30 additions & 0 deletions lib/devbase/env/io_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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/<name>/`` を作るのはファイル 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)
Expand Down Expand Up @@ -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:
Expand Down
53 changes: 53 additions & 0 deletions lib/devbase/plugin/syncer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: 別名 (``<base>.<owner>``) のときだけ渡す元のプロジェクト名
"""
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> の側にある
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,
Expand All @@ -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
Expand All @@ -151,6 +197,12 @@ 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)。
# `.` 始まり (.vscode など) はプロジェクトとして扱わず知らせも出さない (決定 8)。
# set のままだと警告の順が実行ごとに変わるため並べる
for name in sorted(real_projects):
Comment thread
takemi-ohama marked this conversation as resolved.
if not name.startswith('.'):
_warn_unusable_name(name, _SOURCE_REAL_DIRECTORY)

for entry in projects_dir.iterdir():
if entry.is_symlink():
Expand Down Expand Up @@ -186,6 +238,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
Expand Down
4 changes: 4 additions & 0 deletions lib/devbase/utils/names.py
Original file line number Diff line number Diff line change
Expand Up @@ -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``)。
Expand Down
Loading