Skip to content

Latest commit

 

History

History
797 lines (641 loc) · 69.1 KB

cli-argument-resolution.md

File metadata and controls

797 lines (641 loc) · 69.1 KB

位置引数の解決(プロジェクト名・イメージ名)

概要

devbase <コマンド> <値> の <値> を、プロジェクト名として解釈するか、そのコマンド固有の意味 (イメージ名・login の番号・scale の台数)のまま下流へ渡すかを決める規則。判定は起動ラッパー bin/devbase(name 解決)と、ラッパーを経ない直接起動に備える Python 側の検証の 2 か所にあり、 どちらも同じ名前の形を使う。

入口 名前の位置 解決するもの
devbase <sub> <name>(ショートカット) 2 番目 [name] の表の devbase <sub> の行に login と build を足したもの
devbase project <sub> <name> 3 番目 [name] の表の devbase project <sub> の行
devbase container <sub> … / ct(非推奨) — 解決しない([name] を受け付けない)
python -m devbase.cli project <sub> <name> 3 番目 Python 側のフォールバック(_resolve_project_name)

名前として解釈した値は、ラッパーが $DEVBASE_ROOT/projects/<name> へ cd して引数から取り除く。 devbase は PATH 上の実行ファイルとして子プロセスで起動するため、この cd が呼び出し元シェルの 作業ディレクトリを変えることはない。

用語

用語 意味
名前の形 親ディレクトリの直下の 1 つの名前として受け付ける形。[A-Za-z0-9][A-Za-z0-9._-]* の全体一致
name 解決 位置引数を名前として解釈し、projects/<name> へ cd して引数から取り除く処理
ラッパー bin/devbase(bash 実装の入口。Python 実装のコマンドへ run_python で振り分ける)
ショートカット devbase up のように project を省いたトップレベルの同義語(cli.SHORTCUTS と shell の _NAME_RESOLVABLE_SHORTCUTS)
単体ビルド $DEVBASE_ROOT/containers/<image> を devbase-<image>:latest として 1 つだけ作るビルド

「プロジェクトとして数える名前」「名前の形の説明」「機密の書き込みの知らせ」「列挙の正本」「一致テスト」「写し」 「補完の例外」の定義は用語集にある。

構成要素

要素 置き場所 責務
名前の形の規則 lib/devbase/utils/names.py SINGLE_SEGMENT_NAME_PATTERN と is_single_segment_name(value)、名前の形を利用者へ説明する文 NAME_FORM_HINT、プロジェクトとして数える名前の述語 counts_as_project(name)、その述語と is_dir() で projects/ の直下を絞る project_dirs(projects_dir)。re と pathlib だけに依存し副作用を持たない(ログも出さない)。消費側は from devbase.utils import names と読み込み、module の属性として呼ぶ
名前の形の規則(shell) bin/devbase _SINGLE_SEGMENT_NAME_RE と is_single_segment_name。Python と同じ正規表現を文字列で持つ
name 解決 bin/devbase maybe_cd_project。形・実在の順に見て、通れば cd と COMPOSE_PROJECT_NAME / env の再読み込み
解決の対象の一覧 bin/devbase _PROJECT_NAME_SUBCOMMANDS(project の対象)と _NAME_RESOLVABLE_SHORTCUTS(トップレベルの対象)
build の使い方 bin/devbase build_usage。トップレベル build の -h / --help で出す
解決の順序 bin/devbase の name 解決の case build のヘルプ → project → build → その他のショートカット
名前の検証(切替) lib/devbase/commands/container.py _resolve_project_name。ラッパーを経ない直接起動のフォールバック
名前の検証(注入) lib/devbase/cli.py _named_lifecycle_project。dispatch 前の機密の注入で使うプロジェクト名
名前の検証(イメージ) lib/devbase/commands/container.py _build_single_image。containers/<image> へ連結する前の検証
同期の知らせ lib/devbase/plugin/syncer.py _warn_unusable_name(name, source, base=None)。sync_projects が symlink を張る直前と、projects/ 直下の実ディレクトリの列挙で呼ぶ
機密の書き込みの知らせ lib/devbase/env/secret_store.py warn_unusable_project_name(ref)。ファイルの backend(PlaintextBackend・AgeBackend)の save_bytes と、commands/env.py の cmd_env_edit の直接編集が呼ぶ
env import の知らせ lib/devbase/env/io_import.py _warn_unusable_project_names(plans, root)。import_bundle が計画を組んだ後、--dry-run の判定より前に呼ぶ
書庫のメンバー名 lib/devbase/env/_import_merge.py project_name_of(arcname)。env/projects/<name>/.env からプロジェクト名を取り出す(それ以外は None)
スナップショットの名前 lib/devbase/snapshot/manager.py SnapshotManager._validate_name。utils/names.is_single_segment_name を共有する
引数の受け口 lib/devbase/cli.py _add_project_parser(name positional を持つサブコマンド)、SHORTCUTS、GROUP_ALIASES

型(クラス)は持たない。モジュール関数の並びで構成する。

graph TD
    subgraph 入口のシェル
        W[name 解決の case] --> UB[build_usage]
        W --> M[maybe_cd_project]
        W --> SS[is_single_segment_name<br/>shell]
        M --> SS
    end
    subgraph Python
        C[cli.main] --> NL[_named_lifecycle_project]
        C --> RP[_resolve_project_name]
        C --> BI[_build_single_image]
        NL --> PS[utils/names<br/>is_single_segment_name]
        RP --> PS
        BI --> PS
    end
    W -->|run_python| C
Loading

cli.main と _resolve_project_name の間には _dispatch_lifecycle と _enter_project がある。

仕様

[name] と --context を受け付けるサブコマンド

どのサブコマンドが [name](プロジェクト名)と --context NAME を受け付けるかの正本は argparse (lib/devbase/cli.py の _create_parser())である。ほかの文書は列挙を持たず、この節の表へリンクする。 ct は container の別名で、受け付けるものは同じである。

[name] を受け付けるサブコマンド

入口 サブコマンド
devbase <sub> up down ps scale rebuild open
devbase project <sub> up down ps logs scale rebuild open post-start
devbase project profile <sub> up down list

container / ct は [name] を受け付けない(PLAN61 決定 10)。plugin / snapshot / env backend use の name はプラグイン・スナップショット・backend の名前で、この表に入れない。ラッパーが名前を解決する 対象(概要の表)は、この表から次の規則で決まる。_PROJECT_NAME_SUBCOMMANDS は project の 直下の行(入れ子の profile は 3 番目の引数が up / down / list になるため除く)。 _NAME_RESOLVABLE_SHORTCUTS は cli.SHORTCUTS のキーと build(login は parser が [name] を 持たないが、ラッパーの name 解決だけが名前の指定の手段になる)。

--context を受け付けるサブコマンド

入口 サブコマンド
devbase <sub> up down ps login scale build rebuild open
devbase project <sub> up down ps logs login scale build rebuild open post-start
devbase project profile <sub> up down list
devbase container <sub> up down ps logs login scale build rebuild open
devbase container profile <sub> up down list
devbase env <sub> exec token

トップレベルの build だけは argparse に parser が無く、シェルの cmd_build が --context を受け付ける。

一致テストが比べる写し

