From 855fd77026ef7787811e78084e8dfa4f9bc56826 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 09:17:34 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat(PLAN61):=20Python=20=E5=81=B4=E3=81=AE?= =?UTF-8?q?=203=20=E5=85=A5=E5=8F=A3=E3=81=A7=E5=90=8D=E5=89=8D=E3=81=AE?= =?UTF-8?q?=E5=BD=A2=E3=82=92=E6=A4=9C=E8=A8=BC=E3=81=97=E3=80=81projects/?= =?UTF-8?q?=20=E3=81=AE=E5=A4=96=E3=81=B8=E5=87=BA=E3=81=AA=E3=81=84=20(#1?= =?UTF-8?q?46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - utils/names.py を新設し、SINGLE_SEGMENT_NAME_PATTERN と is_single_segment_name に 名前の形の規則を 1 か所で置く - _resolve_project_name は形に合わない名前で chdir せず、env も候補も読まずに False - _named_lifecycle_project は形に合わない名前で実在と store_for を見ずに None - _build_single_image の _IMAGE_NAME_RE を同じ関数へ寄せる - 実装計画を issues/PLAN61_name-resolution.md の末尾に足す Co-Authored-By: Claude Opus 5 (1M context) --- issues/PLAN61_name-resolution.md | 89 +++++++++++++++++++++++ lib/devbase/cli.py | 6 ++ lib/devbase/commands/container.py | 24 ++++-- lib/devbase/utils/names.py | 32 ++++++++ tests/cli/test_build_image_argument.py | 12 +++ tests/cli/test_project_name_resolution.py | 48 ++++++++++++ tests/cli/test_secret_injection.py | 23 ++++++ tests/utils/test_names.py | 35 +++++++++ 8 files changed, 261 insertions(+), 8 deletions(-) create mode 100644 lib/devbase/utils/names.py create mode 100644 tests/utils/test_names.py diff --git a/issues/PLAN61_name-resolution.md b/issues/PLAN61_name-resolution.md index 46b2000c..a7c15556 100644 --- a/issues/PLAN61_name-resolution.md +++ b/issues/PLAN61_name-resolution.md @@ -185,3 +185,92 @@ | 常に行う | 手元で全体テスト、`shellcheck` | | 確認してから行う | 前提 3・4 の挙動の決定(設計 Pull Request の承認で確かめる) | | 行わない | `container` グループの削除、新しいフラグの追加、補完の変更 | + +## 実装計画 + +設計は [PLAN61_name-resolution-design.md](PLAN61_name-resolution-design.md)。タスクは設計の「構成要素」と「テスト設計」から導き、 +機能単位(F1〜F4)で分ける。各タスクは失敗するテスト → 通す最小実装 → 整理の順に進める。 + +### 修正対象 + +- `lib/devbase/utils/names.py`(新設)、`lib/devbase/commands/container.py`、`lib/devbase/cli.py` +- `bin/devbase` +- `tests/cli/conftest.py`(新設)、`tests/utils/test_names.py`(新設)、`tests/cli/test_project_name_resolution.py`、 + `tests/cli/test_build_image_argument.py`、`tests/cli/test_project_dispatch.py`、`tests/cli/test_secret_injection.py` +- `docs/user/cli-reference/02-project.md`、`docs/user/cli-reference/README.md`、`docs/developer/architecture.md`、 + `docs/specifications/editor-open.md`、`CHANGELOG.md` + +### Task 1: 名前の形の規則(Python)と Python 側 3 入口の検証(F1) + +- **対象ファイル:** `lib/devbase/utils/names.py`(新設)、`lib/devbase/commands/container.py`、`lib/devbase/cli.py`、 + `tests/utils/test_names.py`(新設)、`tests/cli/test_project_name_resolution.py`、`tests/cli/test_build_image_argument.py`、 + `tests/cli/test_secret_injection.py` +- **変更内容:** `SINGLE_SEGMENT_NAME_PATTERN` と `is_single_segment_name` を新設。`_build_single_image` の `_IMAGE_NAME_RE` を + これへ寄せる。`_resolve_project_name` の入口で形を見て、合わなければ error ログを出して `False`(chdir・env・候補なし)。 + `_named_lifecycle_project` は形に合わなければ実在と `store_for` を見ずに `None` +- **満たす受け入れ条件:** 1(単体)、2(単体)、3、4、5(単体)、決定 4(単体) +- **進め方:** テスト駆動 + +### Task 2: wrapper のハーネス `exec_wrapper` と shell 側の名前の形(F1) + +- **対象ファイル:** `tests/cli/conftest.py`(新設)、`bin/devbase`、`tests/cli/test_project_name_resolution.py` +- **変更内容:** `bin/devbase` を tmp へ複製し `fakebin/uv` で dispatch の先だけを差し替える fixture。`bin/devbase` に + `_SINGLE_SEGMENT_NAME_RE` と `is_single_segment_name`(`local LC_ALL=C`)を足し、`maybe_cd_project` の入口を置き換える。 + 同期テスト(wrapper の正規表現 = `'^' + SINGLE_SEGMENT_NAME_PATTERN + '$'`) +- **満たす受け入れ条件:** 2(wrapper)、5(wrapper)、15、決定 2、決定 4(wrapper) +- **進め方:** テスト駆動 + +### Task 3: `build --help` / `-h`(F3) + +- **対象ファイル:** `bin/devbase`、`tests/cli/test_build_image_argument.py` +- **変更内容:** `build_usage` を新設し、name 解決の case より前で `build` の引数に `-h` / `--help` があれば使い方を出して 0 で終わる。 + `--context=--help` / `--context=-h` は使い方にしない +- **満たす受け入れ条件:** 9、10、11、決定 8 +- **進め方:** テスト駆動 + +### Task 4: `build ` のイメージとプロジェクトの衝突(F2) + +- **対象ファイル:** `bin/devbase`、`tests/cli/test_build_image_argument.py` +- **変更内容:** name 解決の case に `build` の分岐を足す。`$2` が形に合い `containers/$2` が実在すれば name 解決を通さず、 + `projects/$2` もあれば stderr に 1 行。`build)` 分岐の PLAN49 の注記も合わせる +- **満たす受け入れ条件:** 1(wrapper)、6、7、8 +- **進め方:** テスト駆動 + +### Task 5: `container` / `ct` を name 解決から外す(F4) + +- **対象ファイル:** `bin/devbase`、`lib/devbase/cli.py`(コメント)、`tests/cli/test_project_name_resolution.py`、 + `tests/cli/test_project_dispatch.py` +- **変更内容:** name 解決の case を `project` だけにする。`test_wrapper_ct_up_name_cds_and_strips` を受け入れ条件 12 のテストへ + 置き換える。name 解決の説明コメントを変更後の規則に書き替え、`cli.py` の同期注意を `project` だけに直す +- **満たす受け入れ条件:** 12、13、14 +- **進め方:** テスト駆動 + +### Task 6: 文書と CHANGELOG + +- **対象ファイル:** `docs/user/cli-reference/02-project.md`、`docs/user/cli-reference/README.md`、`docs/developer/architecture.md`、 + `docs/specifications/editor-open.md`、`CHANGELOG.md` +- **変更内容:** 設計の「文書とテスト」の表のとおり +- **満たす受け入れ条件:** (文書。受け入れ条件には対応しない) +- **進め方:** テスト駆動を適用しない(文書のみ) + +### Task 7: 全体の検証 + +- **変更内容:** `uv run --locked pytest -q tests/`、`shellcheck --severity=error bin/devbase`、`ruff check --select=E9,F63,F7,F82 lib`、 + macOS の `/bin/bash`(3.2)で `tests/cli` +- **満たす受け入れ条件:** 16 + +### リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `bin/devbase` は 1 ファイルの shell で、name 解決と `build)` の分岐を 4 タスクが触る | タスクごとにテストを通す。既存の `sed` ハーネスのテストを退行の検出に使う(受け入れ条件 14) | +| pytest が実環境の `DEVBASE_ROOT` を継承する | `exec_wrapper` は wrapper の複製から `DEVBASE_ROOT` を tmp に決めさせる(決定 11) | + +### 切り戻し手順 + +- コードの変更のみ(データ移行なし)。ブランチの revert で戻せる + +### 完了の定義 + +- [ ] 受け入れ条件 16 件に検証手段(テスト名)が対応している +- [ ] `uv run --locked pytest -q tests/` が exit 0、`shellcheck --severity=error bin/devbase` と `ruff check --select=E9,F63,F7,F82 lib` が exit 0 diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index a52c3eb6..0670ade8 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -10,6 +10,7 @@ from devbase.errors import DevbaseError from devbase.log import get_logger, setup +from devbase.utils.names import is_single_segment_name try: from . import __version__ @@ -946,6 +947,11 @@ def _named_lifecycle_project(root: Path, cmd: str, subcommand: Optional[str], return None if cmd not in SHORTCUTS and GROUP_ALIASES.get(cmd, cmd) != 'project': return None + # `name` は projects/ へそのまま連結する。`..` や `/` を通すと projects/ の外の + # ディレクトリの実在を見て、そこの env の宣言を読むため、連結の前に名前の形で弾く + # (PLAN61 / #146)。規則は container._resolve_project_name と同じ。 + if not is_single_segment_name(name): + return None if not (root / 'projects' / name).is_dir(): return None from devbase.env import runtime as _runtime diff --git a/lib/devbase/commands/container.py b/lib/devbase/commands/container.py index 5b526063..43b2dc4e 100644 --- a/lib/devbase/commands/container.py +++ b/lib/devbase/commands/container.py @@ -29,6 +29,7 @@ running_dev_instances, ) from devbase.utils.config import get_project_name +from devbase.utils.names import is_single_segment_name from devbase.utils import docker_context from devbase.project import runtime as project_runtime from devbase.project.local_config import load_project_local_config @@ -584,8 +585,19 @@ def _resolve_project_name(project_name: str) -> bool: Returns: True: 解決成功 (または既に対象ディレクトリにいる) - False: DEVBASE_ROOT 未設定 / 対象が存在しない (呼び出し側で return 1) + False: DEVBASE_ROOT 未設定 / 名前の形に合わない / 対象が存在しない + (呼び出し側で return 1) """ + # `project_name` は projects/ へそのまま連結する。`..` や `/` を通すと projects/ の + # 外のディレクトリへ chdir してそこの env を読むため、連結の前に名前の形で弾く + # (PLAN61 / #146)。wrapper (bin/devbase の maybe_cd_project) も同じ規則で弾く。 + if not is_single_segment_name(project_name): + logger.error( + "プロジェクト名に使えない形です: '%s'" + "(英数字で始まり、英数字・'.'・'-'・'_' だけからなる名前)", + project_name) + return False + projects_dir = _projects_dir() if projects_dir is None: logger.error("DEVBASE_ROOT が未設定のため project name '%s' を解決できません。", @@ -1676,11 +1688,6 @@ def cmd_scale(new_scale: int, project_name: str = None, # cmd_build # --------------------------------------------------------------------------- -# 単体ビルドで受け付けるイメージ名。`containers/` 配下の 1 ディレクトリ名であることを -# 保証するため、英数字始まりで英数字・ハイフン・アンダースコア・ピリオドのみを許可する。 -_IMAGE_NAME_RE = re.compile(r'[A-Za-z0-9][A-Za-z0-9._-]*') - - def _build_single_image(image: str, no_cache: bool = False) -> int: """``$DEVBASE_ROOT/containers/`` を単体ビルドする (PLAN49 / #139)。 @@ -1698,8 +1705,9 @@ def _build_single_image(image: str, no_cache: bool = False) -> int: # `image` はパスの一部として連結し、そのままタグにもなる。`/` `\` `..` などを # 通すと $DEVBASE_ROOT の外を指せてしまい、Docker タグとして不正な名前も作れるため、 - # ディレクトリ名 1 つとして妥当な文字だけを許可し、それ以外はここで弾く。 - if not _IMAGE_NAME_RE.fullmatch(image): + # ディレクトリ名 1 つとして妥当な文字だけを許可し、それ以外はここで弾く。規則は + # プロジェクト名と同じ (devbase.utils.names / PLAN61 決定 2)。 + if not is_single_segment_name(image): logger.error( "Invalid image name: %r (must be a single directory name under " "containers/: alphanumeric start, then letters, digits, '.', '-', '_')", diff --git a/lib/devbase/utils/names.py b/lib/devbase/utils/names.py new file mode 100644 index 00000000..f36fffae --- /dev/null +++ b/lib/devbase/utils/names.py @@ -0,0 +1,32 @@ +"""名前の形の規則 (PLAN61)。 + +``projects/`` と ``containers/`` の直下の 1 つの名前として受け付ける形を 1 か所で決める。 +名前を親ディレクトリへ連結する入口 (``container._resolve_project_name``、 +``cli._named_lifecycle_project``、``container._build_single_image``) は、連結の前にここで +弾く。``..`` や ``/`` を通すと ``$DEVBASE_ROOT`` の外を指せてしまうため。 + +``re`` だけに依存する。``cli.py`` は起動を軽く保つためコマンドのモジュールを dispatch まで +読まないので、規則はコマンドのモジュールではなくここに置く (設計の決定 2)。 + +同期注意: ``bin/devbase`` の ``_SINGLE_SEGMENT_NAME_RE`` は同じ正規表現を文字列で持つ +(Python を呼ぶと name 解決のたびに ``uv run`` の起動が増えるため)。片方を変えたら +もう片方も変える。一致は ``tests/cli/test_project_name_resolution.py`` の同期テストで見る。 +""" + +from __future__ import annotations + +import re + +#: 英数字で始まり、英数字・``.``・``-``・``_`` だけからなる。先頭が英数字なので ``.`` と +#: ``..`` と ``-x`` と空は当たらない。``/`` ``\`` 空白 非 ASCII は含められない。 +SINGLE_SEGMENT_NAME_PATTERN = r'[A-Za-z0-9][A-Za-z0-9._-]*' + +_SINGLE_SEGMENT_NAME_RE = re.compile(SINGLE_SEGMENT_NAME_PATTERN) + + +def is_single_segment_name(value: str) -> bool: + """``value`` が親ディレクトリの直下の 1 つの名前の形か (``re.fullmatch``)。 + + ``fullmatch`` で見るため、末尾の改行も不一致になる。 + """ + return _SINGLE_SEGMENT_NAME_RE.fullmatch(value) is not None diff --git a/tests/cli/test_build_image_argument.py b/tests/cli/test_build_image_argument.py index c2982768..e0b00f51 100644 --- a/tests/cli/test_build_image_argument.py +++ b/tests/cli/test_build_image_argument.py @@ -293,6 +293,18 @@ def test_single_build_rejects_invalid_image_name( assert "Invalid image name" in caplog.text +def test_cli_project_build_rejects_traversal_image(devbase_root, captured_run, monkeypatch, caplog): + """受け入れ条件 1 (単体): `python -m devbase.cli project build ../etc` は docker を起動せず 1。""" + from devbase import cli + + monkeypatch.setattr("sys.argv", ["devbase", "project", "build", "../etc"]) + with caplog.at_level(logging.ERROR): + assert cli.main() == 1 + + assert captured_run == [] + assert "Invalid image name" in caplog.text + + def test_single_build_accepts_real_container_directory_names(devbase_root, captured_run): """`containers/` 配下の実在ディレクトリ名は検証を通る。""" names = ["base", "bi-tools", "general", "go", "latex", diff --git a/tests/cli/test_project_name_resolution.py b/tests/cli/test_project_name_resolution.py index 5523c3a3..3de26789 100644 --- a/tests/cli/test_project_name_resolution.py +++ b/tests/cli/test_project_name_resolution.py @@ -120,6 +120,54 @@ def test_resolve_without_devbase_root(tmp_path, monkeypatch, caplog): assert any("DEVBASE_ROOT" in r.message for r in caplog.records) +INVALID_NAMES = ["../etc", "a/b", ".", ".."] + + +@pytest.mark.parametrize("name", INVALID_NAMES) +def test_resolve_rejects_malformed_name_without_chdir(fake_root, monkeypatch, caplog, name): + """受け入れ条件 2 (単体): 形に合わない名前は chdir せず、env も読まず、候補も出さず False。 + + `projects/` へそのまま連結すると `..` で `projects/` の外へ出るため、連結の前に + 名前の形 (`devbase.utils.names.is_single_segment_name`) で弾く (#146)。 + """ + (fake_root / "etc").mkdir() + (fake_root / "etc" / "env").write_text("MARKER=leaked\n") + monkeypatch.delenv("MARKER", raising=False) + called = [] + monkeypatch.setattr(container.os, "chdir", lambda p: called.append(p)) + + with caplog.at_level(logging.ERROR, logger="devbase.commands.container"): + assert container._resolve_project_name(name) is False + + assert called == [], "形に合わない名前で chdir を呼んではならない" + assert "MARKER" not in os.environ + messages = " ".join(r.message for r in caplog.records) + assert "プロジェクト名に使えない形" in messages + assert name in messages + # 候補の一覧は出さない + assert "carmo" not in messages and "shop" not in messages + + +def test_cli_project_up_rejects_malformed_name(fake_root, monkeypatch, caplog): + """受け入れ条件 3: wrapper を経ない `python -m devbase.cli project up ../etc` は chdir せず 1。""" + from devbase import cli + + (fake_root / "etc").mkdir() + (fake_root / "etc" / "env").write_text("MARKER=leaked\n") + monkeypatch.delenv("MARKER", raising=False) + monkeypatch.setattr(container, "cmd_up", + lambda *a, **k: pytest.fail("cmd_up を呼んではならない")) + monkeypatch.setattr("sys.argv", ["devbase", "project", "up", "../etc"]) + before = Path.cwd() + + with caplog.at_level(logging.ERROR, logger="devbase.commands.container"): + assert cli.main() == 1 + + assert Path.cwd() == before + assert "MARKER" not in os.environ + assert "プロジェクト名に使えない形" in caplog.text + + def test_resolve_noop_when_already_in_target(fake_root, monkeypatch): """wrapper が既に cd 済みなら chdir を呼ばない (冪等)。""" target = fake_root / "projects" / "carmo" diff --git a/tests/cli/test_secret_injection.py b/tests/cli/test_secret_injection.py index 9b073294..e768d4cd 100644 --- a/tests/cli/test_secret_injection.py +++ b/tests/cli/test_secret_injection.py @@ -151,6 +151,29 @@ def test_unknown_name_keeps_the_current_project(calls, tmp_path): assert calls == [(tmp_path, None)] +@pytest.mark.parametrize('name', ['../etc', 'a/b', '.', '..']) +def test_malformed_name_is_not_a_project_and_reads_nothing(calls, tmp_path, monkeypatch, name): + """受け入れ条件 4: 形に合わない名前は projects/ の外の env を読まず、設定も読まずに None (PLAN61)。 + + `projects/../etc` が実在すると、名前を連結してから実在を見る形では `etc/env` の宣言まで + 読みに行く。名前の形を先に見て、`groups.declare` と `runtime.store_for` を呼ばない。 + """ + from devbase.env import groups, runtime + + _grouped(tmp_path) + (tmp_path / 'etc').mkdir() + (tmp_path / 'etc' / 'env').write_text('DEVBASE_ACCOUNT_GROUP=leaked\n', encoding='utf-8') + monkeypatch.setattr(groups, 'declare', + lambda root, project: pytest.fail('groups.declare を呼んではならない')) + monkeypatch.setattr(runtime, 'store_for', + lambda root: pytest.fail('runtime.store_for を呼んではならない')) + + assert cli._named_lifecycle_project(tmp_path, 'project', 'up', name) is None + + cli._load_secret_env('project', 'up', name=name) + assert calls == [(tmp_path, None)] + + def test_name_of_a_non_lifecycle_command_is_not_a_project(calls, tmp_path): _grouped(tmp_path) diff --git a/tests/utils/test_names.py b/tests/utils/test_names.py new file mode 100644 index 00000000..32eed1b1 --- /dev/null +++ b/tests/utils/test_names.py @@ -0,0 +1,35 @@ +"""PLAN61: 名前の形の規則 (`devbase.utils.names`)。 + +`projects/` と `containers/` の直下の 1 つの名前として受け付ける形を 1 か所で決める。 +先頭が英数字なので `.` と `..` は当たらず、`/` `\\` 空白 非 ASCII は含められない。 +""" + +from __future__ import annotations + +import pytest + +from devbase.utils.names import SINGLE_SEGMENT_NAME_PATTERN, is_single_segment_name + + +@pytest.mark.parametrize("name", ["carmo", "github_work_time", "carmo-ai", "php85", + "carmo.takemi", "9lives", "a"]) +def test_accepts_real_project_names(name): + """受け入れ条件 5: 実在するプロジェクト名の形は通る。""" + assert is_single_segment_name(name) is True + + +@pytest.mark.parametrize("name", ["../etc", "a/b", ".", "..", "", "-x", "..\\etc", + "a b", "carmo\n", ".hidden", "_private"]) +def test_rejects_paths_flags_and_empty(name): + """`..` `/` `\\` 空 `-` 始まり 末尾の改行 `.` 始まり `_` 始まりは名前の形ではない。""" + assert is_single_segment_name(name) is False + + +def test_rejects_non_ascii(): + """決定 4: Python の `[A-Za-z0-9]` は ASCII だけに一致する。""" + assert is_single_segment_name("café") is False + + +def test_pattern_is_the_image_name_allowlist(): + """決定 2: `_build_single_image` のイメージ名と同じ許可リスト。""" + assert SINGLE_SEGMENT_NAME_PATTERN == r"[A-Za-z0-9][A-Za-z0-9._-]*" From 3372bcfbf58850a47ed58ea29634eb1e35dcba11 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 09:20:24 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(PLAN61):=20bin/devbase=20=E3=81=AE=20n?= =?UTF-8?q?ame=20=E8=A7=A3=E6=B1=BA=E3=81=A7=E5=90=8D=E5=89=8D=E3=81=AE?= =?UTF-8?q?=E5=BD=A2=E3=82=92=E6=A4=9C=E8=A8=BC=E3=81=97=E3=80=81=E5=AE=9F?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=82=BB=E3=82=B9=E3=81=AE=E3=83=8F=E3=83=BC?= =?UTF-8?q?=E3=83=8D=E3=82=B9=E3=81=A7=E5=9B=BA=E5=AE=9A=E3=81=99=E3=82=8B?= =?UTF-8?q?=20(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - is_single_segment_name と _SINGLE_SEGMENT_NAME_RE を足し、maybe_cd_project の入口で 形に合わない値 (フラグ・空・`..` や `/` を含む値) を名前として扱わない - tests/cli/conftest.py に exec_wrapper を新設。bin/devbase を tmp へ複製して起動し、 uv だけを PATH で差し替える (maybe_cd_project と cmd_build は本物のまま動く) - wrapper の正規表現と Python の定義の同期テスト、LC_ALL=C の確認 (café) を足す Co-Authored-By: Claude Opus 5 (1M context) --- bin/devbase | 22 ++++- tests/cli/conftest.py | 100 ++++++++++++++++++++++ tests/cli/test_project_name_resolution.py | 75 ++++++++++++++++ 3 files changed, 196 insertions(+), 1 deletion(-) create mode 100644 tests/cli/conftest.py diff --git a/bin/devbase b/bin/devbase index 55339803..1fa8c43c 100755 --- a/bin/devbase +++ b/bin/devbase @@ -316,10 +316,30 @@ resolve_command() { # 引数はそのまま下流 (Python パーサ) へ渡し、Python 側で index/image/scale # あるいは「存在しない name」エラーとして扱わせる。 +# プロジェクト名 / イメージ名として受け付ける形 (PLAN61 決定 1)。英数字で始まり、英数字・ +# `.`・`-`・`_` だけからなる。先頭が英数字なので `.` `..` `-x` 空は当たらず、`/` `\` 空白 +# 非 ASCII は含められない。$DEVBASE_ROOT/projects/ や containers/ へ連結する前にこれで弾き、 +# `..` で $DEVBASE_ROOT の外へ出ないようにする。 +# +# 同期注意: lib/devbase/utils/names.py の SINGLE_SEGMENT_NAME_PATTERN と同じ正規表現。 +# Python を呼んで判定すると name 解決のたびに uv の起動が増えるため、shell 側にも文字列で +# 持つ。片方を変えたらもう片方も変える (tests/cli/test_project_name_resolution.py の +# 同期テストが一致を見る)。 +_SINGLE_SEGMENT_NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$' + +# $1 が名前の形なら 0。bash 3.2 の `[[ =~ ]]` は引用した右辺を文字列として比べるため、 +# 正規表現は変数で渡す。`[A-Za-z]` の範囲はロケールで変わりうるので、Python の定義 +# (ASCII だけ) と同じ結果になるよう LC_ALL=C で比べる (決定 4)。 +is_single_segment_name() { + local LC_ALL=C + [[ ${1:-} =~ $_SINGLE_SEGMENT_NAME_RE ]] +} + # name 候補を受け取り projects/ 配下に実在すれば cd + env 再設定して 0 を返す。 +# 名前の形に合わない値 (フラグ・空・`..` や `/` を含む値) は名前ではない。 maybe_cd_project() { local name="${1:-}" - case "$name" in -*|"") return 1 ;; esac # フラグ・空は name ではない + is_single_segment_name "$name" || return 1 local target="${DEVBASE_ROOT}/projects/${name}" [ -d "$target" ] || return 1 cd "$target" || return 1 diff --git a/tests/cli/conftest.py b/tests/cli/conftest.py new file mode 100644 index 00000000..4709a444 --- /dev/null +++ b/tests/cli/conftest.py @@ -0,0 +1,100 @@ +"""`bin/devbase` を実プロセスで起動するハーネス (PLAN61 決定 11)。 + +既存の `sed` ハーネス (`test_build_image_argument.py` など) は wrapper の本文を削って `eval` +する。その形では `DEVBASE_ROOT=` の行を削って環境変数の値を使わせるため、pytest が継承した +実環境の `DEVBASE_ROOT` を渡し忘れると実環境の `projects/` を見る。また `run_python` と +`cmd_build` を関数ごと置き換えるため、`=== Building devbase images ===` が出ないことを +確かめられない。 + +ここでは `bin/devbase` を `/bin/devbase` へ複製する。wrapper は自身の場所から +`DEVBASE_ROOT` を `` に決め、継承した環境変数は wrapper の代入で上書きされる。外へ +出る呼び出しはすべて `uv` を通る (`run_python` と `compose_with_secrets`) ので、 +`/fakebin/uv` を `PATH` の先頭に置けば dispatch の先だけを差し替えられる。 +`maybe_cd_project` と `cmd_build` は本物のまま動く。複製にするのは、シンボリックリンクだと +wrapper がリンクを解いて実物の場所を `DEVBASE_ROOT` にするためである。 +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +WRAPPER = REPO_ROOT / "bin" / "devbase" + +_FAKE_UV = """\ +#!/bin/bash +echo "PWD:$PWD" +echo "UV:$*" +echo "MARKER:${MARKER:-}" +exit 0 +""" + + +class WrapperRoot: + """tmp の `DEVBASE_ROOT`。`projects/` `containers/` `etc/env` をテストごとに作る。""" + + def __init__(self, root: Path): + self.root = root + self.work = root / "work" + + def project(self, name: str, env: str | None = None) -> Path: + path = self.root / "projects" / name + path.mkdir(parents=True) + if env is not None: + (path / "env").write_text(env) + return path + + def container(self, name: str) -> Path: + path = self.root / "containers" / name + path.mkdir(parents=True) + (path / "Dockerfile").write_text("FROM ubuntu:26.04\n") + return path + + def etc_env(self, text: str = "MARKER=leaked\n") -> Path: + """`projects/../etc/env`。名前の形を弾けていないと wrapper がここを読む。""" + (self.root / "etc").mkdir(exist_ok=True) + path = self.root / "etc" / "env" + path.write_text(text) + return path + + def run(self, args, cwd: Path | None = None) -> subprocess.CompletedProcess: + env = {k: v for k, v in os.environ.items() if k != "MARKER"} + env["PATH"] = f"{self.root / 'fakebin'}{os.pathsep}{env.get('PATH', '')}" + return subprocess.run( + ["bash", str(self.root / "bin" / "devbase"), *args], + capture_output=True, + text=True, + env=env, + cwd=str(cwd or self.work), + ) + + __call__ = run + + +@pytest.fixture +def exec_wrapper(tmp_path) -> WrapperRoot: + """`bin/devbase` の複製と偽の `uv` を持つ tmp の `DEVBASE_ROOT`。""" + (tmp_path / "bin").mkdir() + shutil.copy(WRAPPER, tmp_path / "bin" / "devbase") + (tmp_path / "projects").mkdir() + (tmp_path / "containers").mkdir() + (tmp_path / "work").mkdir() + fakebin = tmp_path / "fakebin" + fakebin.mkdir() + uv = fakebin / "uv" + uv.write_text(_FAKE_UV) + uv.chmod(0o755) + return WrapperRoot(tmp_path) + + +def stdout_field(result: subprocess.CompletedProcess, prefix: str) -> str | None: + """標準出力から `prefix` で始まる最初の行の残りを返す。無ければ None。""" + for line in result.stdout.splitlines(): + if line.startswith(prefix): + return line[len(prefix):] + return None diff --git a/tests/cli/test_project_name_resolution.py b/tests/cli/test_project_name_resolution.py index 3de26789..e2835f48 100644 --- a/tests/cli/test_project_name_resolution.py +++ b/tests/cli/test_project_name_resolution.py @@ -588,5 +588,80 @@ def test_wrapper_project_login_keeps_index_positional(wrapper_root): assert _python_args(r) == "project login carmo", r.stdout +# =========================================================================== +# wrapper (実プロセス): 名前の形 (PLAN61 / #146) +# +# `exec_wrapper` (conftest.py) は bin/devbase を tmp へ複製して起動し、`uv` だけを PATH で +# 差し替える。maybe_cd_project は本物のまま動く (受け入れ条件 15)。 +# =========================================================================== + +from tests.cli.conftest import stdout_field # noqa: E402 + +TOP_LEVEL_NAME_COMMANDS = ["up", "down", "ps", "scale", "login", "rebuild", "open"] +PROJECT_NAME_SUBCOMMANDS = ["up", "down", "ps", "logs", "scale", "rebuild", "open"] +MALFORMED_NAMES = ["../etc", "a/b", ".", ".."] + + +def _name_commands(): + for cmd in TOP_LEVEL_NAME_COMMANDS: + yield [cmd] + for sub in PROJECT_NAME_SUBCOMMANDS: + yield ["project", sub] + + +@pytest.mark.parametrize("name", MALFORMED_NAMES) +@pytest.mark.parametrize("command", list(_name_commands()), ids=" ".join) +def test_wrapper_malformed_name_stays_put_and_reads_no_outside_env(exec_wrapper, command, name): + """受け入れ条件 2: `..` や `/` を含む名前で projects/ の外へ cd せず、外の env を読まない。 + + `/etc/env` に `MARKER=leaked` を置く。`projects/../etc` へ cd してしまうと wrapper が + それを source し、偽の `uv` が `MARKER:leaked` を出す。名前は wrapper が取り除かず、 + そのまま Python へ渡る (前提 2)。 + """ + exec_wrapper.etc_env() + exec_wrapper.project("carmo") + + r = exec_wrapper([*command, name]) + + assert stdout_field(r, "PWD:") == str(exec_wrapper.work), r.stdout + assert stdout_field(r, "MARKER:") == "", r.stdout + uv = stdout_field(r, "UV:") + assert uv is not None and uv.endswith(f" {' '.join(command)} {name}"), r.stdout + + +@pytest.mark.parametrize("name", ["carmo", "github_work_time", "carmo-ai"]) +def test_wrapper_well_formed_existing_name_cds_and_strips(exec_wrapper, name): + """受け入れ条件 5: 形に合う実在の名前は今と同じく cd して取り除かれる。""" + exec_wrapper.project(name) + + r = exec_wrapper(["up", name]) + + assert stdout_field(r, "PWD:") == str(exec_wrapper.root / "projects" / name), r.stdout + uv = stdout_field(r, "UV:") + assert uv is not None and uv.endswith(" devbase.cli up"), r.stdout + + +def test_wrapper_non_ascii_name_is_not_resolved(exec_wrapper): + """決定 4: shell の比較は LC_ALL=C で行い、`café` は名前の形に当たらない。""" + exec_wrapper.project("café") + + r = exec_wrapper(["up", "café"]) + + assert stdout_field(r, "PWD:") == str(exec_wrapper.work), r.stdout + uv = stdout_field(r, "UV:") + assert uv is not None and uv.endswith(" devbase.cli up café"), r.stdout + + +def test_wrapper_name_regex_is_synced_with_python(): + """決定 2: bin/devbase の `_SINGLE_SEGMENT_NAME_RE` は Python の定義と同じ正規表現。""" + import re + + from devbase.utils.names import SINGLE_SEGMENT_NAME_PATTERN + + found = re.findall(r"^_SINGLE_SEGMENT_NAME_RE='([^']*)'$", WRAPPER.read_text(), re.M) + assert found, "bin/devbase から _SINGLE_SEGMENT_NAME_RE を抜き出せない" + assert found == ["^" + SINGLE_SEGMENT_NAME_PATTERN + "$"] + + if __name__ == "__main__": sys.exit(pytest.main([__file__, "-v"])) From c769ebf5277cc2e6cc9996d3652d8ec93ec01cd7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 09:22:36 +0900 Subject: [PATCH 3/9] =?UTF-8?q?fix(PLAN61):=20devbase=20build=20--help=20/?= =?UTF-8?q?=20-h=20=E3=81=8C=E3=83=93=E3=83=AB=E3=83=89=E3=82=92=E5=A7=8B?= =?UTF-8?q?=E3=82=81=E3=81=9A=E4=BD=BF=E3=81=84=E6=96=B9=E3=82=92=E5=87=BA?= =?UTF-8?q?=E3=81=97=E3=81=A6=E7=B5=82=E3=82=8F=E3=82=8B=20(#196)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build_usage を足し、build の引数のどこかに -h / --help があれば name 解決より前に 使い方を出して終了コード 0 で終わる (build carmo --help でも cd と env の読み込みを起こさない) - --context --help は使い方、--context=--help は下流へ渡す (決定 8) - exec_wrapper で cmd_build を本物のまま動かし、=== Building devbase images === が 出ないことを確かめる Co-Authored-By: Claude Opus 5 (1M context) --- bin/devbase | 35 ++++++++++++++ tests/cli/test_build_image_argument.py | 65 ++++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/bin/devbase b/bin/devbase index 1fa8c43c..6f1fb38f 100755 --- a/bin/devbase +++ b/bin/devbase @@ -370,6 +370,29 @@ maybe_cd_project() { return 0 } +# トップレベル `build` の使い方 (PLAN61 決定 7・9)。トップレベル build は shell の cmd_build と +# Python の project build に振り分けられ、受け付ける引数が両者で違う (--project-no-cache は +# shell にだけある) ため、argparse の --help に委ねず wrapper が出す。 +build_usage() { + cat <<'EOF' +Usage: devbase build [ | ] [options] + +Build devbase images. + (no argument) build the images of the current project (base image first) + build the project in $DEVBASE_ROOT/projects/ + build $DEVBASE_ROOT/containers/ alone as devbase-:latest + (when both containers/ and projects/ exist, is an image; + to build the project, run 'devbase build' in its directory) + +Options: + --no-cache rebuild the base and project images without cache + --project-no-cache rebuild only the project image without cache (base uses cache) + --expires[=DAYS] rebuild without cache only if the image is older than DAYS days (default 7) + --context NAME run docker against the docker context NAME + -h, --help show this help +EOF +} + # Resolve the command (skip flags like --version, -V, -h, --help) _resolved_cmd="${1:-}" case "$_resolved_cmd" in @@ -377,6 +400,18 @@ case "$_resolved_cmd" in *) _resolved_cmd="$(resolve_command "$_resolved_cmd")" ;; esac +# `build` の -h / --help は name 解決より前に判定する (PLAN61 決定 7・8 / #196)。後に置くと +# `build carmo --help` で projects/carmo への cd とその env の読み込みが先に起きる。 +# `--context --help` の `--help` も使い方 (argparse も `-` 始まりを値に取らない)。 +# `--context=--help` は語が違うので使い方にならず、下流の argparse で usage エラーになる。 +if [ "$_resolved_cmd" = "build" ]; then + for _ba in "${@:2}"; do + case "$_ba" in + -h|--help) build_usage; exit 0 ;; + esac + done +fi + # name 解決: 実在するプロジェクト名を検出したら cd し、その token を argv から # 取り除いた配列 _DEVBASE_ARGS を組み立てる。検出しなければ素通し。 # name 候補の位置: diff --git a/tests/cli/test_build_image_argument.py b/tests/cli/test_build_image_argument.py index e0b00f51..6338f1c2 100644 --- a/tests/cli/test_build_image_argument.py +++ b/tests/cli/test_build_image_argument.py @@ -319,3 +319,68 @@ def test_single_build_accepts_real_container_directory_names(devbase_root, captu tags = [cmd[cmd.index("-t") + 1] for cmd in captured_run] assert tags == [f"devbase-{name}:latest" for name in names] + + +# =========================================================================== +# wrapper (実プロセス): `build --help` / `-h` (PLAN61 / #196) +# +# `exec_wrapper` (conftest.py) は bin/devbase を tmp へ複製して起動し、`uv` だけを PATH で +# 差し替える。cmd_build は本物のまま動くので `=== Building devbase images ===` が出ないことを +# 確かめられる。run_python も docker も `uv` を通るため、`UV:` が無いことで両方を確かめる。 +# =========================================================================== + +from tests.cli.conftest import stdout_field # noqa: E402 + +BUILD_USAGE_TOKENS = ["--no-cache", "--project-no-cache", "--expires[=DAYS]", "--context NAME", + ""] + + +def _assert_build_usage(result): + assert result.returncode == 0, result.stderr + assert "=== Building devbase images ===" not in result.stdout + assert stdout_field(result, "UV:") is None, result.stdout + assert "Usage: devbase build" in result.stdout + for token in BUILD_USAGE_TOKENS: + assert token in result.stdout, f"{token!r} が使い方に無い:\n{result.stdout}" + + +@pytest.mark.parametrize("flag", ["--help", "-h"]) +def test_wrapper_build_help_prints_usage_without_building(exec_wrapper, flag): + """受け入れ条件 9・10: `build --help` / `-h` は終了コード 0 で使い方を出し、ビルドしない。""" + _assert_build_usage(exec_wrapper(["build", flag])) + + +@pytest.mark.parametrize("flag", ["--help", "-h"]) +def test_wrapper_build_name_help_does_not_cd_or_read_env(exec_wrapper, flag): + """受け入れ条件 11: `build carmo --help` も cd せず、carmo の env を読まずに使い方を出す。 + + `projects/carmo/env` に `echo CARMO_ENV_READ >&2` を置く。wrapper は env を source するため、 + この行は cd と読み込みが起きたときだけ stderr に出る。 + """ + exec_wrapper.project("carmo", env="echo CARMO_ENV_READ >&2\n") + + r = exec_wrapper(["build", "carmo", flag]) + + _assert_build_usage(r) + assert stdout_field(r, "PWD:") is None, r.stdout + assert "CARMO_ENV_READ" not in r.stderr, r.stderr + + +@pytest.mark.parametrize("flag", ["--help", "-h"]) +def test_wrapper_build_context_followed_by_help_is_usage(exec_wrapper, flag): + """決定 8: `--context --help` の `--help` は context の値ではなく使い方。""" + _assert_build_usage(exec_wrapper(["build", "--context", flag])) + + +@pytest.mark.parametrize("flag", ["--help", "-h"]) +def test_wrapper_build_context_equals_help_is_not_usage(exec_wrapper, flag): + """決定 8: `--context=--help` は使い方にせず、値としてそのまま下流へ渡す。 + + 実際の argparse では値不足の usage エラー (終了コード 2) になる。`-` 始まりの context 名は + 受け付けない。 + """ + r = exec_wrapper(["build", f"--context={flag}"]) + + assert "Usage: devbase build" not in r.stdout + uv = stdout_field(r, "UV:") + assert uv is not None and f"env exec --context {flag} --" in uv, r.stdout From 4f2f7d5252ffe2dec687873c3461202e85c8e7f1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 09:23:45 +0900 Subject: [PATCH 4/9] =?UTF-8?q?fix(PLAN61):=20devbase=20build=20=20?= =?UTF-8?q?=E3=81=A7=20containers/=20=E3=81=A8=20projects/=20?= =?UTF-8?q?=E3=81=8C=E4=B8=A1=E6=96=B9=E3=81=82=E3=82=8C=E3=81=B0=E3=82=A4?= =?UTF-8?q?=E3=83=A1=E3=83=BC=E3=82=B8=E3=82=92=E3=83=93=E3=83=AB=E3=83=89?= =?UTF-8?q?=E3=81=99=E3=82=8B=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - name 解決の case に build の分岐を足す。 が名前の形に合い containers/ が実在すれば name 解決を通さず Python の単体ビルドへ渡し、projects/ もあれば stderr に 1 行知らせる - projects/ にだけある名前は今と同じくプロジェクトへ cd して cmd_build へ進む - build 分岐の PLAN49 の注記を新しい振り分けに合わせる Co-Authored-By: Claude Opus 5 (1M context) EOF ) --- bin/devbase | 23 ++++++++-- tests/cli/test_build_image_argument.py | 59 ++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/bin/devbase b/bin/devbase index 6f1fb38f..1c6440af 100755 --- a/bin/devbase +++ b/bin/devbase @@ -471,6 +471,21 @@ case "$_resolved_cmd" in _DEVBASE_ARGS=("${2:-}" "${@:4}") fi ;; + build) + # `build ` で containers/ が実在すれば はイメージで、name 解決を通さない + # (PLAN61 決定 5・6 / #142)。projects/ もあれば、プロジェクトとしても読めたことと + # プロジェクトをビルドする方法を stderr に 1 行知らせる。判定は maybe_cd_project の + # 前に置く (後だと cd と env の読み込みが先に起き、戻す手段が無い)。名前の形を先に + # 見るのは、`containers/../x` のような値でディレクトリの実在を確かめないため。 + if is_single_segment_name "${2:-}" && [ -d "${DEVBASE_ROOT}/containers/$2" ]; then + if [ -d "${DEVBASE_ROOT}/projects/$2" ]; then + echo "Note: '$2' is also a project (projects/$2); building image containers/$2." \ + "To build the project, run 'devbase build' in ${DEVBASE_ROOT}/projects/$2" >&2 + fi + elif maybe_cd_project "${2:-}"; then + _DEVBASE_ARGS=("${@:3}") + fi + ;; *) if [[ "$_NAME_RESOLVABLE_SHORTCUTS" == *" $_resolved_cmd "* ]] \ && maybe_cd_project "${2:-}"; then @@ -491,10 +506,10 @@ case "$_resolved_cmd" in # (devbase-base の 2 段ビルド) で処理する。次の 2 つは Python (project build) # へ委譲する (PLAN49 / i07): # - 指定の単体ビルド: `devbase project build ` / - # `devbase container build ` と同じ実装へ届ける。逆向き (Python から - # shell を呼ぶ) にすると、この wrapper 冒頭の name 解決を通ってしまい、 - # containers/ と projects/ に同名がある場合 (bi-tools) に別のものを - # ビルドしてしまう。 + # `devbase container build ` と同じ実装へ届ける。containers/ と + # projects/ に同名がある場合 (bi-tools) は、上の name 解決の `build)` 分岐が + # イメージとして扱い cd しない (PLAN61 決定 5)。逆向き (Python から shell を + # 呼ぶ) にすると、この wrapper を再び通ることになるため採らない。 # - --expires: イメージ作成日の判定が必要で、shell では RFC3339 日付パースが # 非可搬なため (build --expires=N / rebuild / up が共通の期限リゾルバを使う)。 build) diff --git a/tests/cli/test_build_image_argument.py b/tests/cli/test_build_image_argument.py index 6338f1c2..ff7f3fc7 100644 --- a/tests/cli/test_build_image_argument.py +++ b/tests/cli/test_build_image_argument.py @@ -384,3 +384,62 @@ def test_wrapper_build_context_equals_help_is_not_usage(exec_wrapper, flag): assert "Usage: devbase build" not in r.stdout uv = stdout_field(r, "UV:") assert uv is not None and f"env exec --context {flag} --" in uv, r.stdout + + +# =========================================================================== +# wrapper (実プロセス): `build ` の名前の形とイメージ / プロジェクトの衝突 (PLAN61 / #146 #142) +# =========================================================================== + +def test_wrapper_build_traversal_does_not_cd_or_read_outside_env(exec_wrapper): + """受け入れ条件 1: `build ../etc` は `$DEVBASE_ROOT/etc` へ cd せず、そこの env を読まない。 + + 形に合わない値は名前として扱わず、そのまま Python の単体ビルドへ渡す。Python 側の + `Invalid image name` で終了コード 1 になる (`test_cli_project_build_rejects_traversal_image`)。 + """ + exec_wrapper.etc_env() + + r = exec_wrapper(["build", "../etc"]) + + assert "=== Building devbase images ===" not in r.stdout + assert stdout_field(r, "PWD:") == str(exec_wrapper.work), r.stdout + assert stdout_field(r, "MARKER:") == "", r.stdout + uv = stdout_field(r, "UV:") + assert uv is not None and uv.endswith(" devbase.cli project build ../etc"), r.stdout + + +def test_wrapper_build_image_wins_over_same_named_project_and_notes(exec_wrapper): + """受け入れ条件 6: containers/ と projects/ の両方にある名前はイメージ。知らせを stderr に 1 行。""" + exec_wrapper.container("bi-tools") + exec_wrapper.project("bi-tools") + + r = exec_wrapper(["build", "bi-tools", "--no-cache"]) + + uv = stdout_field(r, "UV:") + assert uv is not None and uv.endswith(" devbase.cli project build bi-tools --no-cache"), r.stdout + assert stdout_field(r, "PWD:") == str(exec_wrapper.work), r.stdout + notes = [line for line in r.stderr.splitlines() if line.strip()] + assert len(notes) == 1, r.stderr + assert "projects/bi-tools" in notes[0] and "devbase build" in notes[0], r.stderr + assert str(exec_wrapper.root / "projects" / "bi-tools") in notes[0], r.stderr + + +def test_wrapper_build_project_only_name_cds_and_builds_project(exec_wrapper): + """受け入れ条件 7: projects/ にだけある名前は今と同じくプロジェクトのビルド (cmd_build)。""" + exec_wrapper.project("carmo") + + r = exec_wrapper(["build", "carmo"]) + + assert "=== Building devbase images ===" in r.stdout, r.stdout + assert stdout_field(r, "PWD:") == str(exec_wrapper.root / "projects" / "carmo"), r.stdout + assert r.stderr.strip() == "", r.stderr + + +def test_wrapper_build_container_only_name_has_no_note(exec_wrapper): + """受け入れ条件 8: containers/ にだけある名前は今と同じく単体ビルドで、知らせは出ない。""" + exec_wrapper.container("go") + + r = exec_wrapper(["build", "go"]) + + uv = stdout_field(r, "UV:") + assert uv is not None and uv.endswith(" devbase.cli project build go"), r.stdout + assert r.stderr.strip() == "", r.stderr From e75255e35fd13def907d083633c80d2d558ac1ef Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 09:26:22 +0900 Subject: [PATCH 5/9] =?UTF-8?q?fix(PLAN61):=20container=20/=20ct=20?= =?UTF-8?q?=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97=E3=82=92=20bin/devbase=20?= =?UTF-8?q?=E3=81=AE=20name=20=E8=A7=A3=E6=B1=BA=E3=81=8B=E3=82=89?= =?UTF-8?q?=E5=A4=96=E3=81=99=20(#200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - name 解決の case を project だけにし、container / ct は 実在する名前でも cd せずそのまま Python へ渡す (parser が [name] を持たないため usage エラー) - name 解決の説明コメント (衝突注意の footgun) を変更後の規則に書き替え、cli.py の 同期注意を project だけに直す - test_wrapper_ct_up_name_cds_and_strips を受け入れ条件 12 のテストへ置き換える Co-Authored-By: Claude Opus 5 (1M context) --- bin/devbase | 79 +++++++++++------------ lib/devbase/cli.py | 6 +- tests/cli/test_project_dispatch.py | 14 ++++ tests/cli/test_project_name_resolution.py | 48 +++++++++----- 4 files changed, 88 insertions(+), 59 deletions(-) diff --git a/bin/devbase b/bin/devbase index 1c6440af..4cc3e348 100755 --- a/bin/devbase +++ b/bin/devbase @@ -299,7 +299,7 @@ resolve_command() { } # =================================================================== -# Project name resolution (PLAN06 Task 2) +# Project name resolution (PLAN06 Task 2 / PLAN61) # =================================================================== # `devbase project ` および同義のトップレベルシノニム # `devbase ` の が $DEVBASE_ROOT/projects/ に実在する @@ -310,11 +310,20 @@ resolve_command() { # だけが build の name 解決手段になる (PLAN06 方針 A の核心)。Python 側 chdir # フォールバックでは build を救えない。 # -# 判定は projects/ 配下の実在性で行う。これにより `login ` / -# `build ` / `scale ` の既存 positional と曖昧にならない: 実在する -# プロジェクト名のときだけ name として解釈し cd + strip する。実在しなければ -# 引数はそのまま下流 (Python パーサ) へ渡し、Python 側で index/image/scale -# あるいは「存在しない name」エラーとして扱わせる。 +# の判定 (PLAN61): +# 1. 名前の形 (is_single_segment_name) に合わない値は名前ではない。cd も strip も +# せず、そのまま下流の Python へ渡す。下流は位置引数の意味に応じて扱う +# (`[name]` を取るコマンドは名前の検証で 1、`build ` はイメージ名の検証で 1、 +# `scale <値>` は argparse の型エラーで 2、`login <値>` は index として渡る)。 +# 2. 形に合う値は projects/ 配下の実在性で判定する。実在するときだけ name として +# 解釈し cd + strip する。実在しなければそのまま下流へ渡す。 +# 3. `build ` は containers/ が実在すればイメージとして扱い、name 解決を通さない +# (下の case の `build)` 分岐)。 +# 4. `container` / `ct` グループは name 解決の対象外。parser が `[name]` を持たず、 +# 実在するプロジェクト名でも usage エラー (終了コード 2) になる。名前の指定は +# `project ` が持つ。 +# 残る衝突: `login ` / `scale ` の値が数字だけのプロジェクト名と一致する +# 場合は名前として解釈される (数字だけの名前は現在無く、衝突は偶発に限る)。 # プロジェクト名 / イメージ名として受け付ける形 (PLAN61 決定 1)。英数字で始まり、英数字・ # `.`・`-`・`_` だけからなる。先頭が英数字なので `.` `..` `-x` 空は当たらず、`/` `\` 空白 @@ -415,57 +424,41 @@ fi # name 解決: 実在するプロジェクト名を検出したら cd し、その token を argv から # 取り除いた配列 _DEVBASE_ARGS を組み立てる。検出しなければ素通し。 # name 候補の位置: -# project|container -> $3 (サブコマンドは保持) +# project -> $3 (サブコマンドは保持) # トップレベルシノニム -> $2 # -# 重要 (PLAN06 codex 指摘対応): `project`/`container` グループでは parser が -# `name` positional を持つサブコマンド (`up`/`down`/`ps`/`logs`/`scale`) に限定 -# して $3 を name 解決する。`project login` / `project build` は単一 positional が -# index / image (旧 container 互換) であり parser が name を受け付けない -# (cli.py の _add_login_subparser / _add_build_subparser 参照)。これらで $3 を -# name strip すると、`project build web` の image=web や `project login web` の -# index 引数が実在プロジェクト名と一致した瞬間に消えて別操作へ化けるため除外する。 +# 重要 (PLAN06 codex 指摘対応): `project` グループでは parser が `name` positional を +# 持つサブコマンド (`up`/`down`/`ps`/`logs`/`scale`/`rebuild`/`open`) に限定して $3 を +# name 解決する。`project login` / `project build` は単一 positional が index / image +# (旧 container 互換) であり parser が name を受け付けない (cli.py の +# _add_login_subparser / _add_build_subparser 参照)。これらで $3 を name strip すると、 +# `project build web` の image=web や `project login web` の index 引数が実在プロジェクト名と +# 一致した瞬間に消えて別操作へ化けるため除外する。 # -# トップレベルシノニム (`build`/`login` を含む) は従来どおり「実在 project なら -# cd」方針を維持する: トップレベル `build`/`login` は Python parser を経由せず -# shell cmd_build / wrapper cd だけが name 指定の手段であり、`build carmo` / +# `container` / `ct` は name 解決を通さない (PLAN61 決定 10 / #200)。parser が `[name]` を +# 持たないため、wrapper だけが名前を取り除く形では「実在するときだけ受け付ける」動きに +# なり、受け付けるかどうかが projects/ の中身で変わる。非推奨のグループへ受け付けを +# 増やさず、`project ` へ誘導する。 +# +# トップレベルシノニム (`login` を含む) は「実在 project なら cd」方針を維持する: +# トップレベル `login` は Python parser を経由せず wrapper cd だけが name 指定の手段であり、 # `login carmo` を「そのプロジェクトを操作」と解釈する設計 (存在性ベース判定)。 +# `build` は containers/ との衝突をイメージ優先で分ける (下の `build)` 分岐)。 _DEVBASE_ARGS=("${@:2}") # 同期注意 (メンテナンス性): 下記 2 リストは cli.py の parser 定義に対応する。 -# _PROJECT_NAME_SUBCOMMANDS = `project`/`container` で `name` positional を -# 受け付けるサブコマンド集合。cli.py の _add_project_parser で -# `add_argument('name', ...)` を持つもの (up/down/ps/logs/scale/rebuild/open) と一致させる。 -# login/build は index/image 互換のため意図的に除外 (上のコメント参照)。 +# _PROJECT_NAME_SUBCOMMANDS = `project` で `name` positional を受け付けるサブコマンド +# 集合。cli.py の _add_project_parser で `add_argument('name', ...)` を持つもの +# (up/down/ps/logs/scale/rebuild/open) と一致させる。login/build は index/image +# 互換のため意図的に除外 (上のコメント参照)。 # _NAME_RESOLVABLE_SHORTCUTS = トップレベルシノニムのうち「実在 project なら cd」 # を許すもの。cli.py の SHORTCUTS 経由で project サブコマンドへ写像される # 集合 + shell 実装の build を含む。 # cli.py 側でサブコマンドを追加/削除した際は両リストの更新漏れに注意すること # (cli.py の _add_project_parser / SHORTCUTS にも対の注記あり)。 -# -# ⚠ 衝突注意 (footgun): トップレベルシノニムの name 解決は「存在性ベース」で -# 行うため、本来 positional 引数として渡したい値が実在プロジェクト名 -# ($DEVBASE_ROOT/projects/) と一致した場合、その引数が name と解釈され -# project 解決 (cd) が優先されて引数の意味が変わる。具体的には: -# - `devbase login ` の index が実在プロジェクト名と一致 -# (例: projects/2 が存在する状態で `devbase login 2`) → index=2 ではなく -# project `2` への cd になり、login の対象が変わる。 -# - `devbase build ` の image が実在プロジェクト名と一致 -# (例: projects/web が存在する状態で `devbase build web`) → image=web では -# なく project `web` への cd になり、ビルド対象が変わる。 -# - `devbase scale ` の service 引数も同様に化けうる。 -# これはトップレベル build/login/scale を「そのプロジェクトを操作」と解釈する -# 意図的設計 (存在性ベース判定) のトレードオフであり、挙動としては仕様である。 -# 回避策: 衝突時は対象プロジェクトのディレクトリ内で実行するか、明示的に -# そのプロジェクトへ切り替えてから (cd 済みの状態で) コマンドを実行すること。 -# こうすれば name 解決トークンを与える必要がなくなり、index/image/service を -# 意図どおり渡せる。 _PROJECT_NAME_SUBCOMMANDS=" up down ps logs scale rebuild open " _NAME_RESOLVABLE_SHORTCUTS=" up down ps scale login build rebuild open " case "$_resolved_cmd" in - project|container|ct) - # `ct` は container の alias (cli.py: add_parser('container', aliases=['ct']))。 - # name 解決経路でも container と同じ strip/chdir を通すため分岐に含める。 - # _resolved_cmd は `ct` のまま python に渡してよい (cli.py 側で alias 解決済み)。 + project) if [[ "$_PROJECT_NAME_SUBCOMMANDS" == *" ${2:-} "* ]] \ && maybe_cd_project "${3:-}"; then _DEVBASE_ARGS=("${2:-}" "${@:4}") diff --git a/lib/devbase/cli.py b/lib/devbase/cli.py index 0670ade8..25d06fa8 100644 --- a/lib/devbase/cli.py +++ b/lib/devbase/cli.py @@ -33,6 +33,8 @@ # `name` positional 付きサブコマンドは bin/devbase の _NAME_RESOLVABLE_SHORTCUTS / # _PROJECT_NAME_SUBCOMMANDS と対応している。サブコマンドを追加/削除する際は # wrapper 側 (bin/devbase の該当リスト) の更新漏れに注意すること。 +# _PROJECT_NAME_SUBCOMMANDS の対象は `project` グループだけで、`container` / `ct` は +# wrapper の name 解決を通らない (PLAN61 決定 10)。 SHORTCUTS = { 'up': 'up', 'down': 'down', @@ -284,7 +286,9 @@ def _add_project_parser(subparsers): 同期注意: ここで `name` positional を持つサブコマンド集合 (up/down/ps/logs/scale/rebuild/open) は bin/devbase の `_PROJECT_NAME_SUBCOMMANDS` と一致させる必要がある。追加/削除時は - wrapper 側リストの更新漏れに注意すること。 + wrapper 側リストの更新漏れに注意すること。wrapper がこの集合で name を解決するのは + `project` グループだけで、`container` / `ct` は `[name]` を持たず解決も通らない + (PLAN61 決定 10)。 """ pj_parser = subparsers.add_parser('project', help='Manage projects (CWD-independent)') pj_sub = pj_parser.add_subparsers(dest='subcommand') diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py index eed97584..bf85e31e 100644 --- a/tests/cli/test_project_dispatch.py +++ b/tests/cli/test_project_dispatch.py @@ -240,6 +240,20 @@ def test_dispatch_shortcut_routes_to_cmd_project_not_container(monkeypatch): # (重複定義を _add_login_subparser / _add_build_subparser に共通化した結果の検証) # --------------------------------------------------------------------------- +@pytest.mark.parametrize('group', ['container', 'ct']) +@pytest.mark.parametrize('sub', ['up', 'down', 'ps', 'logs', 'scale', 'rebuild', 'open']) +def test_container_subcommands_reject_name_positional(group, sub): + """受け入れ条件 12 (単体): `container ` は parser が `[name]` を持たず SystemExit(2)。 + + `scale` は `carmo` が `new_scale` の int 型エラーになり、他は `unrecognized arguments`。 + どちらも終了コード 2 (PLAN61 決定 10 / #200)。 + """ + parser = cli._create_parser() + with pytest.raises(SystemExit) as exc: + parser.parse_args([group, sub, 'carmo']) + assert exc.value.code == 2 + + @pytest.mark.parametrize('group', ['project', 'container']) def test_login_positional_is_index_in_both_groups(group): """login は project / container いずれでも単一 positional を index として扱う。""" diff --git a/tests/cli/test_project_name_resolution.py b/tests/cli/test_project_name_resolution.py index e2835f48..265e8da1 100644 --- a/tests/cli/test_project_name_resolution.py +++ b/tests/cli/test_project_name_resolution.py @@ -562,21 +562,6 @@ def test_wrapper_project_build_keeps_image_positional(wrapper_root): assert _python_args(r) == "project build carmo", r.stdout -def test_wrapper_ct_up_name_cds_and_strips(wrapper_root): - """`ct up carmo` は container alias として name 解決される (codex 指摘 #319)。 - - `ct` は cli.py で container の alias (add_parser('container', aliases=['ct'])) - のため、wrapper の name 解決 case でも `container` と同じ strip/chdir 経路を - 通す。`ct` 自体は strip せず Python へ渡し、name のみ strip する。 - """ - r = _run_wrapper(["ct", "up", "carmo"], wrapper_root) - assert "unknown command" not in r.stderr.lower(), r.stderr - assert "unrecognized arguments" not in r.stderr.lower(), r.stderr - assert _pwd(r).endswith("/projects/carmo"), r.stdout - # name は strip されるが alias `ct` は保持して Python へ渡す - assert _python_args(r) == "ct up", r.stdout - - def test_wrapper_project_login_keeps_index_positional(wrapper_root): """`project login carmo` の carmo は index positional として素通しする。 @@ -652,6 +637,39 @@ def test_wrapper_non_ascii_name_is_not_resolved(exec_wrapper): assert uv is not None and uv.endswith(" devbase.cli up café"), r.stdout +CONTAINER_SUBCOMMANDS = ["up", "down", "ps", "logs", "scale", "rebuild", "open"] + + +@pytest.mark.parametrize("group", ["container", "ct"]) +@pytest.mark.parametrize("sub", CONTAINER_SUBCOMMANDS) +def test_wrapper_container_group_does_not_resolve_names(exec_wrapper, group, sub): + """受け入れ条件 12: `container ` / `ct ` は実在する名前でも cd しない。 + + `container` の parser は `[name]` を持たない (決定 10 / #200)。wrapper が名前を取り除かず + そのまま渡し、argparse の usage エラー (終了コード 2) になる。旧テスト + `test_wrapper_ct_up_name_cds_and_strips` の置き換え。 + """ + exec_wrapper.project("carmo") + + r = exec_wrapper([group, sub, "carmo"]) + + assert stdout_field(r, "PWD:") == str(exec_wrapper.work), r.stdout + uv = stdout_field(r, "UV:") + assert uv is not None and uv.endswith(f" devbase.cli {group} {sub} carmo"), r.stdout + + +def test_wrapper_container_up_without_name_uses_cwd(exec_wrapper): + """受け入れ条件 13: `container up` (名前なし) は今と同じく現在のディレクトリで動く。 + + 非推奨の警告は Python 側 (`test_cmd_container_warns_and_delegates`)。 + """ + r = exec_wrapper(["container", "up"]) + + assert stdout_field(r, "PWD:") == str(exec_wrapper.work), r.stdout + uv = stdout_field(r, "UV:") + assert uv is not None and uv.endswith(" devbase.cli container up"), r.stdout + + def test_wrapper_name_regex_is_synced_with_python(): """決定 2: bin/devbase の `_SINGLE_SEGMENT_NAME_RE` は Python の定義と同じ正規表現。""" import re From 98cc53dafa870bcda40cc7a3d7a9e31c3a71ac24 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 09:27:55 +0900 Subject: [PATCH 6/9] =?UTF-8?q?docs(PLAN61):=20=E5=90=8D=E5=89=8D=E3=81=AE?= =?UTF-8?q?=E5=BD=A2=E3=83=BBbuild=20=E3=81=AE=E8=A1=9D=E7=AA=81=E3=83=BBb?= =?UTF-8?q?uild=20--help=E3=83=BBcontainer=20=E3=81=AE=E5=90=8D=E5=89=8D?= =?UTF-8?q?=E8=A7=A3=E6=B1=BA=E3=82=92=20CLI=20=E3=83=AA=E3=83=95=E3=82=A1?= =?UTF-8?q?=E3=83=AC=E3=83=B3=E3=82=B9=E3=81=A8=20CHANGELOG=20=E3=81=AB?= =?UTF-8?q?=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 16 +++++++++++ docs/developer/architecture.md | 3 +- docs/specifications/editor-open.md | 7 ++--- docs/user/cli-reference/02-project.md | 41 ++++++++++++++++----------- docs/user/cli-reference/README.md | 9 ++++-- 5 files changed, 52 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe17319a..9bd28319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,8 +15,24 @@ ### Changed - `devbase list` の起動中の操作メニューで、Enter 1 回で決まる項目が「再起動 (up)」から 「エディタを開く (open)」に変わりました。再起動はその 1 つ下です。 +- **位置引数のプロジェクト名は、英数字で始まり英数字・`.`・`-`・`_` だけからなる形に限りました + (PLAN61 / #146)。** `../etc` や `a/b` のように形に合わない値は名前として扱わず、 + `$DEVBASE_ROOT/projects/` の外のディレクトリへ移動したり、そこの `env` を読んだりしません。 + `bin/devbase` の名前解決と、Python 側の `project up ` などの名前の検証、 + `build ` のイメージ名の検証が同じ規則を使います。 +- **`devbase build ` で `containers/` と `projects/` が両方あるときは、イメージ + `` をビルドします(PLAN61 / #142)。** これまではプロジェクトへの移動が優先され、 + イメージ指定が消えていました。プロジェクトとしても読めたことと、プロジェクトをビルドする方法 + (そのディレクトリで `devbase build`)を stderr に 1 行知らせます。 +- **非推奨の `container` / `ct` グループは名前解決の対象外になりました(PLAN61 / #200)。** + `devbase container up ` は実在するプロジェクト名でも usage エラー(終了コード 2)です。 + 名前の指定は `devbase project up ` か `devbase up ` を使ってください。 ### Fixed +- **`devbase build --help` / `-h` がビルドを始めてしまう**のを直しました(PLAN61 / #196)。 + `build` の使い方(`--no-cache` / `--project-no-cache` / `--expires[=DAYS]` / `--context NAME` / + `` 指定)を出して終了コード 0 で終わります。`devbase build carmo --help` のように + プロジェクト名の後ろに置いても、そのプロジェクトへ移動せずに使い方を出します。 - **tmux の中で URL がクリックできなくなっていた**のを直しました。tmux は端末が `Hls` 能力を持つときだけハイパーリンク (OSC 8) を書き出し、持たない端末ではリンクを捨てて 文字列だけを描きます。tmux が `xterm*` へ既定で与える機能に `hyperlinks` は含まれない diff --git a/docs/developer/architecture.md b/docs/developer/architecture.md index 5364833a..cb8b007e 100644 --- a/docs/developer/architecture.md +++ b/docs/developer/architecture.md @@ -46,8 +46,9 @@ flowchart TB | 引数 | 実行する層 | 実体 | 理由 | |------|-----------|------|------| +| `-h` / `--help`(引数のどこにあっても) | Bash | `build_usage()` | shell と Python で受け付ける引数が違う(`--project-no-cache` は shell だけ)ため wrapper が使い方を出す。name 解決より前に判定し、`build carmo --help` で `projects/carmo` への cd と `env` の読み込みを起こさない。`--context=--help` は語が違うので下流へ渡る(PLAN61 決定 7・8) | | なし / `--no-cache` / `--project-no-cache` | Bash | `cmd_build()` | compose.yml のパースと `FROM devbase-*` の依存検出、2 段ビルドの制御がシェルで完結する | -| `` | Python | `container._build_single_image()` | `devbase project build ` / `devbase container build ` と同じ実装へ届ける。逆向きに Python から `bin/devbase build ` を呼ぶと、wrapper 冒頭の name 解決を通ってしまい、`containers/` と `projects/` に同名がある場合に別のものをビルドする | +| `` | Python | `container._build_single_image()` | `devbase project build ` / `devbase container build ` と同じ実装へ届ける。`containers/` と `projects/` が両方ある名前は wrapper の name 解決の `build)` 分岐がイメージとして扱い cd しない(stderr に 1 行知らせる。PLAN61 決定 5・6)。逆向きに Python から `bin/devbase build ` を呼ぶと wrapper を再び通るため採らない | | `--expires[=DAYS]` | Python | `container.cmd_build()` → `_build_resolved()` | イメージ作成日の判定に RFC3339 の日付パースが要り、シェルでは非可搬 | 単体ビルド(`` 指定)は `$DEVBASE_ROOT/containers/` を diff --git a/docs/specifications/editor-open.md b/docs/specifications/editor-open.md index bd504ed6..0823fd8a 100644 --- a/docs/specifications/editor-open.md +++ b/docs/specifications/editor-open.md @@ -105,8 +105,9 @@ graph TD | `--open` / `--no-open`、およびその値付きの形(`--open 2` / `--open=2`) | 2 | argparse の usage エラー。サブパーサーの `allow_abbrev=False` により、`--open-index` の前方一致として受け付けない | | `name` が解決できない | 1 | 既存の `_enter_project` の候補提示 | -`container open` は `[name]` を取らない(`container` の他のサブコマンドと同じ)。前方一致では `devbase o` が -`open` に、`devbase project o` / `container o` も `open` に解決する。`devbase l` → `login`、`devbase project p` +`container open` は `[name]` を取らない(`container` の他のサブコマンドと同じ)。`bin/devbase` の name 解決も +`container` / `ct` を通さないため、`devbase container open ` は実在する名前でも usage エラー(終了コード 2)に +なる(PLAN61 決定 10)。前方一致では `devbase o` が `open` に、`devbase project o` / `container o` も `open` に解決する。`devbase l` → `login`、`devbase project p` → `ps` は変わらない。 ### `running_dev_instances` @@ -142,8 +143,6 @@ graph TD - 窓を閉じた後はコンテナを止めずに `devbase open` で開き直す - 非 TTY(CI・パイプ)では `opener` が開くのを見送り、終了コード 1 になる -- `container` / `ct` グループのサブコマンドが `bin/devbase` の名前解決を通って `[name]` を受け付けてしまう - 既存の動きは `open` にも及ぶ(#200 で扱う) ## テスト観点 diff --git a/docs/user/cli-reference/02-project.md b/docs/user/cli-reference/02-project.md index 726cf828..fae87514 100644 --- a/docs/user/cli-reference/02-project.md +++ b/docs/user/cli-reference/02-project.md @@ -19,6 +19,10 @@ cd $DEVBASE_ROOT/projects/adminer && devbase project up ``` - `` は `$DEVBASE_ROOT/projects/` 配下のプロジェクト名(`devbase project list` で確認可能) +- 名前として受け付ける形は、英数字で始まり英数字・`.`・`-`・`_` だけからなる文字列です + (`carmo`、`github_work_time`、`carmo-ai`、`carmo.takemi`)。`../etc` や `a/b` のように + 形に合わない値は名前として扱わず、`projects/` の外のディレクトリへ移動することはありません。 + `[name]` を取るコマンドに渡すと、プロジェクト名に使えない形である旨を出して終了コード 1 になります - 存在しない名前を指定するとエラーになり、利用可能なプロジェクト候補が表示されます - 名前解決はラッパー (`bin/devbase`) が対象ディレクトリへ `cd` してから実行します。 これにより `build`(シェル実装)を含む全操作が名前指定で成立します @@ -30,15 +34,13 @@ cd $DEVBASE_ROOT/projects/adminer && devbase project up > ため除外しています。一方、トップレベルシノニム `devbase build ` / `devbase login ` は > ラッパー (`bin/devbase`) の存在性判定(`$DEVBASE_ROOT/projects/` が実在すれば cd)で > 名前解決されます(実在しない場合は従来どおり `index` / `image` として下流へ渡されます)。 +> `devbase build ` で `containers/` も実在する場合はイメージが優先されます +> ([`devbase project build`](#devbase-project-build) を参照)。 -> **⚠ 衝突注意(footgun):** トップレベルシノニムの名前解決は「存在性ベース」のため、本来 -> positional 引数として渡したい値が実在プロジェクト名と一致すると、その引数が名前解決の対象と -> なり project への `cd` が優先されて引数の意味が変わります。例えば `projects/2` が存在する状態の -> `devbase login 2` は index=2 ではなく project `2` への操作に、`projects/web` が存在する状態の -> `devbase build web` は image=web ではなく project `web` のビルドに化けます(`scale` の service 引数 -> も同様)。これは「`build carmo` / `login carmo` でそのプロジェクトを操作する」意図的設計の -> トレードオフです。**回避策:** 衝突する場合は対象プロジェクトのディレクトリ内で実行するか、 -> 明示的にそのプロジェクトへ切り替えてから(`cd` 済みの状態で)コマンドを実行してください。 +> **衝突注意:** トップレベルの `devbase login ` / `devbase scale ` は、値が実在する +> プロジェクト名と一致すると名前として解釈されます(`projects/2` が存在する状態の `devbase login 2` +> は index=2 ではなく project `2` への操作になります)。数字だけのプロジェクト名は通常作られないため +> 衝突は偶発に限られますが、該当する場合は対象プロジェクトのディレクトリ内で実行してください。 ## `--context NAME`(共通オプション) @@ -268,9 +270,14 @@ devbase project migrate-config ``` devbase project build [image] -devbase build [image] [--no-cache | --expires[=DAYS]] +devbase build [ | ] [--no-cache | --project-no-cache | --expires[=DAYS]] [--context NAME] +devbase build --help ``` +`devbase build --help` / `-h` は、ビルドを起こさずにトップレベル `build` の使い方(上のオプションと +`` 指定)を出して終了コード 0 で終わります。`devbase build carmo --help` のようにプロジェクト名 +の後ろに置いても、そのプロジェクトへ移動せずに使い方を出します。 + | モード | 子イメージ | 親イメージ(`FROM devbase-*`) | |--------|-----------|-------------------------------| | `devbase build` | キャッシュがあれば使う | キャッシュがあれば使う | @@ -304,12 +311,12 @@ docker buildx build --load -t devbase-:latest $DEVBASE_ROOT/containers/` または その `Dockerfile` が無い場合は、探したパスを表示して終了コード 1 で終わります。 -> **`` が `$DEVBASE_ROOT/projects/` に実在する名前と一致する場合、トップレベルの -> `devbase build ` はそのプロジェクトへの操作として解釈されます。** これは -> `devbase build <プロジェクト名>` を「そのプロジェクトをビルドする」と読む設計によるもので、 -> イメージ指定は失われます。該当するときは `devbase project build ` を使ってください -> (こちらは常にイメージ名として扱います)。詳細は -> [#142](https://github.com/devbasex/devbase/issues/142) を参照してください。 +> **`` が `$DEVBASE_ROOT/containers/` と `$DEVBASE_ROOT/projects/` の両方に実在する場合、 +> トップレベルの `devbase build ` はイメージ `` の単体ビルドとして扱います。** `build ` +> はイメージを明示した指定で、プロジェクトのビルドは通常そのディレクトリで引数なしに行うためです。 +> このとき、プロジェクトとしても解釈できたことと、プロジェクトをビルドする方法(そのディレクトリで +> `devbase build`)を stderr に 1 行知らせます。`projects/` にだけある名前は今までどおりそのプロジェクトの +> ビルドです。詳細は [#142](https://github.com/devbasex/devbase/issues/142) を参照してください。 ## `devbase project rebuild` @@ -411,7 +418,9 @@ carmo.takemi carmo-fork stopped > **非推奨:** `container` グループは `project` グループへ移行しました。`devbase container > ` は当面 `devbase project ` のエイリアスとして動作しますが、実行時に非推奨警告を > 表示します(移行期間後のリリースで削除予定)。`[name]` 指定や `list` などの新機能は -> `project` 側のみで提供されます。 +> `project` 側のみで提供されます。`devbase container up `(`ct` も同じ)は実在する +> プロジェクト名でも受け付けず、argparse の usage エラー(終了コード 2)になります。名前の指定は +> `devbase project up ` か `devbase up ` を使ってください。 ```bash # 旧(非推奨・警告が出ます) diff --git a/docs/user/cli-reference/README.md b/docs/user/cli-reference/README.md index f0f9c536..5e88b450 100644 --- a/docs/user/cli-reference/README.md +++ b/docs/user/cli-reference/README.md @@ -65,11 +65,14 @@ graph TD > **Note:** `logs` はトップレベルシノニムを持ちません。`devbase project logs` を使用してください。 > -> **※ `build` の転送先について:** `devbase build`(既定 / `--no-cache` / ``)は他の +> **※ `build` の転送先について:** `devbase build`(既定 / `--no-cache` / `--project-no-cache`)は他の > ショートカットのように `project` グループ(Python 実装)へ転送されるのではなく、`bin/devbase` の > シェル実装 `cmd_build` に直接委譲されます。base イメージの段階ビルド等を CWD で行う必要があるため -> です(名前指定はラッパーの `cd` で解決)。ただし `devbase build --expires[=DAYS]` のみ、作成日の -> 判定が必要なため例外的に Python 経路(`project build`)へ委譲されます。挙動上の入出力は同等です。 +> です(プロジェクト名の指定 `devbase build ` はラッパーの `cd` で解決)。 +> `devbase build `(`$DEVBASE_ROOT/containers/` の単体ビルド)と +> `devbase build --expires[=DAYS]`(作成日の判定が必要)は Python 経路(`project build`)へ委譲されます。 +> `containers/` と `projects/` の両方にある名前はイメージとして扱います。`devbase build --help` は +> ビルドせずに使い方を出します。 ### ユニークプレフィックスマッチング From 80b7ee8e0e7119909e747754cfffb829413b418c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 09:33:08 +0900 Subject: [PATCH 7/9] =?UTF-8?q?docs(PLAN61):=20=E5=8F=97=E3=81=91=E5=85=A5?= =?UTF-8?q?=E3=82=8C=E6=9D=A1=E4=BB=B6=2016=20=E4=BB=B6=E3=81=AB=E6=A4=9C?= =?UTF-8?q?=E8=A8=BC=E6=89=8B=E6=AE=B5=E3=82=92=E6=B7=BB=E3=81=88=E3=80=81?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E8=A8=88=E7=94=BB=E3=81=AE=E3=82=BF=E3=82=B9?= =?UTF-8?q?=E3=82=AF=E3=82=92=E5=AE=8C=E4=BA=86=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- issues/PLAN61_name-resolution.md | 72 +++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 25 deletions(-) diff --git a/issues/PLAN61_name-resolution.md b/issues/PLAN61_name-resolution.md index a7c15556..5f5bd9c1 100644 --- a/issues/PLAN61_name-resolution.md +++ b/issues/PLAN61_name-resolution.md @@ -102,57 +102,79 @@ 名前の形(#146): -- [ ] 1. 前提: `$DEVBASE_ROOT/etc` が実在する +- [x] 1. 前提: `$DEVBASE_ROOT/etc` が実在する 操作: `devbase build ../etc` を実行する 結果: `$DEVBASE_ROOT/etc` へ cd せず、そこの `env` を読まない。ビルドは 1 つも始まらず、終了コードは 0 以外 -- [ ] 2. トップレベルの `up` `down` `ps` `scale` `login` `rebuild` `open` と、`project` の + 検証: `tests/cli/test_build_image_argument.py::test_wrapper_build_traversal_does_not_cd_or_read_outside_env`(wrapper)、 + `::test_cli_project_build_rejects_traversal_image`(Python が 1 で終わる)、`::test_single_build_rejects_invalid_image_name[../etc]` +- [x] 2. トップレベルの `up` `down` `ps` `scale` `login` `rebuild` `open` と、`project` の `up` `down` `ps` `logs` `scale` `rebuild` `open` に `..` や `/` を含む名前(`../etc`、`a/b`、`.`、`..`)を渡すと、 `projects/` の外のディレクトリへ cd せず、`projects/` の外の `env` を読まない -- [ ] 3. `python -m devbase.cli project up ../etc`(wrapper を経ない直接起動)は、chdir せず、 + 検証: `tests/cli/test_project_name_resolution.py::test_wrapper_malformed_name_stays_put_and_reads_no_outside_env`(14 コマンド × 4 名前)、 + `::test_resolve_rejects_malformed_name_without_chdir`(単体) +- [x] 3. `python -m devbase.cli project up ../etc`(wrapper を経ない直接起動)は、chdir せず、 プロジェクト名に使えない形である旨を出して終了コード 1 -- [ ] 4. 名前を指定したライフサイクル操作の dispatch 前の注入(`_named_lifecycle_project`)は、形に合わない名前で + 検証: `tests/cli/test_project_name_resolution.py::test_cli_project_up_rejects_malformed_name` +- [x] 4. 名前を指定したライフサイクル操作の dispatch 前の注入(`_named_lifecycle_project`)は、形に合わない名前で `projects/` の外の `env` を読まず、`None` を返す -- [ ] 5. 形に合う実在のプロジェクト名(`carmo`、`github_work_time`、`carmo-ai` の形)は、今と同じく cd して + 検証: `tests/cli/test_secret_injection.py::test_malformed_name_is_not_a_project_and_reads_nothing` +- [x] 5. 形に合う実在のプロジェクト名(`carmo`、`github_work_time`、`carmo-ai` の形)は、今と同じく cd して 取り除かれる + 検証: `tests/cli/test_project_name_resolution.py::test_wrapper_well_formed_existing_name_cds_and_strips`、 + `tests/utils/test_names.py::test_accepts_real_project_names` イメージとの衝突(#142): -- [ ] 6. 前提: `containers/bi-tools` と `projects/bi-tools` が実在する +- [x] 6. 前提: `containers/bi-tools` と `projects/bi-tools` が実在する 操作: 任意のディレクトリで `devbase build bi-tools --no-cache` を実行する 結果: `containers/bi-tools` の単体ビルド(Python の `project build bi-tools --no-cache`)へ届き、 `projects/bi-tools` へ cd しない。プロジェクトとしても解釈できたことと、プロジェクトをビルドする方法を stderr に 1 回出す -- [ ] 7. 前提: `projects/carmo` だけが実在し、`containers/carmo` は無い + 検証: `tests/cli/test_build_image_argument.py::test_wrapper_build_image_wins_over_same_named_project_and_notes` +- [x] 7. 前提: `projects/carmo` だけが実在し、`containers/carmo` は無い 操作: `devbase build carmo` を実行する 結果: 今と同じく `projects/carmo` へ cd してプロジェクトのビルド(`cmd_build`)へ進む -- [ ] 8. 前提: `containers/go` だけが実在し、`projects/go` は無い + 検証: `tests/cli/test_build_image_argument.py::test_wrapper_build_project_only_name_cds_and_builds_project` +- [x] 8. 前提: `containers/go` だけが実在し、`projects/go` は無い 操作: `devbase build go` を実行する 結果: 今と同じく `go` の単体ビルドへ届く。衝突の知らせは出ない + 検証: `tests/cli/test_build_image_argument.py::test_wrapper_build_container_only_name_has_no_note` ヘルプ(#196): -- [ ] 9. `devbase build --help` と `devbase build -h` は、終了コード 0 で `build` の使い方を出し、 +- [x] 9. `devbase build --help` と `devbase build -h` は、終了コード 0 で `build` の使い方を出し、 `=== Building devbase images ===` を出さない。`cmd_build`・`docker`・Python の `project build` の いずれも呼ばれない -- [ ] 10. 使い方には `--no-cache` / `--project-no-cache` / `--expires[=DAYS]` / `--context NAME` / `` の指定が載る + 検証: `tests/cli/test_build_image_argument.py::test_wrapper_build_help_prints_usage_without_building[--help|-h]` +- [x] 10. 使い方には `--no-cache` / `--project-no-cache` / `--expires[=DAYS]` / `--context NAME` / `` の指定が載る (`--project-no-cache` は 2026-09-18 のドキュメントレビューで追加) -- [ ] 11. `devbase build carmo --help`(実在するプロジェクト名の後ろの `--help`)も、ビルドせず使い方を出して + 検証: 9 と同じテストの `_assert_build_usage`(`BUILD_USAGE_TOKENS` の 5 語) +- [x] 11. `devbase build carmo --help`(実在するプロジェクト名の後ろの `--help`)も、ビルドせず使い方を出して 終了コード 0 + 検証: `tests/cli/test_build_image_argument.py::test_wrapper_build_name_help_does_not_cd_or_read_env[--help|-h]` `container` グループ(#200): -- [ ] 12. 前提: `projects/carmo` が実在する +- [x] 12. 前提: `projects/carmo` が実在する 操作: `devbase container up carmo`(`down` / `ps` / `logs` / `scale` / `rebuild` / `open`、`ct` でも同じ)を実行する 結果: `projects/carmo` へ cd せず、argparse の usage エラーで終了コード 2 -- [ ] 13. `devbase container up`(名前なし)は今と同じく現在のディレクトリのプロジェクトで動き、非推奨の警告を出す + 検証: `tests/cli/test_project_name_resolution.py::test_wrapper_container_group_does_not_resolve_names`(container / ct × 7 サブコマンド)、 + `tests/cli/test_project_dispatch.py::test_container_subcommands_reject_name_positional`(SystemExit(2)) +- [x] 13. `devbase container up`(名前なし)は今と同じく現在のディレクトリのプロジェクトで動き、非推奨の警告を出す + 検証: `tests/cli/test_project_name_resolution.py::test_wrapper_container_up_without_name_uses_cwd`、 + `tests/cli/test_project_dispatch.py::test_cmd_container_warns_and_delegates`(既存) 退行しないこと: -- [ ] 14. `devbase project up ` などの名前指定、`devbase project build `、`devbase build ` +- [x] 14. `devbase project up ` などの名前指定、`devbase project build `、`devbase build ` (衝突しない名前)、`devbase build --context NAME` の既存テストが変更なしで通る -- [ ] 15. name 解決を含む経路のテストが、`bin/devbase` を実プロセスで起動し、`maybe_cd_project` を + 検証: `tests/cli/test_project_name_resolution.py`(`test_wrapper_ct_up_name_cds_and_strips` を 12 のテストへ置き換えた以外は変更なし)、 + `tests/cli/test_build_image_argument.py`・`test_wrapper_build_context.py`・`test_open_command.py`・`test_project_dispatch.py` の既存テストは変更なしで通過 +- [x] 15. name 解決を含む経路のテストが、`bin/devbase` を実プロセスで起動し、`maybe_cd_project` を スタブせずに通る(dispatch の先だけを差し替える) -- [ ] 16. 全体テスト(`uv run pytest tests/`)が通る + 検証: `tests/cli/conftest.py` の `exec_wrapper`(`bin/devbase` を tmp へ複製し `uv` だけを PATH で差し替える)を使う 1・2・5〜13 のテスト +- [x] 16. 全体テスト(`uv run pytest tests/`)が通る + 検証: `uv run --locked pytest -q tests/` → `2813 passed`(exit 0、2026-09-19) ## 影響 @@ -200,7 +222,7 @@ - `docs/user/cli-reference/02-project.md`、`docs/user/cli-reference/README.md`、`docs/developer/architecture.md`、 `docs/specifications/editor-open.md`、`CHANGELOG.md` -### Task 1: 名前の形の規則(Python)と Python 側 3 入口の検証(F1) +### Task 1 [x]: 名前の形の規則(Python)と Python 側 3 入口の検証(F1) - **対象ファイル:** `lib/devbase/utils/names.py`(新設)、`lib/devbase/commands/container.py`、`lib/devbase/cli.py`、 `tests/utils/test_names.py`(新設)、`tests/cli/test_project_name_resolution.py`、`tests/cli/test_build_image_argument.py`、 @@ -211,7 +233,7 @@ - **満たす受け入れ条件:** 1(単体)、2(単体)、3、4、5(単体)、決定 4(単体) - **進め方:** テスト駆動 -### Task 2: wrapper のハーネス `exec_wrapper` と shell 側の名前の形(F1) +### Task 2 [x]: wrapper のハーネス `exec_wrapper` と shell 側の名前の形(F1) - **対象ファイル:** `tests/cli/conftest.py`(新設)、`bin/devbase`、`tests/cli/test_project_name_resolution.py` - **変更内容:** `bin/devbase` を tmp へ複製し `fakebin/uv` で dispatch の先だけを差し替える fixture。`bin/devbase` に @@ -220,7 +242,7 @@ - **満たす受け入れ条件:** 2(wrapper)、5(wrapper)、15、決定 2、決定 4(wrapper) - **進め方:** テスト駆動 -### Task 3: `build --help` / `-h`(F3) +### Task 3 [x]: `build --help` / `-h`(F3) - **対象ファイル:** `bin/devbase`、`tests/cli/test_build_image_argument.py` - **変更内容:** `build_usage` を新設し、name 解決の case より前で `build` の引数に `-h` / `--help` があれば使い方を出して 0 で終わる。 @@ -228,7 +250,7 @@ - **満たす受け入れ条件:** 9、10、11、決定 8 - **進め方:** テスト駆動 -### Task 4: `build ` のイメージとプロジェクトの衝突(F2) +### Task 4 [x]: `build ` のイメージとプロジェクトの衝突(F2) - **対象ファイル:** `bin/devbase`、`tests/cli/test_build_image_argument.py` - **変更内容:** name 解決の case に `build` の分岐を足す。`$2` が形に合い `containers/$2` が実在すれば name 解決を通さず、 @@ -236,7 +258,7 @@ - **満たす受け入れ条件:** 1(wrapper)、6、7、8 - **進め方:** テスト駆動 -### Task 5: `container` / `ct` を name 解決から外す(F4) +### Task 5 [x]: `container` / `ct` を name 解決から外す(F4) - **対象ファイル:** `bin/devbase`、`lib/devbase/cli.py`(コメント)、`tests/cli/test_project_name_resolution.py`、 `tests/cli/test_project_dispatch.py` @@ -245,7 +267,7 @@ - **満たす受け入れ条件:** 12、13、14 - **進め方:** テスト駆動 -### Task 6: 文書と CHANGELOG +### Task 6 [x]: 文書と CHANGELOG - **対象ファイル:** `docs/user/cli-reference/02-project.md`、`docs/user/cli-reference/README.md`、`docs/developer/architecture.md`、 `docs/specifications/editor-open.md`、`CHANGELOG.md` @@ -253,7 +275,7 @@ - **満たす受け入れ条件:** (文書。受け入れ条件には対応しない) - **進め方:** テスト駆動を適用しない(文書のみ) -### Task 7: 全体の検証 +### Task 7 [x]: 全体の検証 - **変更内容:** `uv run --locked pytest -q tests/`、`shellcheck --severity=error bin/devbase`、`ruff check --select=E9,F63,F7,F82 lib`、 macOS の `/bin/bash`(3.2)で `tests/cli` @@ -272,5 +294,5 @@ ### 完了の定義 -- [ ] 受け入れ条件 16 件に検証手段(テスト名)が対応している -- [ ] `uv run --locked pytest -q tests/` が exit 0、`shellcheck --severity=error bin/devbase` と `ruff check --select=E9,F63,F7,F82 lib` が exit 0 +- [x] 受け入れ条件 16 件に検証手段(テスト名)が対応している +- [x] `uv run --locked pytest -q tests/` が exit 0(2813 passed)、`shellcheck --severity=error bin/devbase`(0.11.0、exit 0)と `ruff check --select=E9,F63,F7,F82 lib`(exit 0)が exit 0。macOS の `/bin/bash` 3.2.57 で `tests/cli` 824 passed From 712682af689100656ea9bf0ff6c88ee4a1d5e14f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 10:29:59 +0900 Subject: [PATCH 8/9] test: characterize CLI dispatch, repo prefixes, and injection failures Add characterization tests for R1-001, R1-002, and R1-004 without changing production code. Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- tests/cli/test_prefix_resolution.py | 25 +++++++++++++++ tests/cli/test_project_dispatch.py | 18 +++++++++++ tests/cli/test_secret_injection.py | 50 +++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+) diff --git a/tests/cli/test_prefix_resolution.py b/tests/cli/test_prefix_resolution.py index 0acc12c6..fb848757 100644 --- a/tests/cli/test_prefix_resolution.py +++ b/tests/cli/test_prefix_resolution.py @@ -4,6 +4,8 @@ import sys +import pytest + from devbase import cli @@ -78,6 +80,29 @@ def test_expand_argv_env_k_resolves_to_keygen(monkeypatch): assert sys.argv == ["devbase", "env", "keygen"] +@pytest.mark.parametrize('command', ['plugin', 'pl']) +def test_expand_argv_plugin_repo_ref_resolves_to_refresh(monkeypatch, command): + """現状固定: repo の一意な prefix は第 4 引数で展開される。""" + monkeypatch.setattr(sys, 'argv', ['devbase', command, 'repo', 'ref']) + cli._expand_argv() + assert sys.argv == ['devbase', command, 'repo', 'refresh'] + + +@pytest.mark.parametrize('prefix', ['r', 're']) +def test_expand_argv_plugin_repo_ambiguous_prefix_is_unchanged(monkeypatch, prefix): + """現状固定: remove / refresh に一致する prefix は選択されない。""" + monkeypatch.setattr(sys, 'argv', ['devbase', 'plugin', 'repo', prefix]) + cli._expand_argv() + assert sys.argv == ['devbase', 'plugin', 'repo', prefix] + + +def test_expand_argv_env_leaves_fourth_argument_unchanged(monkeypatch): + """現状固定: plugin repo 以外では第 4 引数を展開しない。""" + monkeypatch.setattr(sys, 'argv', ['devbase', 'env', 'get', 'ref']) + cli._expand_argv() + assert sys.argv == ['devbase', 'env', 'get', 'ref'] + + def test_env_subcmd_map_covers_all_registered_subcommands(): """SUBCMD_MAP['env'] が parser 登録済みサブコマンドを漏れなく含む。 diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py index bf85e31e..286a8547 100644 --- a/tests/cli/test_project_dispatch.py +++ b/tests/cli/test_project_dispatch.py @@ -215,6 +215,24 @@ def test_dispatch_project_routes_to_cmd_project(monkeypatch): assert calls == ['project'] +def test_dispatch_project_migrate_config_passes_root_and_args(monkeypatch, tmp_path): + """現状固定: migrate-config の委譲先・引数・戻り値を記録する。""" + from devbase.commands import project + calls = [] + + def handler(root, args): + calls.append((root, args)) + return 7 + + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + monkeypatch.setattr(project, 'cmd_project_migrate_config', handler) + args = _args(command='project', subcommand='migrate-config') + + assert cli._dispatch('project', args) == 7 + assert calls == [(tmp_path, args)] + assert calls[0][1] is args + + def test_dispatch_container_routes_to_cmd_container(monkeypatch): from devbase.commands import container calls = [] diff --git a/tests/cli/test_secret_injection.py b/tests/cli/test_secret_injection.py index e768d4cd..7c5c32f3 100644 --- a/tests/cli/test_secret_injection.py +++ b/tests/cli/test_secret_injection.py @@ -194,6 +194,56 @@ def test_main_passes_the_parsed_name(monkeypatch): assert seen == {'cmd': 'project', 'subcommand': 'up', 'name': 'web'} +def test_main_preserves_command_result_after_secret_devbase_error(monkeypatch, tmp_path): + """現状固定: 任意注入の DevbaseError はコマンド結果を置き換えない。""" + from devbase.commands import container + from devbase.env import runtime + from devbase.errors import DevbaseError + + def inject(root, project): + raise DevbaseError('failed to inject secrets') + + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + monkeypatch.setattr('sys.argv', ['devbase', 'project', 'ps']) + monkeypatch.setattr(runtime, 'inject', inject) + monkeypatch.setattr(container, 'cmd_project', lambda args: 7) + + assert cli.main() == 7 + + +def test_main_preserves_command_result_after_secret_runtime_error(monkeypatch, tmp_path): + """現状固定: 任意注入の一般例外でもコマンドを継続する。""" + from devbase.commands import container + from devbase.env import runtime + + def inject(root, project): + raise RuntimeError('unexpected injection failure') + + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + monkeypatch.setattr('sys.argv', ['devbase', 'project', 'ps']) + monkeypatch.setattr(runtime, 'inject', inject) + monkeypatch.setattr(container, 'cmd_project', lambda args: 7) + + assert cli.main() == 7 + + +def test_main_returns_one_after_command_devbase_error(monkeypatch, tmp_path): + """現状固定: 注入成功後のコマンドの DevbaseError は終了値 1 になる。""" + from devbase.commands import container + from devbase.env import runtime + from devbase.errors import DevbaseError + + def command(args): + raise DevbaseError('command failed') + + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + monkeypatch.setattr('sys.argv', ['devbase', 'project', 'ps']) + monkeypatch.setattr(runtime, 'inject', lambda root, project: None) + monkeypatch.setattr(container, 'cmd_project', command) + + assert cli.main() == 1 + + @pytest.mark.parametrize('subcommand', ['list', 'get', 'set', 'delete', 'edit', 'init', 'sync', 'project', 'export', 'import']) def test_env_subcommands_resolve_their_own_group_with_the_group_layout(calls, tmp_path, From 8f12bbb940a3c8e0c20546e2dc8103b171096ba2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 10:44:40 +0900 Subject: [PATCH 9/9] =?UTF-8?q?Test:=20characterization=20=E2=80=94=20lib/?= =?UTF-8?q?devbase/cli.py#=5Frequire=5Fdevbase=5Froot,=20#main?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 現状固定テストを 2 件追加する(対象コードは変更しない)。 - test_project_dispatch.py: _require_devbase_root の DEVBASE_ROOT 未設定→ SystemExit(1) のエラー分岐と、設定済み→Path 返却の正常系を固定する。 - test_secret_injection.py: トップレベルショートカット (devbase up ) 経由でも解析された名前が cmd='up' とともに _load_secret_env へ渡る主要 経路を main エントリで固定する。 Item-Id: R1-003 Round: 1 Impl-Runtime: kiro Impl-Model: default --- tests/cli/test_project_dispatch.py | 26 ++++++++++++++++++++++++++ tests/cli/test_secret_injection.py | 16 ++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/tests/cli/test_project_dispatch.py b/tests/cli/test_project_dispatch.py index 286a8547..c1521552 100644 --- a/tests/cli/test_project_dispatch.py +++ b/tests/cli/test_project_dispatch.py @@ -528,3 +528,29 @@ def test_dispatch_unknown_command_returns_one(): """未知コマンドで spec is None となり return 1 する経路を固定する。""" args = _args(command='bogus') assert cli._dispatch('bogus', args) == 1 + + +# --------------------------------------------------------------------------- +# _require_devbase_root: DEVBASE_ROOT 未設定→終了、設定済み→Path を返す +# (list / migrate-config / _ROOT_COMMANDS が共有するヘルパの現状固定) +# --------------------------------------------------------------------------- + +def test_require_devbase_root_exits_when_unset(monkeypatch): + """現状固定: DEVBASE_ROOT 未設定なら SystemExit(1) で終了する。""" + from pathlib import Path + + monkeypatch.delenv('DEVBASE_ROOT', raising=False) + + with pytest.raises(SystemExit) as exc: + cli._require_devbase_root() + + assert exc.value.code == 1 + + +def test_require_devbase_root_returns_path_when_set(monkeypatch, tmp_path): + """現状固定: DEVBASE_ROOT が設定済みなら Path(値) を返す。""" + from pathlib import Path + + monkeypatch.setenv('DEVBASE_ROOT', str(tmp_path)) + + assert cli._require_devbase_root() == Path(tmp_path) diff --git a/tests/cli/test_secret_injection.py b/tests/cli/test_secret_injection.py index 7c5c32f3..4444706d 100644 --- a/tests/cli/test_secret_injection.py +++ b/tests/cli/test_secret_injection.py @@ -194,6 +194,22 @@ def test_main_passes_the_parsed_name(monkeypatch): assert seen == {'cmd': 'project', 'subcommand': 'up', 'name': 'web'} +def test_main_passes_the_parsed_name_via_top_level_shortcut(monkeypatch): + """現状固定: トップレベルショートカット (`devbase up `) 経由でも、 + 解析されたプロジェクト名が cmd='up' とともに _load_secret_env へ渡る。 + """ + seen = {} + monkeypatch.setattr(cli, '_load_secret_env', + lambda cmd, subcommand=None, name=None: seen.update( + cmd=cmd, subcommand=subcommand, name=name)) + monkeypatch.setattr(cli, '_dispatch', lambda cmd, args: 0) + monkeypatch.setattr('sys.argv', ['devbase', 'up', 'carmo']) + + assert cli.main() == 0 + assert seen['cmd'] == 'up' + assert seen['name'] == 'carmo' + + def test_main_preserves_command_result_after_secret_devbase_error(monkeypatch, tmp_path): """現状固定: 任意注入の DevbaseError はコマンド結果を置き換えない。""" from devbase.commands import container