tests/cli/test_name_context_consistency.py(一致テスト)は、列挙の正本を走査して 2 つの集合を得て、 写しと比べる。写しの場所ごとに別のテストにし、差があれば場所(ファイルと箇所)と、欠けた・余分な サブコマンドを挙げて落ちる。1 回の実行で、取り残した写しがすべて並ぶ。

項目 規則
走査 _create_parser() の木を入れ子の parser まで辿る。別名のグループ(ct)は cli.GROUP_ALIASES で正式の名前に直して数える
[name] の集合 位置引数 name を持つ parser のうち、トップレベルと project グループ(入れ子の profile を含む)の道。plugin / snapshot / env backend use の name は含めない
--context の集合 --context を持つすべての parser の道に、トップレベルの build(ラッパーだけが受け付ける)を名指しで足したもの
--context を取らない末端の道 子の parser を持たない parser の道から --context の集合を引いたもの。補完の --context の余分を比べる道で、親の道(project profile など)は含めない。親の道の zsh の分岐は子の分岐を含み、子の --context を拾うため
走査の健全性 どちらの集合も空でなく、入れ子の道(3 語)を含み、[name] の集合が plugin / snapshot / env の道を含まない
写し 比べ方
bin/devbase の _PROJECT_NAME_SUBCOMMANDS project の直下で [name] を取る parser の名前の集合に等しい
bin/devbase の _NAME_RESOLVABLE_SHORTCUTS cli.SHORTCUTS のキーと build の和に等しい。あわせて、トップレベルで [name] を取る parser がすべて cli.SHORTCUTS にあること
etc/devbase-completion.bash 一時ディレクトリの projects/ を DEVBASE_ROOT にして補完を実行し、[name] の集合から補完の例外を引いたすべての道の名前の位置でプロジェクト名が候補に出る。--context の集合のすべての道(container の道は ct で始めても)で devbase <道> - の候補に --context が出る。[name] の集合から補完の例外を引いた道では、devbase <道> web - の候補にも --context が出る。--context を取らない末端の道では、devbase <道> - の候補に --context が出ない。project / container の直下で [name] か --context を取るサブコマンド(入れ子は親の profile として数える)が、そのグループのサブコマンドの候補に含まれる。env の直下のサブコマンドは、[name]・--context を取るかによらず全部が env のサブコマンドの候補に含まれる
etc/_devbase zsh を起動せず内容を読む。case "$words[2]" から字下げの深さで道の分岐(up|down) のような | 区切りの見出し)を辿り、[name] の集合から補完の例外を引いた道の本体に _devbase_project_names がある。--context の集合のすべての道の本体に、値の補完を持たない '--context[...]:context:'(:context: の後ろに補完の関数を置かない形)がある。project_subcommands / container_subcommands / env_subcommands の一覧に bash と同じサブコマンドが含まれる
この節の 2 つの表 見出し #### `[name]` を受け付けるサブコマンド と #### `--context` を受け付けるサブコマンド の直後の表を読む。1 列目は devbase <グループ...> <sub> の形で、間の語が道の頭になる。2 列目にバッククォートで並べた語を <sub> に入れる。表は [name] の集合・--context の集合にそれぞれ等しい

補完の例外は project profile up / down / list の 3 つで、理由は「1 つ目の位置引数は、値が 1 個なら プロファイル名、2 個ならプロジェクト名になり、補完の時点ではどちらか決まらない」。一致テストは例外を 理由つきの一覧として持ち、例外の道が [name] の集合に実在すること・理由が空でないことも確かめる。 補完は --context の値(docker context の名前)を bash でも zsh でも補完しない。zsh は一致テストが上の --context の指定の形で確かめ、補完の実行テストが候補が 0 件であることを確かめる。bash は tests/cli/test_completion.py が --context の直後の候補が 0 件であることを代表の道で確かめる。

一致テストは zsh の補完を内容で読む。zsh が実際に出す候補そのものは、補完の実行テストが見る (zsh の補完の実行テスト)。zsh の --context の余分(取らない道で出す)は、 分岐の文字列でなく実行テストの候補で比べる(X1)。

補完が --context を出す位置

位置 例 bash zsh
(a) 道の直後 devbase up -・devbase env exec -・devbase ct profile up - --context の集合のすべての道で出し、--context を取らない末端の道では出さない 同じ(--context の集合のすべての道の分岐で出し、取らない末端の道では出さない)
(b) プロジェクト名の直後 devbase up web -・devbase project open web - [name] の集合から補完の例外を引いた道で出す _arguments が位置を問わず出す
(c) --context の直後(値) devbase up --context <TAB> 候補は 0 件 説明だけを出し、候補は 0 件
(a)・(b) より後ろ devbase scale web 3 -・devbase env exec -- ls - 出すことを求めない 出し得る(argparse では、env exec のコマンドの後の --context はそのコマンドへ渡る)

bash(etc/devbase-completion.bash)は、道ごとの分岐が _devbase_complete_flags <旗の候補> "$cur" [names | 語の並び] を呼ぶ。補完する語が - で始まれば旗の候補(ctx_flags・ps_flags・logs_flags・open_flags。どれも --context を含む)を compgen -W で補完する語に前方一致するものへ絞って出し(devbase ps --a は --all だけ)、 始まらなければ names でプロジェクト名を、語の並びならその語(login の 1 2 など)を出す。- で始まらない語の 候補は旗の有無に依らない。直前の語が --context なら、どの分岐にも入る前に候補を空にして終える。 連想配列を使わず、macOS の bash 3.2 で動く。

bash の補完の入口 _devbase_completions は、語の位置(cword)だけで振り分ける。位置 1 はトップレベルのサブコマンド、 位置 2 は _devbase_complete_subcommands、位置 3 は _devbase_complete_args、位置 4 は _devbase_complete_after_args へ渡し、位置 3・4 はさらにグループ(project・container|ct・env・plugin|pl・snapshot|ss)ごとの関数 (_devbase_complete_project_args など)へ分ける。どの関数も 60 行以下に収める。devbase のルート (${DEVBASE_ROOT:-…})は _devbase_resolve_root 1 か所で解決し、呼び出し元の local devbase_root へ直に入れる。 標準出力で返すと、コマンド置換で値の末尾の改行が落ちるためである。bash の補完のテストは、補完を実行する補助 (tests/cli/conftest.py の run_bash_completion・bash_complete)を共通に使う。

zsh(etc/_devbase)は、サブコマンドの分岐から _arguments を直接呼ばず、_devbase_arguments <深さ> を通す。 _arguments は words[1] だけをコマンド名として飛ばすため、そのまま呼ぶと devbase up <TAB> の up が 1 番目の 引数に数えられ、'1:name:_devbase_project_names' が利用者の引数を指さない。_devbase_arguments は words と CURRENT をサブコマンドの語の位置(devbase up は 2、devbase project up は 3)まで詰めてから _arguments を 呼ぶ。--context の指定は値の補完を持たない '--context[Docker context]:context:' で、--context の集合の すべての道に分岐を置く(トップレベルの build・container up|down|rebuild・env token・入れ子の profile up|down|list も *) に落とさない)。

project と container の profile の分岐は、同じ形の case の入れ子を 2 か所に持つ。関数へ寄せないのは、一致テスト (tests/cli/test_name_context_consistency.py)が case の見出しを字下げの深さで辿って --context の指定を探すため である。関数へ移すと、profile) の本体が呼び出しの 1 行になり、up|down|list) の見出しが見つからなくなる。重なりは 約 10 行で、テストの辿り方を変える費用に見合わない。

zsh の補完の実行テスト

tests/cli/test_zsh_completion_run.py(補完の実行テスト)は、zsh を起動して etc/_devbase を補完として読み込み、 行を打って補完が実際に出した候補(compadd が加えた語)を期待と比べる。部品は tests/cli/zsh_completion_harness.py にある。外側の zsh が zsh/zpty で対話の zsh(zsh -f -i)を 1 つ起こし、etc/ を fpath に入れて compinit し、 compadd を包んで加えた語を行ごとに書き出す。すべての行を 1 つのセッションで流す。

不変条件 打つ行 期待
I1 [name] の集合から補完の例外を引いたすべての道の devbase <道> 候補に proj-a・proj-b が入る
I2 devbase login 候補に 1・2 が入る
I3 --context の集合のすべての道(container の道は ct で始めても)の devbase <道> - 候補に --context が入る
I4 [name] の集合から補完の例外を引いた道のうち --context の集合にもある道の devbase <道> proj-a - 候補に --context が入る
I5 --context の集合のすべての道(ct で始めても)の devbase <道> --context 候補が 0 件
X1 --context を取らない末端の道の devbase <道> - 候補に --context が入らない
項目 規則
打つ行の一覧 テストの _requests() の 1 か所で作る。道の集合と補完の例外は一致テストの走査の結果を読み、書き写さない。走査に無い login だけを名指しで足す
落ちたときの出力 期待に合わない行ごとに、打った行・期待・実際の候補を並べる。どの行でも包んだ compadd が呼ばれなければ(補完を読み込めない)、行ごとの違いを並べる前に、端末の出力の末尾を挙げて落ちる
時間切れ 1 回の補完の要求(と起動・読み込み)に 10 秒の応答が無ければ打ち切る。打ち切った行を「時間切れ」、それより後の行を「流していない」として落とす。起動した zsh とその子孫は、正常の後も打ち切りの後も終える
隔離 利用者の rc を読まない(-f)。渡す環境は PATH・HOME(一時のディレクトリ)・TERM=xterm・DEVBASE_ROOT(projects/proj-a・projects/proj-b だけを持つ一時の root)だけ
走る環境 手元(macOS)は zsh と zsh/zpty があれば走り、無ければ欠けたもの(zsh か zsh/zpty)を警告と飛ばす理由に出して飛ばす。CI の pytest のジョブは zsh を apt で入れ、印 DEVBASE_TEST_REQUIRE_ZSH=1 を立てる。印があると、欠けたときに飛ばさずに落ちる(CI の確定仕様)

利用者の rc(プラグイン管理・zstyle・補完の表示)を通した見た目は、このテストの対象外で、リリース後テストで 利用者の端末で確かめる。

名前の形

項目 内容
規則 [A-Za-z0-9][A-Za-z0-9._-]* に全体が一致する。先頭が英数字なので .・..・-x・空文字は当たらない。/・\・空白・非 ASCII は含められない
Python devbase.utils.names.is_single_segment_name(value)。re.fullmatch で見るため、末尾の改行も不一致になる
shell _SINGLE_SEGMENT_NAME_RE='^[A-Za-z0-9][A-Za-z0-9._-]*$'。比較は関数の中で local LC_ALL=C にして行う
使う場所 shell: maybe_cd_project、build の衝突の判定。Python: _resolve_project_name、_named_lifecycle_project、_build_single_image

プロジェクト名とイメージ名は同じ規則を使う。どちらも「決まった親ディレクトリ(projects/ / containers/)の直下の 1 つの名前」を表すためである。/ と .. だけを弾く拒否リストは採らない (\・空白・制御文字・非 ASCII のように見落とした文字がそのまま通る)。

shell が Python を呼ばずに同じ正規表現を文字列で持つのは、name 解決のたびに uv run の起動が 1 回増えるためである。2 か所の一致は同期テストで保つ。

[A-Za-z] の範囲は C ライブラリの正規表現ではロケールによって ASCII 以外を含みうるため、shell 側は LC_ALL=C を関数の中に閉じて比べる。[[:alnum:]] はロケールに依存し、Python の定義と字面で 比べられなくなるため使わない。

形に合わない値の扱い

ラッパーは形に合わない値を名前として扱わない(cd も引数からの除去もしない)。止めずに そのまま下流へ渡す。同じ位置引数が名前以外の意味(login の番号、build のイメージ、scale の 台数)も持つためである。ラッパーは名前かどうかだけを決め、止めるのは意味を知る下流に任せる。

名前の形に合わない名前の知らせ

名前の形に合わない名前が projects/ に載る時点で、警告を 1 行出す。知らせるだけで弾かない。 作られる symlink・ディレクトリと終了コードは変わらない(弾くと、そのディレクトリの中で名前 なしに打つ使い方まで失うため)。

名前 1 つにつき 1 行だけ出す。 そのため検査は _collect_project_candidates(候補の集約) ではなく、sync_projects が symlink を張る直前に置く。集約の側に置くと、同じ名前を 2 つの プラグインが持つときに 2 行出てしまい、実ディレクトリがあってスキップする名前にも出てしまう。

discover_projects には置かない。 plugin/updater.py が表示のための列挙にこの関数を使う ため、そこへ置くと同期と関係しない場面でも出る。

案内は出所で 4 通りに分かれる。

出所 何を直せばよいか
プラグインのプロジェクト そのプラグイン側の projects/<名前> を改名する
別名 <名前>.<owner> で、<名前> の側が形に合わない 同じくプラグイン側の projects/<名前> を改名する。同じ実行で <名前> 自身の知らせも出る
別名 <名前>.<owner> で、<名前> は形に合う 原因は devbase が付け足す <owner> の側にある。--link で入れたプラグインなら元パスの末尾のディレクトリ名、repos/ 由来ならその置き場のディレクトリ名を変える。プラグイン側を改名しても直らない
projects/ 直下の実ディレクトリ それ自身を改名する(プラグインとは関係しない)

devbase env import は取り込むプロジェクト名を見て、_build_plans が計画を組んだ後・ --dry-run の判定より前に知らせる。したがって --dry-run でも出る(取り込む前に名前の 問題を知らせるのが目的で、実際に書くかどうかとは独立しているため)。

文は保存先で分かれる。projects/<名前>/ を実ディレクトリとして作るのはファイル backend の 平文のときだけで、backend: age なら secrets/projects/<名前>.env.age、サーバ backend なら サーバ側へ書く(保存先は _build_plans が store.path(ref) で決める)。作らない保存先で 「作る」と書かないよう、plan.target と plan.ref から文を選び、projects/ には何も作られない ことと保存先を知らせる。

知らせは verbose に依存させない。知らせることが唯一の効果であり、黙る経路を作らない。文は 完了形にしない(projects/ に載る名前について、これから起きることを知らせるため)。

. で始まる名前(.vscode など)はプロジェクトとして数えず、同期の対象にならず、知らせも出ない。

判定は utils/names.is_single_segment_name、名前の形の説明文は utils/names.NAME_FORM_HINT の 1 か所にある。devbase up などが形に合わない名前を拒むときの文(commands/container.py)も NAME_FORM_HINT を埋め込む。

プロジェクトとして数える名前

projects/ の直下のどの名前をプロジェクトとして数えるかは、utils/names.counts_as_project の 1 か所で決める。空と . で始まる名前だけを数えず、名前の形は見ない(_foo・-x・a b も数え、 形に合わない名前は上の知らせで伝える)。

プラグインの同期(discover_projects・実ディレクトリの知らせ)、plugin info と status の プラグインのプロジェクト数(どちらも discover_projects を通す)、status のコンテナの状態、 project list、見つからない名前での up の候補、project migrate-config、env export、 env doctor、env encrypt / env decrypt、env backend status と backend の移行が、projects/ を 走査するときの名前の条件としてこの述語を呼ぶ。ディレクトリの条件(is_dir()・壊れた symlink を 拾う is_symlink()・compose.yml の有無)は走査ごとに持つ。名前の条件はディレクトリの条件より 先に見る(壊れた symlink を名前だけで外せ、stat を打たないため)。同期が張った symlink の掃除と、 別名の衝突を見るための実ディレクトリの集合は、プロジェクトを数えないため述語を通さない。

走査 場所 名前の条件 ディレクトリの条件
プラグインのプロジェクト(discover_projects) plugin/syncer.py names.project_dirs is_dir()
実ディレクトリの知らせ(sync_projects) plugin/syncer.py 述語の後に _warn_unusable_name(形に合わなければ知らせて数える) 実ディレクトリ
plugin info の一覧・status のプラグインのプロジェクト数 plugin/info.py・commands/status.py discover_projects を呼ぶ 同上
コンテナの状態 commands/status.py 述語 is_dir()・compose.yml の有無
project list commands/project.py 述語 is_symlink() または is_dir()
見つからないときの候補 commands/container.py 述語 is_dir() または is_symlink()
project migrate-config の対象 project/migrate.py 述語 is_dir()
env export の対象(_collect_projects) env/bundle.py names.project_dirs で黙って外し、その後に is_valid_project_name(合わなければ警告して外す) is_dir()
env doctor の重なりと除外設定の確認 commands/env_ops.py names.project_dirs is_dir()
env encrypt / env decrypt の対象 commands/env_migrate.py names.project_dirs is_dir()
env backend status と backend の移行の対象 commands/env_backend.py names.project_dirs の後に SecretRef.for_project(拒まれた名前は黙って外す) is_dir()

plugin info と status のプラグインのプロジェクト数は、述語を個別に持たず discover_projects を 通す。同期と同じ関数を呼ぶことで、ディレクトリの条件まで同期と構造的に一致する。

消費側は述語を names.counts_as_project(...) と module の属性として呼ぶ(project_dirs も module の 大域名で述語を引く)。from devbase.utils.names import counts_as_project と読み込むと、定義元を 差し替えるテストが届かず、独自の規則を持つ場合と同じく落ちる。名前は is_project_name にしない。 名前の形の述語(is_single_segment_name)と読み違えやすいためである。

機密の書き込みの知らせ

名前の形に合わないプロジェクト(_foo など)へ機密を書き込むとき、名前と NAME_FORM_HINT を 含む警告が標準エラーへ 1 行出る。書き込みは止めない。出すのはファイルの backend(平文・age)の save_bytes(env/secret_store.warn_unusable_project_name)と、env edit --project の平文の 直接編集(エディタを開く前)である。読み取り(path・exists・load)と、形に合う名前・共通の 機密への書き込みでは出ない。env import はファイルの backend では save_bytes を通らず、取り込みの 知らせだけが出る。サーバの backend への書き込みでは出ない。

save_bytes の中の順序は次のとおりである。拒否される参照は 2 までに止まり、知らせは出ない。

  1. _reject_user_ref(個人単位の参照を拒否)
  2. self.path(ref)(_validate_project_name で空・パス区切り・.・.. を拒否)
  3. warn_unusable_project_name(ref)
  4. 暗号化(age だけ)と書き込み

知らせの文は同期の知らせと同じ組み立てである。名前の中の表示できない文字(改行・ESC など)は エスケープして埋め込み、知らせが複数行に割れないようにする。

プロジェクト名として使えない形の名前のプロジェクトへ機密を書き込みます: '<name>'。この名前では、名前を指定した操作(devbase up <name> など)ができません(<NAME_FORM_HINT>)。projects/<name> の中で名前なしに打てば動きます。

知らせを SecretStore のメソッドに置かないのは、ストアを通らず backend を直に呼ぶ書き込み (env encrypt・env decrypt・ファイルの backend への移行)があるためである。path() に置かないのは、 読み取りでも呼ばれるためである。サーバの backend で出さないのは、env import がサーバへ書く経路で 取り込みの知らせと 2 回重なるためである。

devbase up などが形に合わない名前を拒むときの文は "プロジェクト名に使えない形です: '%s'(%s)" に 名前と NAME_FORM_HINT を埋め込む。NAME_FORM_HINT は末尾に句点も括弧も持たないので、全角の括弧は 呼び出し側が持つ。

トップレベル build の引数の解釈

上から順に見て、最初に当たった行で決まる。判定に使うのは $2 だけである (build --no-cache bi-tools のように位置引数が 2 番目に無いときは name 解決を通らず、 build) の分岐がイメージとして拾う)。

条件 解釈 行き先 cd
build より後ろのどこかに -h か --help がある 使い方 build_usage → 終了コード 0 しない
$2 が名前の形に合い containers/$2 が実在する イメージ $2 Python の project build $2 …。projects/$2 も実在すれば stderr に知らせを 1 行出す しない
$2 が名前の形に合い projects/$2 が実在する プロジェクト $2 projects/$2 へ cd し $2 を取り除いて shell の cmd_build する
上のどれでもない 既存の build) 分岐 位置引数があれば Python の単体ビルド、無ければ shell の cmd_build しない

containers/<x> があれば projects/<x> の有無によらず name 解決を通さない。build <image> は イメージを明示した指定であり、プロジェクトのビルドは通常そのディレクトリで引数なしに行うためで ある。逆にすると containers/<x> をトップレベルからビルドする手段が無いまま残る。判定は maybe_cd_project より前に置く(後だと cd と env の読み込みが先に起き、戻す手段が無い)。 名前の形を先に見るのは、containers/../x のような値でディレクトリの実在を確かめないためである。

衝突の知らせ

項目 内容
条件 トップレベル build <x> で、<x> が名前の形に合い、containers/<x> と projects/<x> がどちらもディレクトリとして実在する
出力 stderr に 1 行。1 回の起動で 1 回だけ
文言 Note: '<x>' is also a project (projects/<x>); building image containers/<x>. To build the project, run 'devbase build' in $DEVBASE_ROOT/projects/<x>($DEVBASE_ROOT は展開した値)
終了コード 知らせは終了コードに影響しない。単体ビルドの結果がそのまま返る

標準出力ではなく stderr へ出すのは、単体ビルドの出力をパイプで読む側の邪魔をしないためである。

devbase build --help / -h

項目 内容
名前 devbase build -h / devbase build --help。前方一致(devbase b --help)も同じ
入力 build より後ろの引数のどこかにある -h または --help。--context --help / --context -h も使い方として扱う。--context=--help / --context=-h は使い方にせず下流へ渡す(argparse の値不足で終了コード 2 になる)
出力 標準出力に下の使い方。終了コード 0
起こさないこと cmd_build・compose_with_secrets・run_python を呼ばない。cd せず、切り替え先のプロジェクト(projects/<name>)の env を読まない
対象外 devbase project build --help(argparse の --help)は変わらない。- の付かない help という語は名前として扱う
Usage: devbase build [<project> | <image>] [options]

Build devbase images.
  (no argument)       build the images of the current project (base image first)
  <project>           build the project in $DEVBASE_ROOT/projects/<project>
  <image>             build $DEVBASE_ROOT/containers/<image> alone as devbase-<image>:latest
                      (when both containers/<name> and projects/<name> exist, <name> 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

使い方をラッパーが出すのは、トップレベル build が shell の cmd_build と Python の project build に振り分けられ、受け付ける引数が両者で違う(--project-no-cache は shell にだけ ある)ためである。判定を name 解決より前に置くのは、build myapp --help で projects/myapp への cd とその env の読み込みを起こさないためである。起動時の $DEVBASE_ROOT/env と実行時の ディレクトリの env の読み込みは、コマンド名の解決より前に全コマンド共通で起きる。

container / ct グループ

container / ct は name 解決の対象外である。parser が [name] を持たないため、 devbase container up myapp は projects/myapp が実在しても argparse の usage エラー (unrecognized arguments: myapp、終了コード 2)になる。container scale <name> N は new_scale の型エラーで同じく 2 である。名前なしの devbase container up は今までどおり実行時の ディレクトリのプロジェクトで動き、非推奨の警告を出す。

ラッパーだけが名前を取り除く形では「実在するときだけ受け付ける」動きになり、受け付けるかどうかが 利用者の打った語ではなく projects/ の中身で変わる。container は非推奨で、名前の指定は project <sub> <name> が持つため、ラッパーを parser に合わせた。

Python 側の名前の検証

3 つの入口が projects/ または containers/ へ名前を連結する前に同じ規則で弾く。

入口 形に合わないときの振る舞い
container._resolve_project_name(name) プロジェクト名に使えない形です: '<name>'(英数字で始まり、英数字・'.'・'-'・'_' だけからなる名前) を error ログへ出して False を返す。chdir・env の読み込み・候補の提示をしない。呼び出し元(_dispatch_lifecycle)は 1 を返す
cli._named_lifecycle_project(root, cmd, sub, name) projects/<name> の実在を見る前に None を返す。store_for / ref_group を呼ばず、projects/ の外の env の宣言も読まない
container._build_single_image(image) Invalid image name: …(must be a single directory name under containers/: …) を error ログへ出して 1 を返す

_resolve_project_name は、ラッパーが起動前に cd 済みでないとき(python -m devbase.cli の 直接起動、_ensure_env_files などラッパーを経ない経路)のフォールバックである。ラッパー経由なら 同一パス判定で chdir は no-op になる。

失敗の形

状況 終了コード 出力
ラッパー経由で形に合わない名前(up ../etc など) 下流の結果 ラッパーは何も出さない。引数はそのまま Python へ渡る
project <sub> <name> / トップレベル up などで Python が形に合わない name を受け取った 1 プロジェクト名に使えない形です: …(error)。候補の一覧は出さない
形に合うが projects/<name> が無い 1 従来どおり利用可能なプロジェクト候補を添えたエラー
scale <形に合わない値>(値が 1 つだけ) 2 argparse の new_scale の型エラー
login <形に合わない値> 下流の結果 番号として docker compose へ渡る。cd しない
build <形に合わない値> 1 _build_single_image の Invalid image name
container <sub> <name> / ct <sub> <name>(up down ps logs rebuild open) 2 argparse の unrecognized arguments: <name>
container scale <name> [N] 2 argparse の new_scale の型エラー

解決の順序

graph TD
    A[コマンド名を前方一致で解決] --> H{build で<br/>-h か --help を含む}
    H -->|はい| U[build_usage<br/>終了コード 0]
    H -->|いいえ| G{コマンド}
    G -->|project| P3{sub が名前を取る}
    P3 -->|はい| M3[maybe_cd_project に 3 番目]
    P3 -->|いいえ| T[素通し]
    G -->|container か ct| T
    G -->|build| C{2 番目が形に合い<br/>containers にある}
    C -->|はい| N[projects にもあれば<br/>stderr に 1 行]
    N --> T
    C -->|いいえ| M2[maybe_cd_project に 2 番目]
    G -->|他のショートカット| M2
    G -->|それ以外| T
    M2 --> V{形に合い<br/>projects にある}
    M3 --> V
    V -->|はい| CD[cd と env の読み込み<br/>名前を取り除く]
    V -->|いいえ| T
    CD --> D[dispatch]
    T --> D
Loading

「他のショートカット」は _NAME_RESOLVABLE_SHORTCUTS から build を除いた 7 つ (up down ps scale login rebuild open)である。

常に成り立つ条件

  • 名前として projects/ へ連結される値は、必ず名前の形に合う 1 セグメントである。したがって 位置引数に .. や / を混ぜて $DEVBASE_ROOT/projects/<name> の外を指すこと (パストラバーサル)はできず、name 解決と Python 側の検証はそのような値で cd も env の 読み込みもしない。保証するのはここまでで、projects/<name> の実体がどこにあるかは含まない (下の「リンクの先は対象外」)
  • containers/ への連結(実在の確認と単体ビルド)も同じ規則を通る
  • ラッパーが名前として解釈した値だけが引数から取り除かれる。解釈しなかった値は 1 つも欠けずに 下流へ渡る
  • shell と Python の正規表現は同じ文字列である(同期テストが一致を見る)
  • プロジェクトとして数える名前の述語は、名前が空でなく . で始まらないことだけを見る。projects/ を 走査してプロジェクトを数える箇所は、どれもこの述語を前段に通す。plugin info と status の プラグインのプロジェクト数は、同期(discover_projects)と同じ名前の集合を見る
  • 名前の形の説明は NAME_FORM_HINT 1 つで、lib/ に同じ文を持つ別の場所は無い
  • 機密の保存先が受け付ける名前は、空・パス区切り・.・.. 以外のすべてである。拒否は SecretStoreError で、ファイルを書かない。形に合わない名前への書き込みは拒まず、知らせを 1 回出す

リンクの先は対象外

上の保証は位置引数の形についてのもので、$DEVBASE_ROOT/projects/<name> が指す先までは 縛らない。projects/<name> はプラグインの同期が張るシンボリックリンクであることが多く、実体は リポジトリの管理外(repos/ 配下など、.gitignore で除外された場所)にある。

$ ls -l $DEVBASE_ROOT/projects
lrwxr-xr-x  adminer   -> ../repos/github.com--devbasex--devbase-samples/adminer/projects/adminer
lrwxr-xr-x  myapp     -> ../repos/github.com--example-org--devbase-plugins/myapp-web/projects/myapp

maybe_cd_project の cd "$target" も Python 側の os.chdir もリンクを辿るため、対象が リンクなら実体のディレクトリへ移動し、そこの env を source する。これは登録済みの プロジェクトを扱うための意図した動きで、この仕様は変えていない。つまり、

事柄 保証
位置引数に .. / . を含めて projects/<name> の外を指す 拒む(名前の形で弾く)
projects/<name> が登録済みのシンボリックリンクで、実体が projects/ の外にある 拒まない。実体へ cd し、そこの env を読む

リンクを張れるのは $DEVBASE_ROOT/projects/ へ書ける者だけで、その者はもともと 任意の env をそこへ置ける。したがってリンクを辿ることで新たに広がる権限は無い。

残る衝突

トップレベルの devbase login <index> / devbase scale <N> は、値が実在するプロジェクト名と 一致すると名前として解釈される(projects/2 がある状態の devbase login 2 は番号 2 ではなく プロジェクト 2 への操作になる)。数字だけのプロジェクト名は通常作られないため衝突は偶発に 限られる。

名前の解決は現在地を見ない。maybe_cd_project が見るのは $DEVBASE_ROOT/projects/<値> の実在 だけなので、対象プロジェクトのディレクトリの中で打っても回避できない。projects/web の中で devbase login 2 と打つと projects/2 へ切り替わり、2 は引数から取り除かれて index は既定の 1 になる。devbase scale 2 も同じく projects/2 へ切り替わり、new_scale が無くなって usage error になる。回避するには、名前の解決を通らない形か、名前を明示した形を使う。

衝突する形 回避する形 理由
devbase login 2 devbase project login 2 project login は [name] を取らないため _PROJECT_NAME_SUBCOMMANDS に含まれず、2 は index のまま下流へ渡る(対象はカレントプロジェクト)
devbase scale 2 devbase project scale <name> 2 <name> が名前として取り除かれ、残る 2 が new_scale になる。devbase project scale 2 は依然 projects/2 へ切り替わるので、名前は省略しない

データ・設定

maybe_cd_project が名前として解釈したとき、ラッパーは対象ディレクトリで次を行う。

対象 内容
作業ディレクトリ $DEVBASE_ROOT/projects/<name> へ cd(子プロセスの中なので呼び出し元シェルには及ばない)
COMPOSE_PROJECT_NAME <name> を export
呼び出し元の env 固有のキー 起動時に記録したキーを unset してから対象の env を source する(呼び出し元の値の残留を防ぐ)
対象の env set -a の下で source ./env(.env は読まない)

Python 側の _resolve_project_name は同じ結果になるよう、chdir に加えて os.environ['PWD'] と COMPOSE_PROJECT_NAME を更新し、対象の env を os.environ へ反映する。

projects/ と containers/ の位置はどちらも $DEVBASE_ROOT の直下で、DEVBASE_ROOT が未設定の ときは Python 側の名前の検証が「DEVBASE_ROOT が未設定のため解決できません」で 1 を返す。

運用

  • 名前の形に合わないプロジェクト(_ で始まる名前など)は、名前の指定(CLI の [name] と devbase list の一覧)から操作できない。そのディレクトリの中で名前なしに打てば動く。 そうした名前が projects/ に載る時点で、警告が 1 行出る。 出所は 4 つある。プラグインの 同期が張る symlink(plugin install / update / sync)、同期が衝突のときに合成する別名 <名前>.<owner>、devbase env import、手で作った実ディレクトリ。知らせるだけで弾かず、 作られるものと終了コードは変わらない。詳しくは 名前の形に合わない名前の知らせ
  • 知らせは同期のたびに毎回出る(一度知らせたら黙る、といった抑止は持たない)
  • 名前の検証はリポジトリの中で 1 つに寄せきっていない。寄せていないのは env/bundle.py の is_valid_project_name(先頭の _ を許す。env の export / import の書庫の中の名前で、往復の 互換を持つ)と env/secret_store.py の _validate_project_name(機密の保存先のファイル名)の 2 つである。env export は、プロジェクトとして数える名前の述語で . 始まりを黙って外した後に is_valid_project_name を当て、合わない名前(a b など)を警告して外す。書庫の名前の規則 (bundle.is_valid_project_name と _import_merge._PROJECT_ENV_RE)は末尾を \Z で閉じ、末尾に 改行を持つ名前(foo\n)を通さない。この規則はプロジェクトとして数える名前の述語へ寄せない。 先頭の _ を許すかどうかが名前の形と違い、寄せると書庫が受け付ける名前が変わるためである。 _validate_project_name は空・パス区切り・.・.. だけを拒み、機密の保存先がパスを跨がない ための最低限の検査として残す。名前の形で狭めると、いま読み書きできている機密が例外になるため、 形に合わない名前は拒まずに機密の書き込みの知らせで伝える。位置引数の 解決はこの仕様の規則だけを使う。snapshot/manager.py のスナップショットの名前 (SnapshotManager._validate_name)は utils/names.is_single_segment_name を共有する (PLAN66)。文字集合が同じで、寄せても受け付ける名前は広がらない(狭まるのは末尾の改行を 持つ名前だけ)。例外の型と文言は変えていない。述語を将来広げるとスナップショットの名前も 広がるため、受理と拒否を tests/snapshot/test_manager_name.py で固定している
  • shell 側は macOS 既定の bash 3.2 で動くこと。[[ =~ ]] の右辺は変数で渡す(引用した右辺は 文字列として比べられる)。連想配列・${var,,}・mapfile を使わない
  • cli.py で [name] か --context を取るサブコマンドを足し引きすると、 tests/cli/test_name_context_consistency.py が、写しを取り残した場所(bin/devbase の 2 つの リスト・補完 2 ファイル・この仕様の表)と、欠けた・ 余分なサブコマンドを挙げて落ちる。挙がった場所を直す

テスト観点

  • 名前の形が実在のプロジェクト名(myapp・github_work_time・myapp-ai)を通し、../etc・ a/b・.・..・空・-x・café を弾くこと(tests/utils/test_names.py)
  • プロジェクトとして数える名前の述語が . 始まりと空だけを外すこと(tests/utils/test_names.py)。 述語を差し替えると projects/ の走査がすべてそれに従うこと、. 始まりの判定と名前の形の説明文が utils/names.py の外に無いこと(tests/utils/test_project_name_consumers.py)
  • 機密の書き込みの知らせが、形に合わない名前への書き込みで 1 回だけ出て、読み取り・形に合う名前・ 取り込みの重なりでは出ないこと。パスを跨ぐ名前は知らせより先に拒まれること (tests/env/test_secret_store.py・tests/env/test_io_import.py・tests/commands/test_env_store_switch.py)
  • projects/ に bar と .vscode(プラグインでは .foo)があるとき、plugin info・status・ project list・候補の表示・project migrate-config・env の各コマンドに . 始まりが出ず、その中の ファイルを書き換えないこと。plugin info と status の数が discover_projects と一致すること。 projects/a b は env export がこれまでどおり警告して外すこと。形に合わない名前での _resolve_project_name のエラーの文字列が変わらないこと(tests/utils/test_project_name_consumers.py)
  • 書庫の名前の規則が末尾の改行を持つ名前を export でも import でも通さないこと(tests/env/test_bundle.py)
  • shell の _SINGLE_SEGMENT_NAME_RE が Python の SINGLE_SEGMENT_NAME_PATTERN と一致すること (tests/cli/test_project_name_resolution.py の同期テスト)
  • [name] と --context を受け付けるサブコマンドの集合を argparse の走査(入れ子の profile を 含む)で得て、bin/devbase の 2 つのリスト・bash の補完(実行する)・zsh の補完(内容を読む)・ この仕様の 2 つの表と一致すること。補完がプロジェクト名を出さない project profile の [name] は、 理由つきの例外の一覧で外す(tests/cli/test_name_context_consistency.py)
  • 形に合わない名前で、トップレベルの 7 コマンドと project の 7 サブコマンドが projects/ の外へ cd せず、そこの env を読まないこと。形に合う実在の名前は cd して引数から取り除かれること (tests/cli/test_project_name_resolution.py)
  • ラッパーを経ない python -m devbase.cli project up ../etc が chdir せず、使えない形である旨を 出して 1 で終わること(同上)
  • dispatch 前の注入(_named_lifecycle_project)が形に合わない名前で projects/ の外の env を 読まず None を返すこと(tests/cli/test_secret_injection.py)
  • build <x> で containers/<x> と projects/<x> が両方あるときイメージが勝ち、知らせが stderr に ちょうど 1 行出ること。片方だけのときは従来どおりで知らせが出ないこと (tests/cli/test_build_image_argument.py)
  • build --help / -h(build <name> --help を含む)が終了コード 0 で使い方を出し、ビルドを 起こさず cd も env の読み込みもしないこと。使い方に --no-cache・--project-no-cache・ --expires[=DAYS]・--context NAME・<image> が載ること。--context --help は使い方、 --context=--help は下流へ渡ること(同上)
  • container / ct の 7 サブコマンドが名前を取り除かず、parser が SystemExit(2) になること。 名前なしの container up は実行時のディレクトリで動き非推奨の警告を出すこと (tests/cli/test_project_name_resolution.py、tests/cli/test_project_dispatch.py)
  • 同期が形に合わない名前でも今までどおり symlink を張り、その名前だけを 1 回知らせること。 形に合う名前では 1 行も出ないこと。別名では原因の側(<owner> か元の名前)に応じて案内が 変わること。実ディレクトリは残って知らせは 1 回で、同じ名前のプラグインの分と重ならないこと。 . 始まりの名前は symlink も知らせも出ないこと (tests/plugin/test_repos_core.py の TestSyncProjectsNameForm)
  • env import がメンバー名からプロジェクト名を取り出し、形に合わない名前でも今までどおり ディレクトリを作って 0 で終わり、その名前だけを 1 回知らせること。--dry-run では書かずに 知らせだけ出ること。形に合う名前では出ないこと(tests/env/test_io_import.py)
  • env import の保存先が projects/ の外(backend: age・サーバ backend)のときは、 projects/ を作ると書かずに保存先を知らせること(tests/cli/test_env_bundle_backend.py)
  • スナップショットの名前が位置引数と同じ規則を通り、受理と拒否(末尾の改行を含む)が固定 されていること(tests/snapshot/test_manager_name.py)
  • ラッパーの振る舞いは tests/cli/conftest.py の exec_wrapper で確かめる。bin/devbase を 一時ディレクトリへ複製して実プロセスで起動し、maybe_cd_project や cmd_build は差し替えず、 外へ出る呼び出しが通る uv だけを PATH の先頭で差し替える(複製した位置から DEVBASE_ROOT が決まるので、実環境の DEVBASE_ROOT を継承しない)
  • macOS の /bin/bash(3.2)で tests/cli を流すことと shellcheck --severity=error bin/devbase は手元で行う(CI の bash は Linux 版)
  • --context の集合のすべての道(container の道は ct で始めても)で、bash の devbase <道> - の候補に --context が出ること。[name] の集合から補完の例外を引いた道では devbase <道> web - でも出ること。zsh の 道の分岐の本体に値の補完を持たない '--context[...]:context:' があること。bash から道 1 つの --context を 外すと bash のテストが、zsh から外すと zsh のテストが、ファイルと道を挙げて落ちること (tests/cli/test_name_context_consistency.py)
  • --context を取らない末端の道で、bash の devbase <道> - の候補に --context が出ないこと。末端の道の 集合が env set を含み、--context の集合と重ならないこと。bash の末端の道(例 env set)の分岐へ --context を足すと、その道を挙げて落ちること(tests/cli/test_name_context_consistency.py)
  • bash の - で始まる語の候補が、旗の候補に --context を足した集合であること。--context の直後の候補が 0 件であること(devbase up --context・devbase project up web --context・devbase container ps --context・ devbase env exec --context)。etc/_devbase の _devbase() の中に _devbase_arguments を通らない _arguments の呼び出しが無いこと(tests/cli/test_completion.py)
  • zsh の補完が実際に出す候補が I1〜I5・X1 のとおりであること。打つ行の一覧に devbase env set -(X1)が 入ること。zsh の末端の道の分岐へ --context を足すと、X1 がその行を挙げて落ちること。期待に合わない行を、 打った行・期待・実際の候補を並べて挙げること。補完を読み込めなければ行ごとに並べる前に落ちること。応答しない補完を 10 秒(テストでは 短く渡す)で打ち切り、時間切れの行と流さなかった行を返し、起動したプロセスを残さないこと。利用者の rc を 読まないこと。zsh か zsh/zpty が無ければ、印が無ければ欠けたものの名前を出して飛ばし、印があれば落ちること (tests/cli/test_zsh_completion_run.py)

決定の記録

[name] と --context を受け付けるサブコマンドの列挙の正本と一致テスト(#214)、補完の --context(#435)、 zsh の補完の実行テスト(#455)、補完の --context の余分の比べ方(#453)の決定である。

決定 1: 取り残した写しを 1 回の実行で全部挙げるため、写しの場所ごとに別のテストにする

1 つのテストで全部の写しを順に確かめると、最初の差で止まり、2 つ目以降の取り残しが直した後の実行まで 見えない。場所ごとのテストにすれば、サブコマンドを 1 つ足したときに取り残した写しがすべて一度に並び、 どの写しが落ちたかがテスト名から読める。

決定 2: 写しを増やさないため、期待の集合をテストに書き写さず走査で得る

期待の集合をテストの定数に持つと、それ自体が新しい写しになり、サブコマンドを足すたびに直す場所が増える。 走査で得た集合を正とし、走査が壊れていないことは健全性の確かめ(空でない・入れ子を含む・プロジェクト名で ない name を含まない)だけで見る。人が書く確定仕様の表を走査と比べるため、集合の中身は表を通して 人の目に触れる。

決定 3: プロジェクト名の [name] は、トップレベルと project グループの道に限って集める

プロジェクト名を表す name はトップレベルと project グループ(入れ子の profile を含む)にしか無い。 plugin / snapshot / env backend use の name は別のものの名前である。container / ct は [name] を 持たない(PLAN61 決定 10)。含める側を名指しする形にし、ほかのグループの name は走査の健全性の確かめで 入っていないことを見る。

決定 4: 確定仕様の表を人のリンク先と機械の読み口に兼ねるため、見出しの文字列で表を探す

[name] の表と --context の表は、共通の節の下に別の見出しで隣り合わせに置く。見出しがリンクの錨にもなり、 ほかの文書からどちらの表へも直接辿れる。一致テストは見出しの文字列で節を探し、その直後の表を読む。表の 手前に見えない印を置く形は、消されても気づきにくく、見出しと印の 2 つを保つことになるため採らない。

決定 5: 読み手が集合を見渡せるよう、表は 1 行に 1 つの入口を置きサブコマンドを並べる

入口ごとの違い(container に post-start が無いなど)が並べて読める。1 行に 1 つのコマンドの道を置く形は、 --context の表が 40 行を超えて違いが読めなくなるため採らない。別名の ct は表に行を置かず、一致テストが cli.GROUP_ALIASES で container に直して数える。

決定 6: 確定仕様の中にテストで縛らない列挙を残さないため、概要の表は [name] の表へのリンクと規則で書く

概要の表の「解決するもの」はラッパーの 2 つのリストを表す。一致テストはラッパーのリストを規則で縛るため、 概要の表は「[name] の表のトップレベルの行に login と build を足したもの」のように、リンクと規則で 書けば写しを持たずに済む。

決定 7: 同じことを 2 か所で縛らないため、rebuild がラッパーの 2 つのリストにあることだけを見るテストは持たない

一致テストは 2 つのリストが argparse と SHORTCUTS から導いた集合に等しいことを見ており、rebuild を 含むことはその一部である。tests/cli/test_rebuild.py は、一致テストが見ない振る舞い(build) の分岐の cmd_build への委譲)だけを縛る。

決定 8: トップレベルの build の --context は、名指しの一覧で --context の集合に足す

argparse にトップレベルの build の parser は無く、--context はラッパーの build の分岐が抜き取る。 受け付けること自体は tests/cli/test_wrapper_build_context.py がラッパーを実行して確かめている。 bin/devbase の分岐を文字列で探す形は、コメントや使い方の文に --context があるだけで通るため採らない。

決定 9: 別の分岐の記述で通ってしまわないよう、zsh の補完は道の分岐ごとに読む

etc/_devbase の全体で _devbase_project_names を探すと、ほかのサブコマンドの分岐にあるだけで通る。 一致テストは case "$words[2]" から字下げの深さで道の分岐を辿り、その本体だけを見る。候補そのものは補完の 実行テストが zsh を起動して見る。一致テストが内容を読むのは、zsh の無い環境でも分岐の記述を確かめるためである。

決定 10: 例外が黙って広がらないよう、補完の例外は理由を持ち、実在する道だけを指す

例外の道が [name] の集合から消えたのに例外が残ると、次に別の理由で同じ道を外したいときに古い理由のまま 通る。例外を足すことは一致テストのファイルの差分として現れ、レビューで人が見る。

決定 11: --context は値を補完せず、旗の名前だけを補完で出す

--context を取るサブコマンドを補完から見つけられるようにする一方、値(docker context の名前)は利用者が打つ。 bash は直前の語が --context なら分岐に入る前に候補を空にして終える。後から分岐を足しても、値の位置へ プロジェクト名などの候補が漏れない。zsh は action を空にした '--context[...]:context:' で、値の位置では 説明だけを出す。

決定 12: bash は「名前の後でも出す道」を [name] の集合から補完の例外を引いた道に限る

devbase up web - の web がプロジェクト名かは、道が [name] を取るかで決まる。login の番号・build の イメージ名・profile のプロファイル名の後ろでは出すことを求めない。env exec の引数は REMAINDER で、 コマンドの後の --context はそのコマンドへ渡るため、道の 2 つ後ろまで一律に出す形は誤った位置で候補を出す。

決定 13: zsh はサブコマンドの位置まで words を詰めてから _arguments を呼ぶ

_arguments の位置引数の番号は words[1] の次から数える。サブコマンドの分岐でそのまま呼ぶと、'1:name:…' が サブコマンドの語自身を指し、プロジェクト名と番号が補完されない(--context を足すために分岐を _arguments へ 揃えたときに起きた)。分岐ごとに 2:・3: と番号を数える形は、数える場所が分岐の数だけ増えるため採らず、 深さだけを渡す _devbase_arguments の 1 か所で詰める。番号の誤りは、補完の実行テストが道ごとに拾う。素の _arguments が分岐に残らないことは tests/cli/test_completion.py が見る。

決定 14: 利用者の端末と同じ経路で候補を取るため、zpty で対話の zsh を起こし compadd を包む

compadd に渡った語を取るには、行を打って補完を求める経路(zle の complete-word)を通す必要がある。zsh/zpty は zsh 自身のモジュールで、macOS と Linux の zsh 5.9 で同じ候補を出した。包んだ compadd は builtin compadd -O で 加わる語を受け取って書き出し、元の compadd も呼ぶため、補完の振る舞いを変えない。Python の pty で zsh を操作する 形は、端末の制御の文字を読み分ける処理をテストの側に持つため採らない。script コマンドで端末を作る形は、macOS と Linux で引数の形が違うため採らない。

決定 15: 分岐ごとの誤りを道ごとに見つけるため、走査の集合のすべての道を zsh で回す

退行は分岐ごとに起き得る。54ed5bf^ の etc/_devbase では、代表の道だけでは container・ct・env token の直後の - で --context が出ない行が見えず、全道を回したときにだけ見えた。全道を回しても所要は数秒である。行は一致テストの 走査の集合から作り、期待の集合を書き写さない(決定 2 と同じ理由)。代表の道に留める形は、拾える退行が狭まるため採らない。

決定 16: Pull Request の検査で退行を落とすため、CI の pytest のジョブに zsh を入れ、CI では zsh が無ければ落とす

手元だけで走らせると、zsh の無い環境や流し忘れた Pull Request で退行が検査をすり抜ける。ubuntu ランナーには zsh が 無いため、pytest のジョブの手順で apt から入れて zsh --version をログへ出し、ジョブの env に印 DEVBASE_TEST_REQUIRE_ZSH=1 を置く。GitHub が立てる CI の環境変数で決める形は、手元で CI を立てた開発者まで zsh を必須にし、何のための値かが ci.yml から読めないため採らない。zsh の版を公式の配布物で固定する形は、比べるのが 候補の有無で版で揺れにくい一方、ソースから建てる手順が増えるため採らない。

決定 17: zsh が無い環境で静的な検査まで飛ばないよう、補完の実行テストを別のファイルに置く

飛ばしはファイルの単位で掛けるのが漏れにくい。一致テストのファイルに足すと、zsh の無い環境で唯一の zsh の検査である 静的な検査と、飛ばす範囲の境目を関数ごとに持つことになる。zsh を操作する部品は tests/cli/zsh_completion_harness.py に分け、テストのファイルには打つ行の一覧と比べ方だけを置く。

決定 18: 補完の --context は欠けと余分の両方を比べ、余分は子の parser を持たない末端の道に限る

補完が --context を取らない道で --context を勧めると、利用者が補完に従って打ったコマンドを argparse が 退け、終了コード 2 で止まる。欠けだけを比べる形では、写しが列挙の正本より広がっても検査で拾えない。余分を 比べる道を子の parser を持たない末端の道に限るのは、親の道(project profile など)の zsh の分岐の本体が 子の分岐を含み、子の --context を拾って誤って落ちるためである。zsh の余分は分岐の文字列でなく、補完の 実行テストの実際の候補で比べる(X1)。文字列で探すと、分岐の外の記述や子の分岐の記述で結果が変わる。

関連リンク