From 480ea780843d632d32c969d305537acb603ecd0d Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 09:56:39 +0900 Subject: [PATCH 01/20] =?UTF-8?q?docs:=20README=E3=82=92=E5=86=8D=E6=A7=8B?= =?UTF-8?q?=E6=88=90=E3=81=97=E5=AE=9F=E6=8C=99=E5=8B=95=E3=81=A8=E5=90=8C?= =?UTF-8?q?=E6=9C=9F=20/=20Restructure=20README=20and=20sync=20with=20actu?= =?UTF-8?q?al=20behavior?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 見出し階層を再編(導入→インストール→クイックスタート→リファレンス) - ヘルプ・エラー出力例を現行実装の実出力へ更新(表記や旧エラー文言を修正) - hello_app_with_docs.rb は定数名不一致のため -a 付きの実行例へ修正 - --new は「ファイルパスより前」に置く動作確認済みの形へ修正 - 現行実装で動作しないサンプル(--json-args/--eval-args と --new の併用、 --pre-script 単独でのインスタンス専用クラス起動)を削除し、検証済みの形へ差し替え - changelog口調(now infers等)を除去、公開済みgemと矛盾する末尾文言を修正 - Gem Versionバッジ・Ruby 3.0+要件・Licenseセクションを追加 - 日本語版のみにあった YARD 併用時の注意を英語版へも反映し両言語を同期 Co-Authored-By: Claude Fable 5 --- README.ja.md | 516 ++++++++++++++++++++++++------------------------- README.md | 531 +++++++++++++++++++++++++++------------------------ 2 files changed, 537 insertions(+), 510 deletions(-) diff --git a/README.ja.md b/README.ja.md index db8f0bb..7719ae3 100644 --- a/README.ja.md +++ b/README.ja.md @@ -2,11 +2,37 @@ ![Rubycli ロゴ](assets/rubycli-logo.png) -Rubycli は Ruby のクラス/モジュールにある公開メソッドの定義と、そのメソッドに付けたドキュメントコメントから CLI を自動生成する小さなフレームワークです。Python Fire にインスパイアされていますが、互換や公式ポートを目指すものではありません。Ruby のコメント記法と型アノテーションに合わせて設計しており、コメントに書いた型ヒントや繰り返し指定が CLI の引数解釈もコントロールします。 +[![Gem Version](https://img.shields.io/gem/v/rubycli)](https://rubygems.org/gems/rubycli) -> English guide is available in [README.md](README.md). +Rubycli は、既存の Ruby クラス/モジュールをそのままコマンドラインインターフェースにするツールです。 +公開メソッドの定義と、メソッドに付けたドキュメントコメントを読み取って CLI を組み立てるため、 +最小構成ではスクリプト側の変更が一切不要です(`require "rubycli"` すら要りません)。 +コメント内の型アノテーションは単なる説明ではなく、CLI 引数の解釈そのものを制御します +(例: `TAG... [String[]]` と書くと配列としてパースされます)。 -### 1. Rubycli を意識しない既存スクリプト +[Python Fire](https://github.com/google/python-fire) にインスパイアされていますが、 +移植や公式プロジェクトではなく、Ruby のコメント記法と型アノテーションに焦点を当てた独自実装です。 + +> English documentation: [README.md](README.md) + +![Rubycli のデモ(コマンド生成と実行の様子)](assets/rubycli-demo.gif) + +## インストール + +```bash +gem install rubycli +``` + +```ruby +# Gemfile +gem "rubycli" +``` + +Ruby 3.0 以上が必要です。ライセンスは [MIT](LICENSE) です。 + +## クイックスタート + +### 1. 既存スクリプトをそのまま実行する ```ruby # hello_app.rb @@ -19,7 +45,8 @@ module HelloApp end ``` -> リポジトリには `examples/hello_app.rb` を同梱しているので、プロジェクト直下で `rubycli examples/hello_app.rb` を実行すると公開コマンドをすぐに確認できます。 +同じ内容を `examples/hello_app.rb` として同梱しているので、以下のコマンドは +プロジェクト直下でそのまま試せます。 ```bash rubycli examples/hello_app.rb @@ -30,11 +57,13 @@ Usage: hello_app.rb COMMAND [arguments] Available commands: Class methods: - greet + greet NAME Detailed command help: hello_app.rb COMMAND help ``` +引数が足りない場合はスタックトレースではなく使い方が表示されます。 + ```bash rubycli examples/hello_app.rb greet ``` @@ -52,16 +81,15 @@ rubycli examples/hello_app.rb greet Hanako #=> Hello, Hanako! ``` -`rubycli examples/hello_app.rb --help` を実行しても同じヘルプが表示されます。 - -### 2. コメントのヒントを足してオプションを有効化 +`rubycli examples/hello_app.rb --help` でも、コマンド未指定時と同じ一覧が表示されます。 -> まだ `require "rubycli"` は不要です。コメントでオプション解析とヘルプを制御します。 +### 2. コメントを足して型付きオプションを有効にする -**簡潔なプレースホルダ記法** +この段階でも `require "rubycli"` は不要です。コメントだけでオプション解析とヘルプが変わります。 +簡潔なプレースホルダ記法と YARD タグのどちらでも書けます。 ```ruby -# hello_app.rb +# 簡潔なプレースホルダ記法 module HelloApp module_function @@ -75,10 +103,8 @@ module HelloApp end ``` -**YARD タグでも同様に動作** - ```ruby -# hello_app.rb +# YARD タグ module HelloApp module_function @@ -92,42 +118,31 @@ module HelloApp end ``` -> README に合わせたドキュメント付きの版は `examples/hello_app_with_docs.rb` として同梱しています。 - -```bash -rubycli examples/hello_app_with_docs.rb -``` - -```text -Usage: hello_app_with_docs.rb COMMAND [arguments] - -Available commands: - Class methods: - greet [--shout] - -Detailed command help: hello_app_with_docs.rb COMMAND help -``` +ドキュメント付きの版は `examples/hello_app_with_docs.rb` として同梱しています。 +このファイル名は定義している定数(`HelloApp`)と一致しないため、実行時に +`--auto-target` / `-a` を付けるか、定数名を明示してください +(詳細は後述の[対象定数の解決](#対象定数の解決)を参照)。 ```bash -rubycli examples/hello_app_with_docs.rb greet --help +rubycli -a examples/hello_app_with_docs.rb greet --help ``` ```text Usage: hello_app_with_docs.rb greet NAME [--shout] Positional arguments: - NAME [String] required 挨拶対象 + NAME [String] required Name to greet Options: - --shout [Boolean] optional 大文字で出力 (default: false) + --shout [Boolean] optional Print in uppercase (default: false) ``` ```bash -rubycli examples/hello_app_with_docs.rb greet --shout Hanako +rubycli -a examples/hello_app_with_docs.rb greet --shout Hanako #=> HELLO, HANAKO! ``` -CLI に公開したくないヘルパーは、特異クラス側で `private` として定義してください: +CLI に公開したくないヘルパーは、特異クラス側で `private` として定義します。 ```ruby module HelloApp @@ -135,99 +150,19 @@ module HelloApp private def internal_ping(url) - # CLI コマンドとしては露出しない + # CLI コマンドとしては公開されない end end end ``` -### 3. (任意)スクリプト内にランナーを組み込む - -`ruby hello_app.rb ...` の形で呼び出したい場合だけ `require "rubycli"` を追加し、`Rubycli.run` に制御を渡します(後述のクイックスタート参照)。 - -## 定数解決モード - -Rubycli は「ファイル名を CamelCase にした定数」を公開対象だと想定しています。ファイル名とクラス/モジュール名が一致しない場合は、次のモードで挙動を切り替えられます。 - -| モード | 有効化方法 | 挙動 | -| --- | --- | --- | -| `strict`(デフォルト) | 何もしない / `RUBYCLI_AUTO_TARGET=strict` | CamelCase が一致しないとエラーになります。検出した定数一覧と再実行コマンド例を表示します。 | -| `auto` | `--auto-target`(短縮 `-a`) または `RUBYCLI_AUTO_TARGET=auto` | ファイル内で CLI として実行できる定数が 1 つだけなら自動選択します。複数あれば従来通りエラーで案内します。 | - -大規模なコードベースでも安全側を保ちながら、どうしても自動選択したいときだけ 1 フラグで切り替えられます。 - -> **インスタンスメソッド専用のクラスについて** – 公開メソッドがインスタンス側(`def greet` など)にしか無い場合は、`--new` を付けて事前にインスタンス化しないと CLI から呼び出せません。クラスメソッドを 1 つ用意するか、`--new` を明示して実行してください。`--new` を付ければ `rubycli --help` でもインスタンスメソッドが一覧に現れ、`rubycli --check --new` でコメントの lint も実行できます。初期化時に引数が必要なら `--new=VALUE` のように続けて指定できます(通常の引数と同様に YAML/JSON ライクな安全パースに加え、`--json-args` / `--eval-args` / `--eval-lax` も適用可能)。`initialize` に書いたコメントも通常の CLI メソッドと同様に型変換に反映されます。 - -> 補足: `--new 1` のようにスペース区切りで 1 つだけ値を渡すと、後続トークンがパス扱いされやすいため `--new=VALUE` のように `=` 付きで指定するのが確実です。 - -## 開発方針 - -- **便利さが最優先** – 既存の Ruby スクリプトを最小の手間で CLI 化できることを目的にしており、Python Fire の完全移植は目指していません。 -- **インスパイアであってポートではない** – アイデアの出自は Fire ですが、同等機能を揃える予定は基本的にありません。Fire 由来の未実装機能は仕様です。 -- **メソッド定義が土台、コメントが挙動を補強** – 公開メソッドのシグネチャが CLI に露出する範囲と必須/任意を決めますが、コメントに `TAG...` や `[Integer]` を書くと同じ引数でも配列化や型変換が行われます。さらに Rubycli は `--names='["Alice","Bob"]'` のような JSON/YAML らしい入力を自動的に安全なリテラルとして評価します。`rubycli --check パス/対象.rb` でコメントと実装のズレ(未定義の型ラベルや列挙値の誤記を含む)を DidYouMean の候補付きで検査し、通常実行時に `--strict` を付ければドキュメント通りでない入力をその場でエラーにできます。 -- **軽量メンテナンス** – 実装の多くは AI 支援で作られており、深い Ruby メタプログラミングを伴う大規模拡張は想定外です。Fire 互換を求める PR は事前相談をお願いします。 - -## 特徴 - -- コメントベースで CLI オプションやヘルプを自動生成 -- YARD 形式と `NAME [Type] 説明…` の簡潔記法を同時サポート -- 引数はデフォルトで安全なリテラルとして解釈し、必要に応じて厳格 JSON モードや Ruby eval モードを切り替え可能 -- `--pre-script`(エイリアス: `--init`)で任意の Ruby コードを評価し、その結果オブジェクトを公開 -- `--check` でコメント整合性を lint、`--strict` で入力値をドキュメント通りに強制する二段構えのガード -- `examples/new_mode_runner.rb` ではインスタンス専用クラスを `--new=VALUE` で初期化し、eval/JSON モードや pre-script を組み合わせる例を示しています。 - -### サンプル / 付属例 - -- `examples/hello_app.rb` / `examples/hello_app_with_docs.rb`: 最小のモジュール関数とドキュメント付きの版 -- `examples/typed_arguments_demo.rb`: 標準ライブラリ型 (Date/Time/BigDecimal/Pathname) の coercion -- `examples/strict_choices_demo.rb`: リテラル列挙と `--strict` の組み合わせ -- `examples/new_mode_runner.rb`: インスタンス専用クラスを `--new=VALUE` で初期化し、eval/JSON/pre-script を組み合わせる例 - -#### サンプルコマンド - -- `rubycli examples/new_mode_runner.rb run --new='["a","b","c"]' --mode reverse` -- `rubycli --json-args --new='["x","y"]' examples/new_mode_runner.rb run --mode summary --options '{"source":"json"}'` -- `rubycli --eval-args --new='["x","y"]' examples/new_mode_runner.rb run --mode summary --options '{tags: [:a, :b]}'` -- `rubycli --pre-script 'NewModeRunner.new(%w[a b c], options: {from: :pre})' examples/new_mode_runner.rb run --mode summary` - -> 補足: `--strict` はコメントに書かれた型/許可値をそのまま信頼して検証するため、コメントが誤記だと実行時には検出できません。CI では必ず `rubycli --check` を走らせ、`--strict` は「 lint を通過したドキュメントを本番で厳密に守る」用途に使ってください。 - -## Python Fire との違い - -- **コメント対応のヘルプ生成**: コメントがあればヘルプに反映しつつ、最終的な判断は常にライブなメソッド定義に基づきます。 -- **型に基づく解析**: `NAME [String]` や YARD タグから型を推論し、真偽値・配列・数値などを自動変換します。 -- **厳密な整合性チェック**: `rubycli --check` でコメントと実装のズレ(未定義の型ラベルや列挙値の誤記など)をコード実行前に検査し、通常実行時に `--strict` を付ければドキュメントで宣言した型・許可値以外の入力を拒否できます。 -- **Ruby 向け拡張**: キーワード引数やブロック (`@yield*`) といった Ruby 固有の構文に合わせたパーサや `RUBYCLI_*` 環境変数を用意しています。 - -| 機能 | Python Fire | Rubycli | -| ---- | ----------- | -------- | -| 属性の辿り方 | オブジェクトを辿ってプロパティ/属性を自動公開 | 対象オブジェクトの公開メソッドをそのまま公開(暗黙の辿りは無し) | -| クラス初期化 | `__init__` 引数を CLI で自動受け取りインスタンス化 | `--new` 指定時だけ初期化(コンストラクタ引数は `--new=VALUE` で渡せる。YAML/JSON らしいリテラルは安全にパース、`--json-args` / `--eval-args` / `--eval-lax` も適用可能。より複雑なら pre-script や自前ファクトリを利用) | -| インタラクティブシェル | コマンド未指定時に Fire REPL を提供 | インタラクティブモード無し。コマンド実行専用 | -| 情報源 | 反射で引数・プロパティを解析 | ライブなメソッド定義を基点にしつつコメントをヘルプへ反映 | -| 辞書/配列 | dict/list を自動でサブコマンド化 | クラス/モジュールのメソッドに特化(辞書自動展開なし) | - -## インストール - -Rubycli は RubyGems からインストールできます。 - -```bash -gem install rubycli -``` +### 3. (任意)スクリプトにランナーを組み込む -Bundler 例: +`ruby スクリプト.rb ...` の形で起動したい場合は、gem を require して +`Rubycli.run` に委譲します(`examples/hello_app_with_require.rb` として同梱)。 ```ruby -# Gemfile -gem "rubycli" -``` - -## クイックスタート(Rubycli をスクリプトに組み込む) - -ステップ3では `require "rubycli"` を追加し、スクリプト自身から CLI を起動できるようにします。 - -```ruby -# hello_app.rb +# hello_app_with_require.rb require "rubycli" module HelloApp @@ -247,133 +182,172 @@ end Rubycli.run(HelloApp) ``` -実行例: - ```bash -ruby hello_app.rb greet Taro -#=> Hello, Taro! - -ruby hello_app.rb greet Taro --shout +ruby examples/hello_app_with_require.rb greet Taro --shout #=> HELLO, TARO! ``` -`require "rubycli"` を書かなくても、付属コマンドから同じファイルを実行できます: +付属の `rubycli` コマンド経由で実行した場合は、メソッドの戻り値が自動で標準出力に表示されます。 -```bash -rubycli path/to/hello_app.rb greet --shout Hanako -``` +## 対象定数の解決 -クラス/モジュール名を省略した場合でも、ファイル名に対応する定義を自動で推測し、ネストした `Module1::Inner::Runner` のようなクラスも見つけ出します。CLI から実行するとメソッドの戻り値は常に標準出力へ表示されます。 +Rubycli は「ファイル名を CamelCase にした定数」を公開対象と想定します。 +一致しない場合の挙動はモードで切り替えられます。 -別の定数を明示的に指定したい場合は、ファイルパスの後ろに続けてください: +| モード | 有効化方法 | 挙動 | +| --- | --- | --- | +| `strict`(既定) | 何もしない / `RUBYCLI_AUTO_TARGET=strict` | CamelCase 名が一致しないとエラー。検出した定数一覧と再実行方法を表示します。 | +| `auto` | `--auto-target` / `-a` / `RUBYCLI_AUTO_TARGET=auto` | CLI として実行できる定数がファイル内に 1 つだけなら自動選択します。 | + +ファイルパスの後ろに定数名を明示することもできます。1 ファイルに候補が複数ある場合や、 +ネストした定数を選びたい場合に便利です。 ```bash rubycli scripts/multi_runner.rb Admin::Runner list --active ``` -1つのファイルに複数の候補がある場合や、ファイル名と異なるネストした定義を選びたいときに便利です。 +`Module1::Inner::Runner` のようなネストした定数も検出できます。 + +## インスタンスメソッド専用クラスと `--new` + +公開メソッドがインスタンス側にしかないクラスは、`--new` を付けて事前にインスタンス化しないと +CLI から呼び出せません(Rubycli から見えるコマンドが 1 つもない状態になります)。 + +- `--new` を付けると `--help` の一覧にインスタンスメソッドが現れ、 + `rubycli --check --new` でコメントの lint も実行できます。 +- コンストラクタに引数が必要な場合は、**ファイルパスより前に** `--new=VALUE` の形で渡します。 + 値は安全な YAML/JSON ライクなリテラルとして解釈され、`initialize` に付けたコメントも + 通常の CLI メソッドと同様に型変換へ反映されます。 +- スペース区切りの `--new VALUE` は値がファイルパスと誤認されやすいため、 + `--new=VALUE` の形を推奨します。 + +実行例(`examples/new_mode_runner.rb`): + +```bash +rubycli --new='["a","b","c"]' examples/new_mode_runner.rb run --mode reverse +#=> ["c", "b", "a"] +``` ## コメント記法 +YARD タグと短縮形のどちらでも書けます。 + | 用途 | YARD 互換 | Rubycli 標準 | | ---- | --------- | ----------- | | 位置引数 | `@param name [Type] 説明` | `NAME [Type] 説明` | | キーワード引数 | 同上 | `--flag -f VALUE [Type] 説明` | | 戻り値 | `@return [Type] 説明` | `=> [Type] 説明` | -短いオプション(`-f` など)は任意で、登場順も自由です。Rubycli 標準の書き方では次の例が同義になります。 +短いオプション(`-f` など)は任意で、順序も自由です。次の 3 つは同義です。 - `--flag -f VALUE [Type] 説明` - `--flag VALUE [Type] 説明` - `-f --flag VALUE [Type] 説明` -README のサンプルは既定スタイルとして大文字プレースホルダ(`NAME`, `VALUE` など)を使用しています。次項以降の表記揺れは、必要に応じて選べる追加記法です。 +型は `[String]` でも `(String)` でも指定でき、`(String, nil)` のように複数型も書けます。 ### 互換プレースホルダ表記 -コメントやヘルプ出力では次の表記も同じ意味として解釈されます。 +コメントの解析とヘルプ出力の両方で、次の表記も同じ意味として扱われます。 -- 山括弧で値を明示: `--flag `, `NAME []` -- ロングオプションの `=` 付き表記: `--flag=` +- 山括弧: `--flag `, `NAME []` +- `=` 付きロングオプション: `--flag=` - 繰り返し指定: `VALUE...`, `...` -実行時には `--flag VALUE`, `--flag `, `--flag=` のどれで入力しても同じ扱いです。プロジェクトで読みやすいスタイルを選択してください。`[VALUE]` や `[VALUE...]` のような表記を使うと、真偽値・任意値・リストなどの推論が働きます。値プレースホルダを省略したオプション(例: `--quiet`)は自動で Boolean フラグとして扱われます。 - -> 補足: コメント内で任意引数を角括弧で表す必要はありません。Ruby 側のメソッドシグネチャから必須/任意は自動判定され、ヘルプ出力では Rubycli が適切に角括弧を追加します。 +実行時には `--flag VALUE`, `--flag `, `--flag=` のどれも同じ扱いなので、 +プロジェクトで読みやすいスタイルを選んでください。任意引数を自分で角括弧に包む必要は +ありません。必須/任意は Ruby のメソッドシグネチャから自動判定され、ヘルプ出力では +Rubycli が角括弧を補います。 -型ヒントは `[String]` や `(String)` のように角括弧/丸括弧で指定できます。複数型は `(String, nil)` のように列挙してください。 +注釈が部分的な場合の推論規則: -`VALUE...` のような繰り返し指定(`TAG...` など)や、`[String[]]` / `Array` といった配列型の注釈が付いたオプションは配列として扱われます。JSON/YAML 形式のリスト(例: `--tags '["build","test"]'`)を渡すか、カンマ区切り文字列(`--tags "build,test"`)を渡すことで配列に変換されます。スペース区切りの複数値入力(`--tags build test`)にはまだ対応しておらず、繰り返し注記のないオプションは従来どおりスカラーとして扱われます。`--strict` 実行時は各要素の型も検証されるため、`[String[]]` と書かれているのに `--tags [1,2]` のような数値配列を渡すと即エラーになります。 +- `ARG1` のように型を省略したプレースホルダは `String` として扱われます。 +- 値プレースホルダのないオプション(`--verbose`)は Boolean フラグになります。 +- 位置引数を Boolean にするには `[Boolean]` の明示が必要です。`NAME 説明` のように + 型を省略すると、Ruby 側のデフォルト値に関わらず `String` とみなされます。 -JSON やカンマ区切りで表現しづらいシンボル配列・ハッシュなどを渡したい場合は eval モード(`--eval-args`/`-e` または `--eval-lax`/`-E`)を有効にし、ドキュメントで宣言した型に合わせた Ruby リテラルを渡してください。スペース区切りが未対応でも、安全に複数選択を指定できます(後述の eval 例を参照)。 +### 配列と繰り返し値 -代表的な推論例: +`TAG...` のような繰り返し指定、または `[String[]]` / `Array` のような配列型注釈が +付いたオプションは配列としてパースされます。JSON/YAML 形式のリスト +(`--tags '["build","test"]'`)とカンマ区切り文字列(`--tags "build,test"`)の両方を +受け付けます。スペース区切りの複数値(`--tags build test`)には対応しておらず、 +繰り返し注記のないオプションはスカラーのままです。`--strict` 実行時は各要素の型も +検証されるため、`[String[]]` と書かれた注釈に対して `--tags [1,2]` を渡すとエラーになります。 -- `ARG1` のように型ラベルを省略したプレースホルダは既定で `String` として扱われます。 -- `--name ARG1` のようにオプションへプレースホルダだけを指定しても同じく `String` が推論されます。 -- `--verbose` のように値プレースホルダを省略したオプションは Boolean フラグとして扱われます。 -- 位置引数を Boolean にしたい場合は必ず `[Boolean]` を明示してください。`NAME 説明` や `@param name 説明` のように型を省略すると、Ruby 側のデフォルト値に関わらず `String` とみなされます。 +### リテラル列挙(enum) -### リテラル列挙による制約 +許容値の集合を型注釈の中に直接書けます: `--format MODE [:json, :yaml, :auto]`、 +`LEVEL [:info, :warn]` など。シンボル・文字列(裸の単語も可)・真偽値・数値・`nil` に対応し、 +`--channel TARGET [:stdout, :stderr, Boolean]` のように通常の型とも混在できます。 +`%i[info warn]` / `%w[debug info]` の短縮記法も展開されます。選択肢は常にヘルプへ表示され、 +許可外の値は `--strict` なしなら警告のみで続行、`--strict` 付きなら中断します。 -`--format MODE [:json, :yaml, :auto]` や `LEVEL [:info, :warn]` のように型注釈内へ許容リテラルを列挙すると、ヘルプに選択肢を表示しつつ Rubycli が入力制約として解釈します。シンボル・文字列(裸の単語も可)・真偽値・数値・`nil` に対応し、型ヒントと混在させて `--channel TARGET [:stdout, :stderr, Boolean]` のような宣言も書けます。`%i[info warn]` / `%w[debug info]` などの短縮記法も展開されるため、`LEVEL %i[info warn]` でも同じ効果になります。通常実行では許可外の入力に警告を表示して続行し、`--strict` を付けた場合は `Rubycli::ArgumentError` を送出して即座に停止します。 - -> シンボルと文字列は厳密に区別されます。`[:info, :warn]` と書いた場合は `:info` のようにコロン付きで入力してください。`["info", "warn"]` を選んだ場合はプレーンな文字列のみ受け付けます。 - -> 列挙は各スカラー値に適用されます。`[Symbol[]]` のような配列注釈に対して「許可される組み合わせ」をリテラルで書く構文(例: `[%i[foo bar][]]`)は未サポートなので、必要に応じて文章で説明するか、eval モードで Ruby の配列を渡してください。 +シンボルと文字列は厳密に区別されます。`[:info, :warn]` にはコロン付きの `:info` を、 +`["info", "warn"]` にはプレーンな文字列を入力してください。 ```bash -# literal choice デモ (examples/strict_choices_demo.rb) -ruby examples/strict_choices_demo.rb report warn --format json -#=> [WARN] format=json +# examples/strict_choices_demo.rb — LEVEL の注釈は [:info, :warn, :error] +rubycli examples/strict_choices_demo.rb report :warn --format json +#=> [WARN] format=json (続けて戻り値のハッシュが表示される) -# --strict を付けると仕様外の値で即エラー -ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report debug -#=> Rubycli::ArgumentError: Value "debug" for LEVEL is not allowed: allowed values are :info, :warn, :error -``` +# 裸の文字列はシンボルと一致しない: 警告して続行 +rubycli examples/strict_choices_demo.rb report warn +#=> [WARN] LEVEL must be one of :info, :warn, :error (received "warn") (use --strict to abort on invalid input) -```bash -# シンボル入力はコロンを付ける -ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report :warn -#=> [WARN] format=text - -ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report warn -#=> Rubycli::ArgumentError: Value "warn" for LEVEL is not allowed: allowed values are :info, :warn, :error +# --strict を付けると許可外の入力で中断 +rubycli --strict examples/strict_choices_demo.rb report debug +#=> [ERROR] LEVEL must be one of :info, :warn, :error (received "debug") ``` -### 標準ライブラリ型ヒント +列挙は各スカラー引数に適用されます。`[%i[foo bar][]]` のような「配列の許容組み合わせ」を +リテラルで書く構文は未サポートです。 + +### 標準ライブラリの型ヒント -コメントに `Date` や `Time`, `BigDecimal`, `Pathname` など標準ライブラリの型名を書けば、Rubycli が必要な `require` を行った上で CLI 引数をその型へ変換します。 +コメントに `Date`、`Time`、`BigDecimal`、`Pathname` などの標準クラスを書くと、 +Rubycli が必要な stdlib を読み込んだ上で CLI 入力をその型へ変換します。 +ハンドラには実際のオブジェクトが渡るため、追加のパース処理は不要です。 ```bash -# examples/typed_arguments_demo.rb より -ruby examples/typed_arguments_demo.rb ingest \ +# examples/typed_arguments_demo.rb を参照 +rubycli examples/typed_arguments_demo.rb ingest \ --date 2024-12-25 \ --moment 2024-12-25T10:00:00Z \ --budget 123.45 \ --input ./data/input.csv ``` -ハンドラ側には `Date` / `Time` / `BigDecimal` / `Pathname` のインスタンスがそのまま渡るため、追加のパース処理は不要です。 +各オプションには既定値があるため、`... ingest --budget 999.99` のように +1 つずつ試すこともできます。 -各オプションには既定値があるため、`ruby examples/typed_arguments_demo.rb ingest --budget 999.99` のように個別の型だけ試すこともできます。 +`@example`、`@raise`、`@see`、`@deprecated` などその他の YARD タグは、 +現状ヘルプ出力には反映されません。 -`@example` や `@raise`, `@see`, `@deprecated` などその他の YARD タグは、現状ヘルプ出力には反映されません。 +> すべての記法をまとめて試すには +> `rubycli examples/documentation_style_showcase.rb canonical --help` などの +> showcase コマンドを実行してください。 -> すべての記法をまとめて試したい場合は `rubycli examples/documentation_style_showcase.rb canonical --help` や `... angled --help` などを実行してみてください。 +### YARD 互換コメントを併用する際の注意 -従来の `@param` 記法も既定で利用できます。簡潔なプレースホルダ記法だけに限定したい場合は `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` を設定してください(厳格モードでの検証は継続されます)。 +- `**kwargs` を受け取るメソッドでも、キーは自動では公開されません。CLI で使わせたいキーは + すべて `--long-name PLACEHOLDER [Type] 説明` の行として明示してください。 +- `@param` 行に続く箇条書きや補足行は CLI 生成には使われません。補足情報は + オプションの説明文に含めてください。 +- 簡潔なプレースホルダ記法へ統一したい場合は `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` を + 設定します。`@param`/`@return` タグが警告扱いになり、段階的に移行できます。 -### コメントが不足している場合のフォールバック +### コメントが不足している場合 -Rubycli は常に実装中のメソッドシグネチャを信頼します。コメントに書いていない引数やオプションがあっても、定義そのものから名前や初期値を推論して CLI に表示します。 +Rubycli は常に実装のメソッドシグネチャを信頼します。コメントに書かれていない引数も、 +定義から名前・既定値・型を推論して CLI に公開されます。 ```ruby -# fallback_example.rb +# examples/fallback_example.rb module FallbackExample module_function - # AMOUNT [Integer] 処理対象の数値 + # AMOUNT [Integer] Base amount to process def scale(amount, factor = 2, clamp: nil, notify: false) result = amount * factor result = [result, clamp].min if clamp @@ -383,20 +357,6 @@ module FallbackExample end ``` -```bash -rubycli examples/fallback_example.rb -``` - -```text -Usage: fallback_example.rb COMMAND [arguments] - -Available commands: - Class methods: - scale AMOUNT [] [--clamp=] [--notify] - -Detailed command help: fallback_example.rb COMMAND help -``` - ```bash rubycli examples/fallback_example.rb scale --help ``` @@ -405,52 +365,51 @@ rubycli examples/fallback_example.rb scale --help Usage: fallback_example.rb scale AMOUNT [FACTOR] [--clamp=] [--notify] Positional arguments: - AMOUNT [Integer] required 処理対象の数値 - FACTOR optional (default: 2) + AMOUNT [Integer] required Base amount to process + FACTOR [String] optional (default: 2) Options: --clamp= [String] optional (default: nil) --notify [Boolean] optional (default: false) ``` -`AMOUNT` だけがドキュメント化されていますが、`factor` や `clamp`, `notify` も自動的に補完され、既定値や型が推論されていることがわかります。開発時は `rubycli --check 対象.rb` でコメントとシグネチャの矛盾を検出し、本番実行で `--strict` を付ければ仕様外の入力をその場で弾けます。 - -#### 存在しない引数やオプションをコメントに書いた場合 - -- **整合しないコメントは詳細テキストへフォールバック** – 実装に存在しないオプション(例: `--ghost`)や位置引数(例: `EXTRA`)を記述すると、その行はヘルプ末尾の詳細セクションに素のテキストとして表示され、実際の引数としては機能しません。厳格モードなら `Extra positional argument comments were found: EXTRA` のような警告が出て、位置引数のズレにも気付きやすくなります。 - -> 実際に確認したい場合は `rubycli examples/fallback_example_with_extra_docs.rb scale --help` を試してみてください。 +ドキュメント化されているのは `AMOUNT` だけですが、`factor`・`clamp`・`notify` も +推論された既定値・型付きで表示されます。 -コメントだけでは実装を拡張できません。メソッドシグネチャとコメントを一致させておくことで、ヘルプと挙動の整合性を保てます。 +コメントだけで実引数が増えることはありません。実装に存在しないオプション(例: `--ghost`)や +位置引数をコメントに書いた場合、その行はヘルプ末尾の詳細セクションに素のテキストとして +表示されるだけで、strict モードでは位置引数のズレに対する警告も出ます。動作するデモ: +`rubycli examples/fallback_example_with_extra_docs.rb scale --help` -### YARD 互換コメントを併用する際の注意点 +開発中は `rubycli --check 対象.rb` でコメントと実装のズレ(未定義の型ラベルや列挙値の +誤記を含む。DidYouMean の候補付き)を検出し、実行時に `--strict` を付ければ仕様外の +入力を警告ではなくエラーにできます。 -- `**kwargs` を受け取るメソッドでも、Rubycli は個別のキーワードコメント(`--config-path ...` など)が無い限りヘルプへ露出させません。CLI で使わせたいキーはすべて `--LONG-NAME PLACEHOLDER [Type] 説明` の行として明示してください。 -- `@param` で位置引数を記述した場合も解析できますが、位置引数・キーワード引数を同じ行形式で列挙する必要があります。`@param source Path` のように書いても、キーワード向けのロングオプションが自動生成されるわけではありません。 -- `@param` の行に続く箇条書きや補足行は CLI の自動生成には使われません。補足情報を表示したい場合は、`--flag ...` 行の説明に含めるか、README など別のドキュメントで扱ってください。 -- `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` にすると `@param`/`@return` などのタグは警告扱いになります。プロジェクト内で簡潔記法へ統一するときはこの環境変数で段階的に移行できます。 +> `--strict` はコメントに書かれた型・許容値をそのまま信頼します。コメント自体の誤記は +> 実行時には検出できないため、CI で `rubycli --check` を回した上で `--strict` を +> 使ってください。 ## 引数解析モード ### 既定のリテラル解析 -Rubycli は `{` や `[`、クォート、YAML の先頭記号といった「構造化リテラルらしい」形の引数に対して `Psych.safe_load` を試み、成功すれば Ruby の配列/ハッシュ/真偽値に変換してからメソッドへ渡します。たとえば `--names='["Alice","Bob"]'` や `--config='{foo: 1}'` のような値は追加フラグ無しでネイティブな配列・ハッシュとして届きます。一方、プレーンな `1,2,3` のような文字列はこの段階ではそのまま維持されます(コメントで `String[]` や `TAG...` と宣言されている場合は後段で配列に整形されます)。扱えない形式は自動的に文字列へフォールバックするため、`"2024-01-01"` のような値もそのまま文字列で受け取れますし、構文が崩れていても CLI 全体が落ちることはありません。 - -### JSON モード - -CLI 実行時に `--json-args`(短縮形 `-j`)を付けると、後続の引数が厳格に JSON として解釈されます。 - -```bash -rubycli -j my_cli.rb MyCLI run '["--config", "{\"foo\":1}"]' -``` +`{`、`[`、クォート、YAML 記号で始まる「構造化リテラルらしい」引数は `Psych.safe_load` で +解釈され、`--names='["Alice","Bob"]'` や `--config='{foo: 1}'` は追加フラグなしで +ネイティブな配列・ハッシュとして届きます。`1,2,3` のようなプレーンな文字列はこの段階では +そのまま維持され(コメントで `String[]` や `TAG...` と宣言されていれば後段で配列化)、 +解釈できない形式は元の文字列にフォールバックします。`"2024-01-01"` は文字列のまま届き、 +構文が崩れた入力でも実行全体は落ちません。 -YAML 固有の書き方は拒否され、無効な JSON であれば `JSON::ParserError` が発生するため、入力の妥当性を強く保証したいときに便利です。プログラム側では `Rubycli.with_json_mode(true) { … }` で有効化できます。 +### JSON モード(`--json-args` / `-j`) -### Eval モード +後続の引数を厳格に JSON として解釈します。YAML 固有の記法は拒否され、無効な JSON は +`JSON::ParserError` になるため、silent fallback ではなく明示的な失敗が欲しい場合に +便利です。プログラムからは `Rubycli.with_json_mode(true) { ... }` で切り替えられます。 -`--eval-args`(短縮形 `-e`)を使うと、後続の引数を Ruby コードとして評価した結果を CLI に渡せます。JSON や YAML では表現しづらいオブジェクトを扱いたいときに便利ですが、評価は `Object.new.instance_eval { binding }` 上で行われるため、信頼できる入力に限定してください。コード内では `Rubycli.with_eval_mode(true) { … }` で切り替えられます。 +### Eval モード(`--eval-args` / `-e`、`--eval-lax` / `-E`) -Ruby 評価はシンボルや配列/ハッシュもそのまま扱えるため、列挙値の組み合わせをオプションへ渡すときにも役立ちます。 +各引数を Ruby 式として評価してから渡します。シンボル配列・Range・インライン計算など、 +JSON では書きにくい値に便利です。 ```bash rubycli -E scripts/report_runner.rb publish \ @@ -458,56 +417,99 @@ rubycli -E scripts/report_runner.rb publish \ --channels '[:email, :slack]' ``` -Ruby 評価を使いつつ、構文エラーが出たときは元の文字列にフォールバックさせたい場合は `--eval-lax`(短縮形 `-E`)を指定します。`--eval-args` と同じく eval モードを有効にしますが、Ruby として解釈できなかったトークン(例: 素の `https://example.com`)は警告を出した上でそのまま渡すため、`60*60*24*14` のような式と文字列を気軽に混在させられます。 +評価は隔離された binding(`Object.new.instance_eval { binding }`)内で行われますが、 +入力そのものは信頼できる呼び出し元に限定してください。プログラムからは +`Rubycli.with_eval_mode(true) { ... }` で切り替えられます。 -`--json-args`/`-j` は `--eval-args`/`-e` および `--eval-lax`/`-E` と同時指定できません。どのモードも既定のリテラル解析を拡張する位置づけなので、用途に応じて厳格な JSON か Ruby eval(通常/lax)のいずれかを選択してください。 +`--eval-lax` / `-E` は `--eval-args` と同様に eval モードを有効にしつつ、Ruby として +解釈できなかったトークン(例: 素の `https://example.com`)は警告を出して元の文字列の +まま渡します。`60*60*24*14` のような式と通常の文字列を混在させたいときに便利です。 + +`--json-args` と eval 系フラグは同時指定できません(両方あるとエラーになります)。 ## Pre-script ブートストラップ -付属 CLI を起動するときに `--pre-script SRC`(別名: `--init`)を指定すると、公開メソッドを呼び出す前に任意の Ruby コードを評価できます。評価は隔離された binding 内で行われ、以下のローカル変数があらかじめ用意されています。 +`--pre-script SRC`(別名: `--init`)を付けると、コマンド解決の前に任意の Ruby コードを +評価できます。評価は隔離された binding 内で行われ、次のローカル変数が用意されています。 -- `target` – `--new` を適用する前のクラス/モジュール -- `current` / `instance` – 現在公開予定のオブジェクト(`--new` を指定した場合は生成済みインスタンス) +- `target` — 元のクラス/モジュール(`--new` 適用前) +- `current` / `instance` — そのまま公開される予定のオブジェクト -スクリプトの最後に評価された値が新しい公開対象になります。`nil` を返した場合は直前のオブジェクトを維持します。 +最後に評価された値が新しい公開対象になります(`nil` を返すと直前のオブジェクトを維持)。 +`SRC` にはインラインの Ruby コードとファイルパスのどちらも指定できます。 -インラインで書く例: +実行例 — `--new` で作られるインスタンスを、自分で組み立てたものに差し替える: ```bash -rubycli --pre-script 'InitArgRunner.new(source: "cli", retries: 2)' \ - lib/init_arg_runner.rb summarize --verbose +rubycli --new='["a"]' \ + --pre-script 'NewModeRunner.new(%w[a b c], options: {from: :pre})' \ + examples/new_mode_runner.rb run --mode summary ``` -ファイルに切り出す例: +## フラグと環境変数 -```ruby -# scripts/bootstrap_runner.rb -instance = InitArgRunner.new(source: "preset") -instance.logger = Logger.new($stdout) -instance -``` +| フラグ / 環境変数 | 説明 | 既定値 | +| ---------------- | ---- | ------ | +| `--auto-target` / `-a`, `RUBYCLI_AUTO_TARGET=auto` | ファイル名と定数名が一致しないときに自動選択 | `strict` | +| `--new[=VALUE]` | コマンド解決前にインスタンス化。`VALUE` はコンストラクタ引数 | off | +| `--pre-script SRC` / `--init SRC` | 公開対象オブジェクトを Ruby コードで構築・差し替え | off | +| `--check` | コメントと実装のズレを検査(コマンドは実行しない) | off | +| `--strict` | ドキュメントの型・許容値を強制。仕様外入力はエラー | off | +| `--json-args` / `-j` | 引数を厳格に JSON として解釈 | off | +| `--eval-args` / `-e`, `--eval-lax` / `-E` | 引数を Ruby として評価(lax は失敗時に素の文字列へフォールバック) | off | +| `RUBYCLI_DEBUG=true` | デバッグログを表示 | `false` | +| `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` | YARD `@param` 行を無効化(互換性のため既定は有効) | `ON` | -```bash -rubycli --pre-script scripts/bootstrap_runner.rb \ - lib/init_arg_runner.rb summarize --verbose -``` +## ライブラリ API + +- `Rubycli.parse_arguments(argv, method)` — コメント情報を考慮した引数解析 +- `Rubycli.available_commands(target)` — 公開 CLI コマンド一覧 +- `Rubycli.usage_for_method(name, method)` — 指定メソッドのヘルプ生成 +- `Rubycli.method_description(method)` — 構造化されたドキュメント取得 + +## Python Fire との違い + +- **コメント対応のヘルプ生成** — コメントはヘルプを豊かにしますが、最終的な判断は常に + ライブなメソッド定義に基づきます。 +- **型に基づく解析** — プレースホルダ記法と YARD タグから、真偽値・配列・数値などへ + 追加コードなしで変換します。 +- **二段構えの検証** — `--check` はコマンドを実行せずにドキュメントのズレを lint し、 + `--strict` はドキュメントの型・許容値を実行時の契約として強制します。 +- **Ruby 中心の設計** — キーワード引数、ブロックドキュメント(`@yield*` タグ)、 + `RUBYCLI_*` 環境変数に対応します。 + +| 機能 | Python Fire | Rubycli | +| ---- | ----------- | ------- | +| 属性の辿り方 | プロパティ/属性を再帰的に自動公開 | 対象の公開メソッドのみ公開(暗黙の辿りなし) | +| クラス初期化 | `__init__` 引数を自動で受け取る | `--new` 指定時のみ初期化。引数は `--new=VALUE`、複雑な構築は pre-script | +| インタラクティブシェル | コマンド未指定時に Fire REPL | なし。コマンド実行専用 | +| 情報源 | 純粋なリフレクション | ライブなメソッド定義 + コメントをヘルプへ反映 | +| 辞書/配列 | dict/list を自動でサブコマンド化 | クラス/モジュールのメソッドに特化(自動展開なし) | + +## 開発方針 -この仕組みを使えば、`--new` のシンプルさを保ったまま、DI 風の初期化やラッパーオブジェクトの準備といった高度な前処理を CLI で行えます。 +- **便利さを最優先** — 既存の Ruby スクリプトを最小の手間で CLI 化することが目的です。 + Python Fire との機能一致は目標ではなく、Fire 由来の未実装機能は基本的に仕様です。 +- **メソッド定義が土台、コメントが補強** — 公開範囲と必須/任意はシグネチャが決め、 + コメントは型・ヘルプ・検証を補強します。 +- **軽量メンテナンス** — 実装の多くは AI 支援で作られており、深い Ruby メタプログラミングに + 踏み込む拡張は想定外です。互換性追求の PR は事前にご相談ください。 -## 環境変数とフラグ +## 同梱サンプル -| 変数 / フラグ | 説明 | 既定値 | -| ------------- | ---- | ------ | -| `RUBYCLI_DEBUG=true` | デバッグログ表示 | `false` | -| `--check` | コメント/実装のズレを検査し、コマンドは実行しない | `off` | -| `--strict` | ドキュメントで許可した型・値以外をエラーとして拒否 | `off` | -| `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` | レガシーな `@param` 記法を無効化(互換性のため既定では ON) | `ON` | +- `examples/hello_app.rb` / `examples/hello_app_with_docs.rb` — 最小のモジュール関数、 + ドキュメントなし/あり +- `examples/hello_app_with_require.rb` — `Rubycli.run` の組み込み +- `examples/typed_arguments_demo.rb` — 標準ライブラリ型の変換 + (Date/Time/BigDecimal/Pathname) +- `examples/strict_choices_demo.rb` — リテラル列挙と `--strict` +- `examples/new_mode_runner.rb` — `--new=VALUE` で初期化するインスタンス専用クラス +- `examples/documentation_style_showcase.rb` — 全コメント記法のショーケース +- `examples/fallback_example.rb` / `examples/fallback_example_with_extra_docs.rb` + — シグネチャからの補完とコメント不一致のデモ -## Rubycli API +## ライセンス -- `Rubycli.parse_arguments(argv, method)` – コメント情報を考慮した引数解析 -- `Rubycli.available_commands(target)` – 公開 CLI コマンド一覧 -- `Rubycli.usage_for_method(name, method)` – 指定メソッドのヘルプ生成 -- `Rubycli.method_description(method)` – 構造化されたドキュメント取得 +MIT。[LICENSE](LICENSE) を参照してください。 -ご意見・フィードバックは Issue や Pull Request でお寄せください。 +ご意見・不具合報告は Issue や Pull Request でお寄せください。 diff --git a/README.md b/README.md index 47fcb52..7bb2605 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,39 @@ ![Rubycli logo](assets/rubycli-logo.png) -Rubycli turns existing Ruby classes and modules into CLIs by inspecting their public method definitions and the doc comments attached to those methods. It is inspired by [Python Fire](https://github.com/google/python-fire) but is not a drop-in port or an official project; the focus here is Ruby’s documentation conventions and type annotations, and those annotations can actively change how a CLI argument is coerced (for example, `TAG... [String[]]` forces array parsing). +[![Gem Version](https://img.shields.io/gem/v/rubycli)](https://rubygems.org/gems/rubycli) -> 🇯🇵 Japanese documentation is available in [README.ja.md](README.ja.md). +Rubycli turns existing Ruby classes and modules into command-line interfaces. +It inspects public method definitions and the doc comments attached to them, so +in the simplest case your script needs no changes at all — not even +`require "rubycli"`. Type annotations in comments are not just documentation: +they drive how CLI arguments are parsed (for example, `TAG... [String[]]` +forces array parsing). + +Rubycli is inspired by [Python Fire](https://github.com/google/python-fire) but +is not a port or an official project; the focus is Ruby's documentation +conventions and type annotations. + +> 🇯🇵 Japanese documentation: [README.ja.md](README.ja.md) ![Rubycli demo showing generated commands and invocation](assets/rubycli-demo.gif) -### 1. Existing Ruby script (Rubycli unaware) +## Installation + +```bash +gem install rubycli +``` + +```ruby +# Gemfile +gem "rubycli" +``` + +Requires Ruby 3.0 or later. Licensed under [MIT](LICENSE). + +## Quick start + +### 1. Run an existing script as-is ```ruby # hello_app.rb @@ -21,7 +47,8 @@ module HelloApp end ``` -> Try it yourself: this repository ships with `examples/hello_app.rb`, so from the project root you can run `rubycli examples/hello_app.rb` to explore the generated commands. +This repository ships the same file as `examples/hello_app.rb`, so you can try +everything below from the project root. ```bash rubycli examples/hello_app.rb @@ -32,11 +59,13 @@ Usage: hello_app.rb COMMAND [arguments] Available commands: Class methods: - greet + greet NAME Detailed command help: hello_app.rb COMMAND help ``` +Missing arguments produce a usage message instead of a stack trace: + ```bash rubycli examples/hello_app.rb greet ``` @@ -54,16 +83,16 @@ rubycli examples/hello_app.rb greet Hanako #=> Hello, Hanako! ``` -Running `rubycli examples/hello_app.rb --help` prints the same summary as invoking it without a command. +`rubycli examples/hello_app.rb --help` prints the same summary as invoking it +without a command. -### 2. Add documentation hints for richer flags +### 2. Add doc comments for typed options -> Still no `require "rubycli"` needed; comments alone drive option parsing and help text. - -**Concise placeholder style** +Still no `require "rubycli"` needed; comments alone drive option parsing and +help text. Both the concise placeholder style and YARD-style tags work: ```ruby -# hello_app.rb +# Concise placeholder style module HelloApp module_function @@ -77,10 +106,8 @@ module HelloApp end ``` -**YARD-style tags work too** - ```ruby -# hello_app.rb +# YARD-style tags module HelloApp module_function @@ -94,24 +121,13 @@ module HelloApp end ``` -> The documented variant lives at `examples/hello_app_with_docs.rb` if you want to follow along locally. - -```bash -rubycli examples/hello_app_with_docs.rb -``` - -```text -Usage: hello_app_with_docs.rb COMMAND [arguments] - -Available commands: - Class methods: - greet [--shout] - -Detailed command help: hello_app_with_docs.rb COMMAND help -``` +The documented variant lives at `examples/hello_app_with_docs.rb`. Its file +name does not match the constant it defines (`HelloApp`), so pass +`--auto-target` / `-a` or name the constant explicitly — see +[Target constant resolution](#target-constant-resolution) below. ```bash -rubycli examples/hello_app_with_docs.rb greet --help +rubycli -a examples/hello_app_with_docs.rb greet --help ``` ```text @@ -125,11 +141,11 @@ Options: ``` ```bash -rubycli examples/hello_app_with_docs.rb greet --shout Hanako +rubycli -a examples/hello_app_with_docs.rb greet --shout Hanako #=> HELLO, HANAKO! ``` -Need to keep a helper off the CLI? Define it as `private` on the singleton class: +To keep a helper off the CLI, define it as `private` on the singleton class: ```ruby module HelloApp @@ -143,95 +159,10 @@ module HelloApp end ``` -### 3. (Optional) Embed the runner inside your script - -Prefer to launch via `ruby ...` directly? Require the gem and delegate to `Rubycli.run` (see Quick start below for `examples/hello_app_with_require.rb`). - -```bash -ruby examples/hello_app_with_require.rb greet Hanako --shout -#=> HELLO, HANAKO! -``` - -## Constant resolution modes - -Rubycli assumes that the file name (CamelCased) matches the class or module you want to expose. When that is not the case you can choose how eagerly Rubycli should pick a constant: - -| Mode | How to enable | Behaviour | -| --- | --- | --- | -| `strict` (default) | do nothing / `RUBYCLI_AUTO_TARGET=strict` | Fails unless the CamelCase name matches. The error lists the detected constants and gives explicit rerun instructions. | -| `auto` | `--auto-target`, `-a`, or `RUBYCLI_AUTO_TARGET=auto` | If exactly one constant in that file defines CLI-callable methods, Rubycli auto-selects it; otherwise you still get the friendly error message. | - -This keeps large projects safe by default but still provides a one-flag escape hatch when you prefer the fully automatic behaviour. - -> **Instance-only classes** – If a class only defines public *instance* methods (for example, it exposes functionality via `def greet` on the instance), you must run Rubycli with `--new` so the class is instantiated before commands are resolved. Otherwise Rubycli cannot see any CLI-callable methods. Add at least one public class method when you do not want to rely on `--new`. Passing `--new` also makes those instance methods appear in `rubycli --help` output and allows `rubycli --check --new` to lint their documentation. When your constructor needs arguments, pass them inline with `--new=VALUE` (safe YAML/JSON-like parsing by default; `--json-args` for strict JSON, `--eval-args` / `--eval-lax` for Ruby literals). Any comments on `initialize` are respected for type coercion just like regular CLI methods. - -> Hint: Single values should be passed as `--new=value` so they aren’t mistaken for the next path/command. Space-separated single tokens like `--new 1` may be treated as the following path unless they look obviously structured. - -## Project Philosophy - -- **Convenience first** – The goal is to wrap existing Ruby scripts in a CLI with almost no manual plumbing. Fidelity with Python Fire is not a requirement. -- **Inspired, not a port** – We borrow ideas from Python Fire, but we do not aim for feature parity. Missing Fire features are generally “by design.” -- **Method definitions first, comments augment behavior** – Public method signatures determine what gets exposed (and which arguments are required), while doc comments like `TAG...` or `[Integer]` can turn the very same CLI value into arrays, integers, booleans, etc. Rubycli also auto-parses inputs that look like JSON/YAML literals (for example `--names='["Alice","Bob"]'`) before enforcing the documented type. Run `rubycli --check path/to/script.rb` to lint documentation mismatches—including undefined type labels or enumerated values, with DidYouMean suggestions for `Booalean`-style typos—and pass `--strict` during normal runs when you want invalid input to abort instead of merely warning. -- **Lightweight maintenance** – Much of the implementation was generated with AI assistance; contributions that diverge into deep Ruby metaprogramming are out of scope. Please discuss expectations before opening parity PRs. - -## Features - -- Comment-aware CLI generation with both YARD-style tags and concise placeholders -- Automatic option signature inference (`NAME [Type] Description…`) without extra DSLs -- Safe literal parsing out of the box (arrays / hashes / booleans) with opt-in strict JSON and Ruby eval modes -- Optional pre-script hook (`--pre-script` / `--init`) to evaluate Ruby and expose the resulting object -- Dedicated CLI flags for quality gates: `--check` lints documentation/comments without running commands, and `--strict` treats documented types/choices as hard requirements -- Example `examples/new_mode_runner.rb` demonstrates instance-only classes with `--new=VALUE` constructor arguments, eval/JSON modes, and a pre-script initialization pattern. - -### Examples - -- `examples/hello_app.rb` / `examples/hello_app_with_docs.rb`: minimal module-function variants, with and without docs -- `examples/typed_arguments_demo.rb`: stdlib type coercions (Date/Time/BigDecimal/Pathname) -- `examples/strict_choices_demo.rb`: literal enumerations and `--strict` -- `examples/new_mode_runner.rb`: instance-only class initialized via `--new=VALUE` with eval/JSON/pre-script combinations - -> Tip: `--strict` trusts whatever types/choices your comments spell out—if the annotations are misspelled, runtime enforcement has nothing reliable to compare against. Keep `rubycli --check` in CI so documentation typos are caught before production runs that rely on `--strict`. - -## How it differs from Python Fire - -- **Comment-aware help** – Rubycli leans on doc comments when present but still reflects the live method signature, keeping code as the ultimate authority. -- **Type-aware parsing** – Placeholder syntax (`NAME [String]`) and YARD tags let Rubycli coerce arguments to booleans, arrays, numerics, etc. without additional code. -- **Strict validation** – `rubycli --check` lint runs catch documentation drift (including undefined type labels or enumerated values) without executing commands, while runtime `--strict` runs turn those documented types/choices into enforceable contracts. -- **Ruby-centric tooling** – Supports Ruby-specific conventions such as optional keyword arguments, block documentation (`@yield*` tags), and `RUBYCLI_*` environment toggles. - -| Capability | Python Fire | Rubycli | -| ---------- | ----------- | -------- | -| Attribute traversal | Recursively exposes attributes/properties on demand | Exposes public methods defined on the target; no implicit traversal | -| Constructor handling | Automatically prompts for `__init__` args when instantiating classes | `--new` instantiates and accepts constructor arguments via `--new=VALUE` (safe YAML/JSON-like parsing by default; `--json-args` for strict JSON, `--eval-args` / `--eval-lax` for Ruby literals). Use pre-scripts or your own factories for more complex wiring. | -| Interactive shell | Offers Fire-specific REPL when invoked without command | No interactive shell mode; strictly command execution | -| Input discovery | Pure reflection, no doc comments required | Doc comments drive option names, placeholders, and validation | -| Data structures | Dictionaries / lists become subcommands by default | Focused on class or module methods; no automatic dict/list expansion | - -#### Example commands - -- `rubycli examples/new_mode_runner.rb run --new='["a","b","c"]' --mode reverse` -- `rubycli --json-args --new='["x","y"]' examples/new_mode_runner.rb run --mode summary --options '{"source":"json"}'` -- `rubycli --eval-args --new='["x","y"]' examples/new_mode_runner.rb run --mode summary --options '{tags: [:a, :b]}'` -- `rubycli --pre-script 'NewModeRunner.new(%w[a b c], options: {from: :pre})' examples/new_mode_runner.rb run --mode summary` - -## Installation - -Rubycli is published on RubyGems. - -```bash -gem install rubycli -``` - -Bundler example: - -```ruby -# Gemfile -gem "rubycli" -``` - -## Quick start (embed Rubycli in the script) +### 3. Optional: embed the runner in your script -Step 3 adds `require "rubycli"` so the script can invoke the CLI directly (see `examples/hello_app_with_require.rb`): +If you prefer launching via plain `ruby`, require the gem and delegate to +`Rubycli.run` (shipped as `examples/hello_app_with_require.rb`): ```ruby # hello_app_with_require.rb @@ -254,131 +185,181 @@ end Rubycli.run(HelloApp) ``` -Run it: - ```bash -ruby examples/hello_app_with_require.rb greet Taro -#=> Hello, Taro! - ruby examples/hello_app_with_require.rb greet Taro --shout #=> HELLO, TARO! ``` -To launch the same file without adding `require "rubycli"`, use the bundled executable: +When you run a file through the bundled `rubycli` executable instead, return +values are printed automatically. -```bash -rubycli path/to/hello_app.rb greet --shout Hanako -``` +## Target constant resolution -When you omit `CLASS_OR_MODULE`, Rubycli now infers it from the file name and even locates nested constants such as `Module1::Inner::Runner`. Return values are printed by default when you run the bundled CLI. +Rubycli assumes that the file name (CamelCased) matches the class or module you +want to expose. When it does not, choose how eagerly Rubycli should pick a +constant: -Need to target a different constant explicitly? Provide it after the file path: +| Mode | How to enable | Behaviour | +| --- | --- | --- | +| `strict` (default) | nothing / `RUBYCLI_AUTO_TARGET=strict` | Fails unless the CamelCase name matches. The error lists the detected constants and shows how to rerun. | +| `auto` | `--auto-target` / `-a` / `RUBYCLI_AUTO_TARGET=auto` | If exactly one constant in the file defines CLI-callable methods, it is selected automatically. | + +You can always name the constant explicitly after the file path — useful when a +file defines several candidates or a nested constant: ```bash rubycli scripts/multi_runner.rb Admin::Runner list --active ``` -This is useful when a file defines multiple candidates or when you want a nested constant that does not match the file name. +Nested constants such as `Module1::Inner::Runner` are found as well. + +## Instance-only classes and `--new` + +If a class only defines public *instance* methods, run Rubycli with `--new` so +the class is instantiated before commands are resolved; otherwise Rubycli sees +no CLI-callable methods. + +- `--new` also makes instance methods appear in `--help` output, and lets + `rubycli --check --new` lint their documentation. +- When the constructor needs arguments, pass them with `--new=VALUE` **before + the file path**. Values are parsed as safe YAML/JSON-like literals, and + comments on `initialize` drive type coercion just like regular CLI methods. +- Prefer the `--new=VALUE` form over a space-separated `--new VALUE`, so the + value is not mistaken for the file path. + +Example (`examples/new_mode_runner.rb`): + +```bash +rubycli --new='["a","b","c"]' examples/new_mode_runner.rb run --mode reverse +#=> ["c", "b", "a"] +``` ## Comment syntax -Rubycli parses a hybrid format – you can stick to familiar YARD tags or use short forms. +Rubycli parses a hybrid format — familiar YARD tags or short forms: | Purpose | YARD-compatible | Rubycli style | | ------- | --------------- | ------------- | | Positional argument | `@param name [Type] Description` | `NAME [Type] Description` | -| Keyword option | Same as above | `--flag -f VALUE [Type] Description` | +| Keyword option | same | `--flag -f VALUE [Type] Description` | | Return value | `@return [Type] Description` | `=> [Type] Description` | -Short options are optional and order-independent, so the following examples are equivalent in Rubycli’s default style: +Short options are optional and order-independent; these are equivalent: - `--flag -f VALUE [Type] Description` - `--flag VALUE [Type] Description` - `-f --flag VALUE [Type] Description` -Our examples keep the classic uppercase placeholders (`NAME`, `VALUE`) as the canonical style; the variations below are optional sugar. +Types can be written as `[String]` or `(String)`, and unions as +`(String, nil)`. ### Alternate placeholder notations -Rubycli also understands these syntaxes when parsing comments and rendering help: - -- Angle brackets for user input: `--flag ` or `NAME []` -- Inline equals for long options: `--flag=` -- Trailing ellipsis for repeated values: `VALUE...` or `...` - -The CLI treats `--flag VALUE`, `--flag `, and `--flag=` identically at runtime—document with whichever variant your team prefers. Optional placeholders like `[VALUE]` or `[VALUE...]` let Rubycli infer boolean flags, optional values, and list coercion. When you omit the placeholder entirely (for example `--quiet`), Rubycli infers a Boolean flag automatically. +These are understood both when parsing comments and when rendering help: -> Tip: You do not need to wrap optional arguments in brackets inside the comment. Rubycli already knows which parameters are optional from the Ruby signature and will introduce the brackets in generated help. +- Angle brackets: `--flag `, `NAME []` +- Inline equals: `--flag=` +- Trailing ellipsis for repeated values: `VALUE...`, `...` -You can annotate types using `[String]` or `(String)`—they both convey the same hint, and you can list multiple types such as `(String, nil)`. +At runtime `--flag VALUE`, `--flag `, and `--flag=` are +identical — document with whichever variant your team prefers. You do not need +to bracket optional arguments yourself: Rubycli already knows which parameters +are optional from the Ruby signature and adds the brackets in generated help. -Repeated values (`VALUE...`) now materialize as arrays automatically whenever the option is documented with an ellipsis (for example `TAG...`) or an explicit array type hint (`[String[]]`, `Array`). Supply either JSON/YAML list syntax (`--tags "[\"build\",\"test\"]"`) or a comma-delimited string (`--tags "build,test"`); Rubycli will coerce both forms to arrays. Space-separated multi-value flags (`--tags build test`) are still not supported, and options without a repeated/array hint continue to be parsed as scalars. Strict mode still verifies each element against the documented type, so `--tags [1,2]` will fail when the docs say `[String[]]`. +Inference rules when annotations are partial: -Need to pass structures that are awkward to express as JSON (for example symbol arrays or hashes)? Enable eval mode (`--eval-args`/`-e` or `--eval-lax`/`-E`) and supply a Ruby literal that matches the documented type; the example in the eval section below shows how to pass multiple enum selections safely even though space-separated syntax remains unsupported. +- A bare placeholder such as `ARG1` (no type) is treated as `String`. +- An option with no value placeholder (`--verbose`) becomes a Boolean flag. +- Positional arguments only become booleans with an explicit `[Boolean]`; + a bare `NAME Description` falls back to `String` regardless of the Ruby + default value. -Common inference rules: +### Arrays and repeated values -- Writing a placeholder such as `ARG1` (without `[String]`) makes Rubycli treat it as a `String`. -- Using that placeholder in an option line (`--name ARG1`) also infers a `String`. -- Omitting the placeholder entirely (`--verbose`) produces a Boolean flag. -- Positional arguments only become booleans when you annotate `[Boolean]`; a bare `NAME Description` (or `@param name Description`) falls back to `String`, regardless of the Ruby default value. +Options documented with an ellipsis (`TAG...`) or an array type +(`[String[]]`, `Array`) are parsed as arrays. Both JSON/YAML list +syntax (`--tags '["build","test"]'`) and comma-delimited strings +(`--tags "build,test"`) are accepted. Space-separated multi-value flags +(`--tags build test`) are not supported, and options without a repeated/array +hint stay scalars. `--strict` verifies each element against the documented +type, so `--tags [1,2]` fails when the docs say `[String[]]`. ### Literal choices and enums -You can express a finite set of accepted values directly inside the type annotation, for example `--format MODE [:json, :yaml, :auto]` or `LEVEL [:info, :warn]`. Symbols, strings (including barewords), booleans, numbers, and `nil` are supported, and you can mix literal entries with broader types such as `--channel TARGET [:stdout, :stderr, Boolean]`. `%i[info warn]` / `%w[debug info]` short-hands expand as expected, so `LEVEL %i[info warn]` works the same as the explicit array form. Rubycli always records these choices in the generated help; when you run with `--strict`, any value outside the documented set results in `Rubycli::ArgumentError`, otherwise a warning is printed and execution proceeds. +A finite set of accepted values can be written directly inside the type +annotation: `--format MODE [:json, :yaml, :auto]` or `LEVEL [:info, :warn]`. +Symbols, strings (including barewords), booleans, numbers, and `nil` are +supported; literals can be mixed with broader types +(`--channel TARGET [:stdout, :stderr, Boolean]`), and `%i[info warn]` / +`%w[debug info]` shorthands expand as expected. The choices always appear in +generated help; without `--strict` an out-of-range value only prints a warning, +with `--strict` it aborts. -> Symbols and strings are compared strictly. `[:info, :warn]` requires symbol inputs such as `:info`, while `["info", "warn"]` only accepts plain strings. Prefix a value with `:` at the CLI to pass a symbol. - -> Literal enums currently apply to each scalar argument. If an option is documented as an array (for example `[Symbol[]]`), spell out the allowed members in prose for now—combined literal arrays such as `[%i[foo bar][]]` are not supported. +Symbols and strings are compared strictly: `[:info, :warn]` requires symbol +input such as `:info` (prefix the value with `:` at the CLI), while +`["info", "warn"]` only accepts plain strings. ```bash -# literal choices + booleans (see examples/strict_choices_demo.rb) -ruby examples/strict_choices_demo.rb report warn --format json -#=> [WARN] format=json - -# the same command with --strict will abort when values drift -ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report debug -#=> Rubycli::ArgumentError: Value "debug" for LEVEL is not allowed: allowed values are :info, :warn, :error -``` +# see examples/strict_choices_demo.rb — LEVEL is documented as [:info, :warn, :error] +rubycli examples/strict_choices_demo.rb report :warn --format json +#=> [WARN] format=json (followed by the returned hash) -```bash -# symbol values stay distinct -ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report :warn -#=> [WARN] format=text +# a plain string is not the documented symbol: warn and continue +rubycli examples/strict_choices_demo.rb report warn +#=> [WARN] LEVEL must be one of :info, :warn, :error (received "warn") (use --strict to abort on invalid input) -ruby -Ilib exe/rubycli --strict examples/strict_choices_demo.rb report warn -#=> Rubycli::ArgumentError: Value "warn" for LEVEL is not allowed: allowed values are :info, :warn, :error +# with --strict, out-of-range input aborts +rubycli --strict examples/strict_choices_demo.rb report debug +#=> [ERROR] LEVEL must be one of :info, :warn, :error (received "debug") ``` +Literal enums currently apply to each scalar argument; combined literal arrays +such as `[%i[foo bar][]]` are not supported. + ### Standard library type hints -Doc comments can reference standard classes such as `Date`, `Time`, `BigDecimal`, or `Pathname`. Rubycli loads the necessary stdlib files on demand and coerces CLI inputs using the documented types. +Doc comments can reference standard classes such as `Date`, `Time`, +`BigDecimal`, or `Pathname`. Rubycli loads the required stdlib on demand and +coerces CLI inputs, so the handler receives real objects without manual +parsing: ```bash # see examples/typed_arguments_demo.rb -ruby examples/typed_arguments_demo.rb ingest \ +rubycli examples/typed_arguments_demo.rb ingest \ --date 2024-12-25 \ --moment 2024-12-25T10:00:00Z \ --budget 123.45 \ --input ./data/input.csv ``` -This command prints a normalized summary and the handler receives real `Date`, `Time`, `BigDecimal`, and `Pathname` objects without manual parsing. +Every option there has a default, so you can also experiment one at a time +(`... ingest --budget 999.99`). -Each option has a sensible default, so you can also experiment one at a time (for example `ruby examples/typed_arguments_demo.rb ingest --budget 999.99`). +Other YARD tags such as `@example`, `@raise`, `@see`, and `@deprecated` are +currently ignored by the help renderer. -Other YARD tags such as `@example`, `@raise`, `@see`, and `@deprecated` are currently ignored by the CLI renderer. +> To explore every notation in one script, try +> `rubycli examples/documentation_style_showcase.rb canonical --help` and the +> other showcase commands. -> Want to explore every notation in a single script? Try `rubycli examples/documentation_style_showcase.rb canonical --help`, `... angled --help`, or the other showcase commands. +### Notes on YARD-style comments -YARD-style `@param` annotations continue to work out of the box. If you want to enforce the concise placeholder syntax exclusively, set `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` (strict mode still applies either way). +- Methods that accept `**kwargs` do not expose those keys automatically; every + key you want on the CLI needs its own `--long-name PLACEHOLDER [Type] ...` + line. +- Bullet lists or free-form lines following a `@param` line are not used for + CLI generation; put supplementary text in the option's description instead. +- To enforce the concise placeholder syntax exclusively, set + `RUBYCLI_ALLOW_PARAM_COMMENT=OFF`; `@param`/`@return` tags then produce + warnings, which helps a gradual migration. ### When docs are missing or incomplete -Rubycli always trusts the live method signature. If a parameter (or option) is undocumented, the CLI still exposes it using the parameter name and default values inferred from the method definition: +Rubycli always trusts the live method signature. Undocumented parameters are +still exposed, with names, defaults, and types inferred from the definition: ```ruby -# fallback_example.rb +# examples/fallback_example.rb module FallbackExample module_function @@ -392,20 +373,6 @@ module FallbackExample end ``` -```bash -rubycli examples/fallback_example.rb -``` - -```text -Usage: fallback_example.rb COMMAND [arguments] - -Available commands: - Class methods: - scale AMOUNT [] [--clamp=] [--notify] - -Detailed command help: fallback_example.rb COMMAND help -``` - ```bash rubycli examples/fallback_example.rb scale --help ``` @@ -415,50 +382,56 @@ Usage: fallback_example.rb scale AMOUNT [FACTOR] [--clamp=] [--notify] Positional arguments: AMOUNT [Integer] required Base amount to process - FACTOR optional (default: 2) + FACTOR [String] optional (default: 2) Options: --clamp= [String] optional (default: nil) --notify [Boolean] optional (default: false) ``` -Here only `AMOUNT` is documented, yet `factor`, `clamp`, and `notify` are still presented with sensible defaults and inferred types. Run `rubycli --check path/to/script.rb` during development to surface mismatches between comments and signatures, and pass `--strict` when executing commands to enforce the documented types/choices. - -#### What if the docs mention arguments that do not exist? +Only `AMOUNT` is documented, yet `factor`, `clamp`, and `notify` are presented +with inferred defaults and types. -- **Out-of-sync lines fall back to plain text** – Comments that reference non-existent options (for example `--ghost`) or positionals (such as `EXTRA`) are emitted verbatim in the help’s detail section. They do not materialize as real arguments, and strict mode still warns about positional mismatches (`Extra positional argument comments were found: EXTRA`) so you can reconcile the docs. +Comments never add live parameters by themselves. Lines that reference +non-existent options (say `--ghost`) or positionals are shown verbatim in the +help's detail section instead of becoming real arguments, and strict mode warns +about positional mismatches. For a runnable mismatch demo: +`rubycli examples/fallback_example_with_extra_docs.rb scale --help`. -> Want to see this behaviour? Try `rubycli examples/fallback_example_with_extra_docs.rb scale --help` for a runnable mismatch demo. +Run `rubycli --check path/to/script.rb` during development to lint +documentation drift — including undefined type labels and enum typos, with +DidYouMean suggestions — and pass `--strict` at runtime when invalid input +should abort instead of merely warning. -In short, comments never add live parameters by themselves; they enrich or describe what your method already supports. +> `--strict` trusts whatever types/choices your comments spell out. Keep +> `rubycli --check` in CI so documentation typos are caught before production +> runs that rely on `--strict`. ## Argument parsing modes ### Default literal parsing -Rubycli tries to interpret arguments that look like structured literals (values starting with `{`, `[`, quotes, or YAML front matter) using `Psych.safe_load` before handing them to your code. That means values such as `--names='["Alice","Bob"]'` or `--config='{foo: 1}'` arrive as native arrays / hashes without any extra flags. Plain strings like `1,2,3` stay untouched at this stage (if the documentation declares `String[]` or `TAG...`, a later pass still normalises them into arrays), and unsupported constructs fall back to the original text, so `"2024-01-01"` remains a string and malformed payloads still reach your method instead of killing the run. +Arguments that look like structured literals (starting with `{`, `[`, quotes, +or YAML markers) are parsed with `Psych.safe_load`, so +`--names='["Alice","Bob"]'` or `--config='{foo: 1}'` arrive as native arrays +and hashes without extra flags. Plain strings like `1,2,3` stay untouched at +this stage (a later pass normalises them into arrays when the docs declare +`String[]` or `TAG...`), and unsupported constructs fall back to the original +text, so `"2024-01-01"` remains a string and malformed payloads still reach +your method instead of killing the run. -### JSON mode +### JSON mode (`--json-args` / `-j`) -Supply `--json-args` (or the shorthand `-j`) when invoking the runner and Rubycli will parse subsequent arguments strictly as JSON before passing them to your method: +Parses subsequent arguments strictly as JSON. YAML-only syntax is rejected and +invalid payloads raise `JSON::ParserError` — for callers who want explicit +failures instead of silent fallbacks. Programmatic equivalent: +`Rubycli.with_json_mode(true) { ... }`. -```bash -rubycli -j my_cli.rb MyCLI run '["--config", "{\"foo\":1}"]' -``` - -This mode rejects YAML-only syntax and raises `JSON::ParserError` when the payload is invalid, which is handy for callers who want explicit failures instead of silent fallbacks. Programmatically you can call `Rubycli.with_json_mode(true) { … }`. - -## Eval mode +### Eval mode (`--eval-args` / `-e`, `--eval-lax` / `-E`) -Use `--eval-args` (or the shorthand `-e`) to evaluate Ruby expressions before they are forwarded to your CLI. This is handy when you want to pass rich objects that are awkward to express as JSON: - -```bash -rubycli -e scripts/data_cli.rb DataCLI run '(1..10).to_a' -``` - -Under the hood Rubycli evaluates each argument inside an isolated binding (`Object.new.instance_eval { binding }`). Treat this as unsafe input: do not enable it for untrusted callers. The mode can also be toggled programmatically via `Rubycli.with_eval_mode(true) { … }`. - -Because Ruby evaluation understands symbols, arrays, and hashes, it’s a convenient way to pass literal enum combinations to options that expect arrays: +Evaluates each argument as a Ruby expression before it is forwarded, which is +handy for objects that are awkward as JSON — symbol arrays, ranges, inline +math: ```bash rubycli -E scripts/report_runner.rb publish \ @@ -466,56 +439,108 @@ rubycli -E scripts/report_runner.rb publish \ --channels '[:email, :slack]' ``` -Need Ruby evaluation plus a safety net? Pass `--eval-lax` (or `-E`). It flips on eval mode just like `--eval-args`, but if Ruby fails to parse a token (for example, a bare `https://example.com`), Rubycli emits a warning and forwards the original string unchanged. This lets you mix inline math (`60*60*24*14`) with literal values without constantly juggling quotes. - -`--json-args`/`-j` cannot be combined with either `--eval-args`/`-e` or `--eval-lax`/`-E`; Rubycli will raise an error if both are present. Both modes augment the default literal parsing, so you can pick either strict JSON or one of the Ruby eval variants when the defaults are not enough. +Evaluation happens inside an isolated binding +(`Object.new.instance_eval { binding }`). Treat this as unsafe input: do not +enable it for untrusted callers. Programmatic equivalent: +`Rubycli.with_eval_mode(true) { ... }`. -## Pre-script bootstrap +`--eval-lax` / `-E` behaves like `--eval-args`, but tokens that fail to parse +as Ruby (for example a bare `https://example.com`) produce a warning and are +forwarded as the original string — convenient for mixing expressions like +`60*60*24*14` with plain values. -Add `--pre-script SRC` (alias: `--init`) when launching the bundled CLI to run arbitrary Ruby code before exposing methods. The code runs inside an isolated binding where the following locals are pre-populated: +`--json-args` cannot be combined with either eval variant; Rubycli raises an +error if both are present. -- `target` – the original class or module (before `--new` instantiation) -- `current` / `instance` – the object that would otherwise be exposed (after `--new` if specified) +## Pre-script bootstrap -The last evaluated value becomes the new public target. Returning `nil` keeps the previous object. +`--pre-script SRC` (alias: `--init`) runs arbitrary Ruby before commands are +resolved, inside an isolated binding with these locals pre-populated: -Inline example: +- `target` — the original class or module (before `--new` instantiation) +- `current` / `instance` — the object that would otherwise be exposed -```bash -rubycli --pre-script 'InitArgRunner.new(source: "cli", retries: 2)' \ - lib/init_arg_runner.rb summarize --verbose -``` +The last evaluated value becomes the new public target (`nil` keeps the +previous object). `SRC` can be inline Ruby or a file path. -File example: +Example — replace the `--new`-built instance with a hand-built one: ```bash -# scripts/bootstrap_runner.rb -instance = InitArgRunner.new(source: "preset") -instance.logger = Logger.new($stdout) -instance +rubycli --new='["a"]' \ + --pre-script 'NewModeRunner.new(%w[a b c], options: {from: :pre})' \ + examples/new_mode_runner.rb run --mode summary ``` -```bash -rubycli --pre-script scripts/bootstrap_runner.rb \ - lib/init_arg_runner.rb summarize --verbose -``` - -This keeps `--new` available for quick zero-argument instantiation while allowing richer bootstrapping when needed. - -## Environment variables & flags +## Flags and environment variables | Flag / Env | Description | Default | | ---------- | ----------- | ------- | +| `--auto-target` / `-a`, `RUBYCLI_AUTO_TARGET=auto` | Auto-select the target constant when the file name does not match | `strict` | +| `--new[=VALUE]` | Instantiate the class before resolving commands; `VALUE` feeds the constructor | off | +| `--pre-script SRC` / `--init SRC` | Run Ruby code to build/replace the exposed object | off | +| `--check` | Lint documentation/comments without executing commands | off | +| `--strict` | Enforce documented types/choices; invalid input aborts | off | +| `--json-args` / `-j` | Parse arguments strictly as JSON | off | +| `--eval-args` / `-e`, `--eval-lax` / `-E` | Evaluate arguments as Ruby (lax: fall back to the raw string) | off | | `RUBYCLI_DEBUG=true` | Print debug logs | `false` | -| `--check` | Validate documentation/comments without executing commands | `off` | -| `--strict` | Enforce documented choices/types; invalid input aborts | `off` | -| `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` | Disable legacy `@param` lines (defaults to on today for compatibility) | `ON` | +| `RUBYCLI_ALLOW_PARAM_COMMENT=OFF` | Disable YARD `@param` lines (on by default for compatibility) | `ON` | ## Library helpers -- `Rubycli.parse_arguments(argv, method)` – parse argv with comment metadata -- `Rubycli.available_commands(target)` – list CLI exposable methods -- `Rubycli.usage_for_method(name, method)` – render usage for a single method -- `Rubycli.method_description(method)` – fetch structured documentation info +- `Rubycli.parse_arguments(argv, method)` — parse argv with comment metadata +- `Rubycli.available_commands(target)` — list CLI-exposable methods +- `Rubycli.usage_for_method(name, method)` — render usage for a single method +- `Rubycli.method_description(method)` — fetch structured documentation info + +## How it differs from Python Fire -Feedback and issues are welcome while we prepare the public release. +- **Comment-aware help** — doc comments enrich the help, but the live method + signature stays the ultimate authority. +- **Type-aware parsing** — placeholder syntax and YARD tags coerce arguments to + booleans, arrays, numerics, and more without additional code. +- **Two-stage validation** — `--check` lints documentation drift without + executing commands; `--strict` turns documented types/choices into + enforceable runtime contracts. +- **Ruby-centric** — keyword arguments, block documentation (`@yield*` tags), + and `RUBYCLI_*` environment toggles. + +| Capability | Python Fire | Rubycli | +| ---------- | ----------- | ------- | +| Attribute traversal | Recursively exposes attributes/properties | Exposes public methods on the target; no implicit traversal | +| Constructor handling | Prompts for `__init__` args automatically | `--new` instantiates; constructor args via `--new=VALUE`, richer wiring via pre-scripts | +| Interactive shell | Fire-specific REPL when invoked without a command | No interactive shell; strictly command execution | +| Input discovery | Pure reflection, no doc comments | Doc comments drive option names, placeholders, and validation | +| Data structures | Dicts/lists become subcommands | Class/module methods only; no automatic dict/list expansion | + +## Project philosophy + +- **Convenience first** — wrap existing Ruby scripts with almost no manual + plumbing. Fidelity with Python Fire is not a goal; missing Fire features are + generally by design. +- **Method definitions first, comments augment** — signatures determine what is + exposed and what is required; comments refine types, help text, and + validation. +- **Lightweight maintenance** — much of the implementation was generated with + AI assistance; contributions that dive into deep Ruby metaprogramming are out + of scope. Please discuss expectations before opening parity PRs. + +## Bundled examples + +- `examples/hello_app.rb` / `examples/hello_app_with_docs.rb` — minimal + module-function variants, without and with docs +- `examples/hello_app_with_require.rb` — embedded `Rubycli.run` +- `examples/typed_arguments_demo.rb` — stdlib type coercions + (Date/Time/BigDecimal/Pathname) +- `examples/strict_choices_demo.rb` — literal enumerations and `--strict` +- `examples/new_mode_runner.rb` — instance-only class initialized via + `--new=VALUE` +- `examples/documentation_style_showcase.rb` — every comment notation in one + script +- `examples/fallback_example.rb` / `examples/fallback_example_with_extra_docs.rb` + — signature fallback and doc-mismatch demos + +## License + +MIT. See [LICENSE](LICENSE). + +Feedback and issues are welcome. From 3311a8461aff46601909f6785f3e54d34be80bba Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:08:04 +0900 Subject: [PATCH 02/20] =?UTF-8?q?fix:=20CLI=E5=A2=83=E7=95=8C=E3=81=A8Runn?= =?UTF-8?q?er=E6=A4=9C=E5=87=BA=E3=82=92=E4=BF=AE=E6=AD=A3=20/=20Repair=20?= =?UTF-8?q?CLI=20and=20runner=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 10 ++++- lib/rubycli.rb | 2 +- lib/rubycli/argument_parser.rb | 41 ++++++++++++++---- lib/rubycli/cli.rb | 15 ++----- lib/rubycli/constant_capture.rb | 66 ++++++++++++++++++++++++++++- test/argument_parser_test.rb | 37 +++++++++++++++++ test/cli_test.rb | 25 +++++++++++ test/command_line_test.rb | 8 ++++ test/constant_capture_test.rb | 28 +++++++++++++ test/runner_test.rb | 73 ++++++++++++++++++++++++--------- 10 files changed, 263 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 726644b..b7928d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog -# Changelog +## [Unreleased] + +### Fixed +- Parameterless commands now reject unexpected arguments without invoking the target method or attempting implicit return-value traversal. +- Required options now report a missing value instead of consuming the following option token, while explicit values such as `true` remain valid. +- Constant discovery now includes classes and modules assigned with `Class.new` / `Module.new` during the target file load. +- Constructor arity errors raised by `--new` are now wrapped in Rubycli's user-facing runner error. +- Positional type conversion now waits for JSON/eval coercion, matching keyword-option behavior and preserving `--new` JSON/eval inputs. +- Runner tests now execute without terminating the Minitest process and assert converted command arguments instead of stubbed targets. ## [0.1.7] - 2025-11-12 diff --git a/lib/rubycli.rb b/lib/rubycli.rb index a8b789c..ceccb22 100644 --- a/lib/rubycli.rb +++ b/lib/rubycli.rb @@ -378,7 +378,7 @@ def instantiate_target(target, initializer_args = nil) else target end - rescue ArgumentError => e + rescue ::ArgumentError => e raise Error, "Failed to instantiate target: #{e.message}" end diff --git a/lib/rubycli/argument_parser.rb b/lib/rubycli/argument_parser.rb index cccbf82..44dc633 100644 --- a/lib/rubycli/argument_parser.rb +++ b/lib/rubycli/argument_parser.rb @@ -21,6 +21,7 @@ def parse(args, method = nil) kw_args = {} kw_param_names = extract_keyword_parameter_names(method) + required_kw_param_names = extract_required_keyword_parameter_names(method) debug_log "Available keyword parameters: #{kw_param_names.inspect}" metadata = method ? @documentation_registry.metadata_for(method) : { options: [], returns: [], summary: nil } @@ -48,7 +49,8 @@ def parse(args, method = nil) kw_args, cli_aliases, option_lookup, - type_converters + type_converters, + required_kw_param_names ) elsif assignment_token?(token) stream.advance @@ -88,6 +90,14 @@ def extract_keyword_parameter_names(method) .map { |_, name| name.to_s } end + def extract_required_keyword_parameter_names(method) + return [] unless method + + method.parameters + .select { |type, _| type == :keyreq } + .map { |_, name| name.to_s } + end + def option_token?(token) token =~ /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/ end @@ -103,7 +113,8 @@ def process_option_token( kw_args, cli_aliases, option_lookup, - type_converters + type_converters, + required_kw_param_names ) token =~ /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/ cli_key = Regexp.last_match(1).tr('-', '_') @@ -117,7 +128,11 @@ def process_option_token( final_key_sym = final_key.to_sym option_meta = option_lookup[final_key_sym] - requires_value = option_meta ? option_meta[:requires_value] : nil + requires_value = if option_meta + option_meta[:requires_value] + else + required_kw_param_names.include?(final_key) + end option_label = option_meta&.long || "--#{final_key.tr('_', '-')}" value_capture = if embedded_value @@ -128,13 +143,15 @@ def process_option_token( stream, requires_value ) + elsif requires_value + capture_required_option_value(option_label, stream) elsif (next_token = stream.current) && !looks_like_option?(next_token) stream.consume else 'true' end - if requires_value && (value_capture.nil? || value_capture == 'true') + if requires_value && value_capture.nil? raise ArgumentError, "Option '#{option_label}' requires a value" end @@ -147,6 +164,16 @@ def process_option_token( kw_args[final_key_sym] = converted_value end + + def capture_required_option_value(option_label, stream) + next_token = stream.current + if next_token.nil? || looks_like_option?(next_token) + raise ArgumentError, "Option '#{option_label}' requires a value" + end + + stream.consume + end + def capture_option_value(option_meta, stream, requires_value) if option_meta[:boolean_flag] if (next_token = stream.current) && TypeUtils.boolean_string?(next_token) @@ -161,10 +188,7 @@ def capture_option_value(option_meta, stream, requires_value) elsif requires_value == false return 'true' elsif requires_value - next_token = stream.current - raise ArgumentError, "Option '#{option_meta.long}' requires a value" unless next_token - - return stream.consume + return capture_required_option_value(option_meta.long, stream) elsif (next_token = stream.current) && !looks_like_option?(next_token) return stream.consume else @@ -189,6 +213,7 @@ def split_assignment_token(token) def convert_positional_arguments(pos_args, method, metadata) return pos_args unless method + return pos_args if Rubycli.eval_mode? || Rubycli.json_mode? positional_map = metadata[:positionals_map] || {} return pos_args if positional_map.empty? diff --git a/lib/rubycli/cli.rb b/lib/rubycli/cli.rb index 9a92b33..7fb4cfa 100644 --- a/lib/rubycli/cli.rb +++ b/lib/rubycli/cli.rb @@ -134,22 +134,15 @@ def execute_method(method_obj, command, args, cli_mode) end end - def execute_parameterless_method(method_obj, command, args, cli_mode) + def execute_parameterless_method(method_obj, command, args, _cli_mode) if help_requested_for_parameterless?(args) puts usage_for_method(command, method_obj) return 0 end - begin - result = method_obj.call - debug_log "Parameterless method returned: #{result.inspect}" - if result - return run(result, args, false) - end - 0 - rescue StandardError => e - handle_execution_error(e, command, method_obj, [], {}, cli_mode) - end + puts "Command '#{command}' does not accept arguments." + puts usage_for_method(command, method_obj) + 1 end def execute_method_with_params(method_obj, command, args, cli_mode) diff --git a/lib/rubycli/constant_capture.rb b/lib/rubycli/constant_capture.rb index c1c2a84..3064c1a 100644 --- a/lib/rubycli/constant_capture.rb +++ b/lib/rubycli/constant_capture.rb @@ -8,20 +8,30 @@ def initialize end def capture(file) + normalized_file = normalize(file) + @captured[normalized_file] = [] + before_snapshot = constant_snapshot(normalized_file) trace = TracePoint.new(:class) do |tp| location = tp.path - next unless location && same_file?(file, location) + next unless location && same_file?(normalized_file, location) constant_name = qualified_name_for(tp.self) next unless constant_name - @captured[file] << constant_name + @captured[normalized_file] << constant_name end trace.enable yield ensure trace&.disable + if normalized_file && before_snapshot + after_snapshot = constant_snapshot(normalized_file) + changed_names = after_snapshot.keys.select do |name| + before_snapshot[name] != after_snapshot[name] + end + @captured[normalized_file].concat(changed_names) + end end def constants_for(file) @@ -46,5 +56,57 @@ def qualified_name_for(target) name end + + def constant_snapshot(file) + ObjectSpace.each_object(Module).each_with_object({}) do |owner, snapshot| + owner_name = owner.equal?(Object) ? '' : owner.name + next if owner_name.nil? || owner_name.start_with?('#<') + + safe_module_constants(owner).each do |constant_name| + next if safe_autoload?(owner, constant_name) + + location = safe_const_source_location(owner, constant_name) + next unless location && same_file?(file, location[0]) + + value = safe_const_get(owner, constant_name) + next unless value.is_a?(Module) + + name = qualified_constant_name(owner_name, constant_name) + snapshot[name] = [location, value.object_id] + end + end + rescue StandardError + {} + end + + def safe_module_constants(owner) + owner.constants(false) + rescue StandardError + [] + end + + def safe_autoload?(owner, constant_name) + owner.autoload?(constant_name, false) + rescue StandardError + false + end + + def safe_const_source_location(owner, constant_name) + owner.const_source_location(constant_name, false) + rescue StandardError + nil + end + + def safe_const_get(owner, constant_name) + owner.const_get(constant_name, false) + rescue StandardError + nil + end + + def qualified_constant_name(owner_name, constant_name) + return constant_name.to_s if owner_name.empty? + + "#{owner_name}::#{constant_name}" + end end end diff --git a/test/argument_parser_test.rb b/test/argument_parser_test.rb index 8626ef4..bb3ad21 100644 --- a/test/argument_parser_test.rb +++ b/test/argument_parser_test.rb @@ -44,6 +44,14 @@ def ingest(date:, moment:, budget:, input:) end end +module UndocumentedKeywordSamples + module_function + + def call(name:, verbose: false) + [name, verbose] + end +end + class ArgumentParserTest < Minitest::Test def setup @environment = Rubycli::Environment.new(env: {}, argv: []) @@ -66,6 +74,35 @@ def test_parses_tagged_options_and_booleans assert_equal({ greeting: 'Hi', shout: true, punctuation: '!' }, kw_args) end + def test_required_option_rejects_following_option_as_its_value + method = DocExamples::TaggedSamples.new.method(:greet) + + error = assert_raises(Rubycli::ArgumentError) do + @parser.parse(['Alice', '--greeting', '--shout'], method) + end + + assert_includes error.message, "Option '--greeting' requires a value" + end + + def test_required_option_accepts_true_as_an_explicit_value + method = DocExamples::TaggedSamples.new.method(:greet) + + pos_args, kw_args = @parser.parse(['Alice', '--greeting', 'true'], method) + + assert_equal ['Alice'], pos_args + assert_equal({ greeting: true }, kw_args) + end + + def test_undocumented_required_keyword_rejects_following_option_as_its_value + method = UndocumentedKeywordSamples.method(:call) + + error = assert_raises(Rubycli::ArgumentError) do + @parser.parse(['--name', '--verbose'], method) + end + + assert_includes error.message, "Option '--name' requires a value" + end + def test_parses_concise_options_with_short_alias_and_array_conversion method = DocExamples::ConciseSamples.new.method(:describe) args = ['subject', '2', '-s', 'dramatic', '--tags', 'alpha,beta'] diff --git a/test/cli_test.rb b/test/cli_test.rb index b467ab3..3a4dbc3 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -57,6 +57,31 @@ def self.info assert_includes out, 'Usage: rubycli info' end + def test_parameterless_method_rejects_extra_arguments_without_invoking_method + target = Class.new do + class << self + attr_accessor :calls + end + + self.calls = 0 + + def self.info + self.calls += 1 + nil + end + end + + status = nil + out, _err = capture_io do + status = @cli.run(target, ['info', 'unexpected'], true) + end + + assert_equal 1, status + assert_equal 0, target.calls + assert_includes out, "Command 'info' does not accept arguments." + assert_includes out, 'Usage: rubycli info' + end + def test_help_input_skips_strict_validation method_obj = ChoiceDocSamples.method(:report) status = nil diff --git a/test/command_line_test.rb b/test/command_line_test.rb index b4d645d..1142183 100644 --- a/test/command_line_test.rb +++ b/test/command_line_test.rb @@ -3,6 +3,14 @@ require 'test_helper' class CommandLineTest < Minitest::Test + def setup + @previous_print_result = Rubycli.environment.print_result? + end + + def teardown + Rubycli.environment.instance_variable_set(:@print_result, @previous_print_result) + end + def test_returns_usage_when_no_arguments status = nil out, err = capture_io do diff --git a/test/constant_capture_test.rb b/test/constant_capture_test.rb index eee88b5..a0a3ddc 100644 --- a/test/constant_capture_test.rb +++ b/test/constant_capture_test.rb @@ -32,6 +32,34 @@ def test_ignores_constants_from_other_files end end + def test_records_class_assigned_to_constant + capture = Rubycli::ConstantCapture.new + Tempfile.create(['assigned_class', '.rb']) do |file| + file.write("CaptureAssignedClass = Class.new do\n def self.run; end\nend\n") + file.flush + + capture.capture(file.path) { load file.path } + + assert_includes capture.constants_for(file.path), 'CaptureAssignedClass' + ensure + cleanup_constant(:CaptureAssignedClass) + end + end + + def test_records_module_assigned_to_constant + capture = Rubycli::ConstantCapture.new + Tempfile.create(['assigned_module', '.rb']) do |file| + file.write("CaptureAssignedModule = Module.new do\n def self.run; end\nend\n") + file.flush + + capture.capture(file.path) { load file.path } + + assert_includes capture.constants_for(file.path), 'CaptureAssignedModule' + ensure + cleanup_constant(:CaptureAssignedModule) + end + end + private def cleanup_constant(name) diff --git a/test/runner_test.rb b/test/runner_test.rb index 137a6b1..03b5df0 100644 --- a/test/runner_test.rb +++ b/test/runner_test.rb @@ -74,6 +74,19 @@ def run end end + def test_initializer_argument_error_is_wrapped_as_runner_error + target = Class.new do + def initialize(required); end + end + + error = assert_raises(Rubycli::Runner::Error) do + Rubycli::Runner.instantiate_target(target) + end + + assert_includes error.message, 'Failed to instantiate target' + assert_includes error.message, 'wrong number of arguments' + end + def test_auto_mode_selects_single_constant_when_names_differ Dir.mktmpdir do |dir| file = File.join(dir, 'cli_entry.rb') @@ -253,8 +266,11 @@ def self.combine(config, flag) RUBY parsed = nil - Rubycli.stub(:call_target, ->(_method, pos_args, kw_args) { parsed = { pos: pos_args, kw: kw_args } }) do - Rubycli::Runner.execute(file, nil, ['combine', '{"foo":1}', 'true'], constant_mode: :strict, eval_args: false, json: true, new: false) + run_without_exit = ->(target, *_args) { Rubycli.cli.run(target, ARGV.dup, false) } + Rubycli.stub(:run, run_without_exit) do + Rubycli.stub(:call_target, ->(_method, pos_args, kw_args) { parsed = { pos: pos_args, kw: kw_args }; nil }) do + Rubycli::Runner.execute(file, nil, ['combine', '{"foo":1}', 'true'], constant_mode: :strict, eval_args: false, json: true, new: false) + end end assert_equal([{ 'foo' => 1 }, true], parsed[:pos]) assert_equal({}, parsed[:kw]) @@ -262,9 +278,22 @@ def self.combine(config, flag) previous_strict = Rubycli.environment.strict_input? begin Rubycli.environment.enable_strict_input! - assert_raises(Rubycli::ArgumentError) do - Rubycli::Runner.execute(file, nil, ['combine', 'not-a-hash', 'maybe'], constant_mode: :strict, eval_args: false, json: true, new: false) + status = nil + _out, err = capture_io do + Rubycli.stub(:run, run_without_exit) do + status = Rubycli::Runner.execute( + file, + nil, + ['combine', '"not-a-hash"', '"maybe"'], + constant_mode: :strict, + eval_args: false, + json: true, + new: false + ) + end end + assert_equal 1, status + assert_includes err, 'CONFIG must be Hash' ensure Rubycli.environment.instance_variable_set(:@strict_input, previous_strict) end @@ -302,7 +331,7 @@ def run Rubycli.stub(:run, ->(target, *_args) { captured = target }) do Rubycli::Runner.execute(file, nil, ['run'], new: true, new_args: '{"b":2}', eval_args: true, constant_mode: :strict) end - assert_equal({ 'b' => 2 }, captured.opts) + assert_equal({ b: 2 }, captured.opts) error = assert_raises(Rubycli::Runner::Error) do Rubycli::Runner.execute(file, nil, ['run'], new: true, new_args: '{b:2}', json: true, constant_mode: :strict) @@ -311,7 +340,7 @@ def run captured = nil Rubycli.stub(:run, ->(target, *_args) { captured = target }) do - Rubycli::Runner.execute(file, nil, ['run'], new: true, new_args: '{retry: 2}', eval_args: false, eval_lax: true, json: false, constant_mode: :strict) + Rubycli::Runner.execute(file, nil, ['run'], new: true, new_args: '{retry: 2}', eval_args: true, eval_lax: true, json: false, constant_mode: :strict) end assert_equal({ retry: 2 }, captured.opts) @@ -323,7 +352,7 @@ def run ['run'], new: true, new_args: '{retry: 3}', - eval_args: false, + eval_args: true, eval_lax: true, json: false, constant_mode: :strict @@ -333,7 +362,10 @@ def run captured = nil Rubycli.stub(:run, ->(target, *_args) { captured = target }) do - Rubycli::Runner.execute(file, nil, ['run'], new: true, new_args: 'not{json', eval_args: false, eval_lax: true, json: false, constant_mode: :strict) + _out, err = capture_io do + Rubycli::Runner.execute(file, nil, ['run'], new: true, new_args: 'not{json', eval_args: true, eval_lax: true, json: false, constant_mode: :strict) + end + assert_includes err, 'Passing it through because --eval-lax is enabled' end # eval-lax falls back to raw string on parse error assert_equal 'not{json', captured.opts @@ -347,7 +379,7 @@ def test_eval_lax_on_regular_cli_arguments file = File.join(dir, 'eval_lax_runner.rb') File.write(file, <<~RUBY) class EvalLaxRunner - # values [String[]] + # VALUES [Symbol[]] def self.run(values) values end @@ -355,16 +387,19 @@ def self.run(values) RUBY captured = nil - Rubycli.stub(:run, ->(target, *_args) { captured = target }) do - Rubycli::Runner.execute( - file, - nil, - ['run', '[:foo, :bar]'], - eval_args: false, - eval_lax: true, - json: false, - constant_mode: :strict - ) + run_without_exit = ->(target, *_args) { Rubycli.cli.run(target, ARGV.dup, false) } + Rubycli.stub(:run, run_without_exit) do + Rubycli.stub(:call_target, ->(_method, pos_args, _kw_args) { captured = pos_args.first; nil }) do + Rubycli::Runner.execute( + file, + nil, + ['run', '[:foo, :bar]'], + eval_args: true, + eval_lax: true, + json: false, + constant_mode: :strict + ) + end end assert_equal %i[foo bar], captured From 39df58a4199b12187f40cebd412dcf7f5ee5e57e Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:48:56 +0900 Subject: [PATCH 03/20] =?UTF-8?q?fix:=20Runner=E3=81=AE=E6=A4=9C=E6=9F=BB?= =?UTF-8?q?=E5=A2=83=E7=95=8C=E3=82=92=E4=BF=AE=E6=AD=A3=20/=20Repair=20ru?= =?UTF-8?q?nner=20check=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/rubycli.rb | 76 +++++++++++++++-------- test/runner_test.rb | 147 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 24 deletions(-) diff --git a/lib/rubycli.rb b/lib/rubycli.rb index ceccb22..fbcceae 100644 --- a/lib/rubycli.rb +++ b/lib/rubycli.rb @@ -255,35 +255,27 @@ def check( eval_mode: false, eval_lax: false ) - raise ArgumentError, 'target_path must be specified' if target_path.nil? || target_path.empty? previous_doc_check = Rubycli.environment.doc_check_mode? + raise ArgumentError, 'target_path must be specified' if target_path.nil? || target_path.empty? + unless Array(pre_scripts).empty? + raise Error, '--check cannot be combined with --pre-script or --init' + end + Rubycli.environment.clear_documentation_issues! Rubycli.environment.enable_doc_check! - runner_target, full_path = prepare_runner_target( + runner_target, full_path = resolve_runner_target( target_path, class_name, - new: new, - new_args: new_args, - json_mode: json_mode, - eval_mode: eval_mode, - eval_lax: eval_lax, - pre_scripts: pre_scripts, - constant_mode: constant_mode + constant_mode: constant_mode, + instantiate: new ) original_program_name = $PROGRAM_NAME $PROGRAM_NAME = File.basename(full_path) - catalog = Rubycli.cli.command_catalog_for(runner_target) - Array(catalog&.entries).each do |entry| - method_obj = entry&.method - Rubycli.documentation_registry.metadata_for(method_obj) if method_obj - end - - if catalog&.entries&.empty? && runner_target.respond_to?(:call) - method_obj = runner_target.method(:call) rescue nil - Rubycli.documentation_registry.metadata_for(method_obj) if method_obj + documentation_methods_for(runner_target, full_path, instantiate: new).each do |method_obj| + Rubycli.documentation_registry.metadata_for(method_obj) end issues = Rubycli.environment.documentation_issues @@ -318,6 +310,8 @@ def evaluate_pre_script(source, base_target, current_target) end rescue Errno::ENOENT raise PreScriptError, "Pre-script file not found: #{context}" + rescue SyntaxError => e + raise PreScriptError, "Failed to evaluate pre-script (#{context}): #{e.message}" rescue StandardError => e raise PreScriptError, "Failed to evaluate pre-script (#{context}): #{e.message}" end @@ -354,7 +348,7 @@ def constantize(name, defined_constants: nil, full_path: nil) raise Error, "Unable to resolve class/module name: #{name.inspect}" if parts.empty? parts.reduce(Object) do |context, const_name| - context.const_get(const_name) + context.const_get(const_name, false) end rescue NameError message = build_missing_constant_message(name, defined_constants, full_path) @@ -434,6 +428,21 @@ def prepare_runner_target( pre_scripts: [], constant_mode: nil ) + target, full_path = resolve_runner_target( + target_path, + class_name, + constant_mode: constant_mode, + instantiate: new + ) + + initializer_args = new ? parse_initializer_arguments(new_args, target, json_mode: json_mode, eval_mode: eval_mode, eval_lax: eval_lax) : nil + + runner_target = new ? instantiate_target(target, initializer_args) : target + runner_target = apply_pre_scripts(pre_scripts, target, runner_target) + [runner_target, full_path] + end + + def resolve_runner_target(target_path, class_name, constant_mode:, instantiate:) full_path = find_target_path(target_path) capture = Rubycli.constant_capture capture.capture(full_path) { load full_path } @@ -453,15 +462,34 @@ def prepare_runner_target( camelize(File.basename(full_path, '.rb')), candidates, constant_mode, - instantiate: new + instantiate: instantiate ) end - initializer_args = new ? parse_initializer_arguments(new_args, target, json_mode: json_mode, eval_mode: eval_mode, eval_lax: eval_lax) : nil + [target, full_path] + end - runner_target = new ? instantiate_target(target, initializer_args) : target - runner_target = apply_pre_scripts(pre_scripts, target, runner_target) - [runner_target, full_path] + def documentation_methods_for(target, full_path, instantiate:) + unless target.is_a?(Module) + catalog = Rubycli.cli.command_catalog_for(target) + methods = Array(catalog&.entries).filter_map(&:method) + if methods.empty? && target.respond_to?(:call) + callable = target.method(:call) rescue nil + methods << callable if callable + end + return methods + end + + normalized = normalize_path(full_path) + class_methods = collect_defined_methods(target.singleton_class, normalized) + .map { |name| target.method(name) } + .select { |method_obj| Rubycli.cli.send(:exposable_method?, method_obj) } + return class_methods unless instantiate + + instance_methods = collect_defined_methods(target, normalized) + .map { |name| target.instance_method(name) } + .select { |method_obj| Rubycli.cli.send(:exposable_method?, method_obj) } + target.is_a?(Class) ? instance_methods + class_methods : instance_methods end def build_constant_candidates(path, constant_names) diff --git a/test/runner_test.rb b/test/runner_test.rb index 03b5df0..018424b 100644 --- a/test/runner_test.rb +++ b/test/runner_test.rb @@ -134,6 +134,20 @@ def test_invalid_pre_script_source_raises_error assert_match(/Failed to evaluate pre-script/, error.message) end + def test_pre_script_syntax_error_is_wrapped_with_source_context + error = assert_raises(Rubycli::Runner::PreScriptError) do + Rubycli::Runner.apply_pre_scripts( + [{ value: '{', context: '(inline --pre-script)' }], + Object, + Object + ) + end + + assert_includes error.message, 'Failed to evaluate pre-script' + assert_includes error.message, '(inline --pre-script)' + assert_includes error.message, 'syntax error' + end + def test_strict_mode_requires_explicit_constant_when_names_differ Dir.mktmpdir do |dir| file = File.join(dir, 'cli_entry.rb') @@ -154,6 +168,35 @@ def self.run; end end end + def test_explicit_nested_constant_does_not_fall_back_to_ancestor + Object.const_set(:InheritedPayload, Class.new) + + Dir.mktmpdir do |dir| + file = File.join(dir, 'explicit_constant_host.rb') + File.write(file, <<~RUBY) + class ExplicitConstantHost + def self.run + :host + end + end + RUBY + + error = assert_raises(Rubycli::Runner::Error) do + Rubycli::Runner.prepare_runner_target( + file, + 'ExplicitConstantHost::InheritedPayload', + constant_mode: :strict + ) + end + + assert_includes error.message, 'ExplicitConstantHost::InheritedPayload' + ensure + Object.send(:remove_const, :ExplicitConstantHost) if Object.const_defined?(:ExplicitConstantHost, false) + end + ensure + Object.send(:remove_const, :InheritedPayload) if Object.const_defined?(:InheritedPayload, false) + end + def test_error_when_matching_constant_has_no_cli_methods Dir.mktmpdir do |dir| file = File.join(dir, 'lonely_runner.rb') @@ -466,4 +509,108 @@ def self.run(name) Object.send(:remove_const, :DocCheckRunner) if Object.const_defined?(:DocCheckRunner) end end + + def test_check_new_lints_instance_methods_without_instantiating + Dir.mktmpdir do |dir| + file = File.join(dir, 'side_effect_check_runner.rb') + File.write(file, <<~RUBY) + class SideEffectCheckRunner + def initialize + $side_effect_check_runner_initializations += 1 + end + + # @param name [String] Documented name + # @param extra [String] This param does not exist + def run(name) + name + end + end + RUBY + + $side_effect_check_runner_initializations = 0 + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + + status = nil + _out, _err = capture_io do + status = Rubycli::Runner.check(file, 'SideEffectCheckRunner', new: true) + end + + assert_equal 1, status + assert_equal 0, $side_effect_check_runner_initializations + refute_empty Rubycli.environment.documentation_issues + ensure + $side_effect_check_runner_initializations = nil + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + Rubycli.environment.disable_doc_check! + Object.send(:remove_const, :SideEffectCheckRunner) if Object.const_defined?(:SideEffectCheckRunner) + end + end + + def test_check_rejects_pre_scripts_without_evaluating_them + Dir.mktmpdir do |dir| + file = File.join(dir, 'pre_script_check_runner.rb') + File.write(file, <<~RUBY) + class PreScriptCheckRunner + def self.run + :ok + end + end + RUBY + + $pre_script_check_evaluations = 0 + Rubycli.environment.enable_doc_check! + error = assert_raises(Rubycli::Runner::Error) do + Rubycli::Runner.check( + file, + 'PreScriptCheckRunner', + pre_scripts: [{ value: '$pre_script_check_evaluations += 1', context: '(inline --pre-script)' }] + ) + end + + assert_includes error.message, '--check cannot be combined with --pre-script' + assert_equal 0, $pre_script_check_evaluations + assert Rubycli.environment.doc_check_mode? + ensure + $pre_script_check_evaluations = nil + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + Rubycli.environment.disable_doc_check! + Object.send(:remove_const, :PreScriptCheckRunner) if Object.const_defined?(:PreScriptCheckRunner) + end + end + + def test_check_new_ignores_generated_attribute_writers + Dir.mktmpdir do |dir| + file = File.join(dir, 'accessor_check_runner.rb') + File.write(file, <<~RUBY) + class AccessorCheckRunner + # Writable value. + attr_writer :value + + def run + :ok + end + end + RUBY + + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + + status = nil + _out, _err = capture_io do + status = Rubycli::Runner.check(file, 'AccessorCheckRunner', new: true) + end + + assert_equal 0, status + assert_empty Rubycli.environment.documentation_issues + ensure + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + Rubycli.environment.disable_doc_check! + Object.send(:remove_const, :AccessorCheckRunner) if Object.const_defined?(:AccessorCheckRunner) + end + end + end From 399f6618f5803bef9ab99aad74adb18a2e40e3d4 Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:49:01 +0900 Subject: [PATCH 04/20] =?UTF-8?q?fix:=20=E5=BC=95=E6=95=B0=E3=81=A8?= =?UTF-8?q?=E6=B3=A8=E9=87=88=E3=81=AE=E5=AF=BE=E5=BF=9C=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20/=20Align=20arguments=20with=20annotations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/rubycli/argument_parser.rb | 209 ++++++++++++---- lib/rubycli/documentation/metadata_parser.rb | 22 +- lib/rubycli/help_renderer.rb | 14 +- lib/rubycli/type_utils.rb | 2 +- test/argument_parser_test.rb | 245 ++++++++++++++++++- test/documentation_registry_test.rb | 20 ++ test/help_renderer_test.rb | 36 +++ 7 files changed, 490 insertions(+), 58 deletions(-) diff --git a/lib/rubycli/argument_parser.rb b/lib/rubycli/argument_parser.rb index 44dc633..e51dbe5 100644 --- a/lib/rubycli/argument_parser.rb +++ b/lib/rubycli/argument_parser.rb @@ -18,6 +18,7 @@ def initialize(environment:, documentation_registry:, json_coercer:, debug_logge def parse(args, method = nil) pos_args = [] + raw_pos_args = [] kw_args = {} kw_param_names = extract_keyword_parameter_names(method) @@ -37,8 +38,9 @@ def parse(args, method = nil) if token == '--' stream.advance - rest_tokens = stream.consume_remaining.map { |value| convert_arg(value) } - pos_args.concat(rest_tokens) + rest_tokens = stream.consume_remaining + raw_pos_args.concat(rest_tokens) + pos_args.concat(rest_tokens.map { |value| convert_arg(value) }) break elsif option_token?(token) stream.advance @@ -52,16 +54,17 @@ def parse(args, method = nil) type_converters, required_kw_param_names ) - elsif assignment_token?(token) + elsif assignment_token_for_method?(token, method, kw_param_names) stream.advance - process_assignment_token(token, kw_args) + process_assignment_token(token, kw_args, option_lookup, type_converters) else + raw_pos_args << token pos_args << convert_arg(token) stream.advance end end - pos_args = convert_positional_arguments(pos_args, method, metadata) + pos_args = convert_positional_arguments(pos_args, raw_pos_args, method, metadata) debug_log "Final parsed - pos_args: #{pos_args.inspect}, kw_args: #{kw_args.inspect}" [pos_args, kw_args] end @@ -102,8 +105,13 @@ def option_token?(token) token =~ /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/ end - def assignment_token?(token) - !split_assignment_token(token).nil? + def assignment_token_for_method?(token, method, kw_param_names) + key, = split_assignment_token(token) + return false unless key + return true unless method + return true if kw_param_names.include?(key) + + method.parameters.any? { |type, _| type == :keyrest } end def process_option_token( @@ -196,9 +204,11 @@ def capture_option_value(option_meta, stream, requires_value) end end - def process_assignment_token(token, kw_args) + def process_assignment_token(token, kw_args, option_lookup, type_converters) key, value = split_assignment_token(token) - kw_args[key.to_sym] = convert_arg(value) + keyword = key.to_sym + option_meta = option_lookup[keyword] + kw_args[keyword] = convert_option_value(keyword, value, option_meta, type_converters) end def split_assignment_token(token) @@ -211,7 +221,7 @@ def split_assignment_token(token) [key, value] end - def convert_positional_arguments(pos_args, method, metadata) + def convert_positional_arguments(pos_args, raw_pos_args, method, metadata) return pos_args unless method return pos_args if Rubycli.eval_mode? || Rubycli.json_mode? @@ -219,26 +229,95 @@ def convert_positional_arguments(pos_args, method, metadata) return pos_args if positional_map.empty? converted = pos_args.dup - method.parameters.each_with_index do |(type, name), index| - next unless %i[req opt].include?(type) + positional_argument_bindings(method, converted.size).each do |type, name, indexes| definition = positional_map[name] next unless definition - next if index >= converted.size - converter = converter_for_definition(definition) - next unless converter + case type + when :req, :opt + index = indexes.first + next unless index + + converter = converter_for_definition(definition) + next unless converter + + begin + converted[index] = converter.call(raw_pos_args[index]) + rescue StandardError => e + label = definition.label || definition.placeholder || name.to_s.upcase + raise ArgumentError, "Value '#{converted[index]}' for #{label} is invalid: #{e.message}" + end + when :rest + next if indexes.empty? + + converter = converter_for_rest_definition(definition) + next unless converter - begin - converted[index] = converter.call(converted[index]) - rescue StandardError => e label = definition.label || definition.placeholder || name.to_s.upcase - raise ArgumentError, "Value '#{converted[index]}' for #{label} is invalid: #{e.message}" + indexes.each do |index| + value = raw_pos_args[index] + converted[index] = converter.call(value) + rescue StandardError => e + raise ArgumentError, "Value '#{value}' for #{label} is invalid: #{e.message}" + end end end converted end + def positional_argument_bindings(method_obj, argument_count) + parameters = method_obj.parameters.select { |type, _| %i[req opt rest].include?(type) } + first_flexible = parameters.index { |type, _| %i[opt rest].include?(type) } + + unless first_flexible + return parameters.each_with_index.map do |(type, name), index| + [type, name, index < argument_count ? [index] : []] + end + end + + last_flexible = parameters.rindex { |type, _| %i[opt rest].include?(type) } + bindings = [] + cursor = 0 + + parameters[0...first_flexible].each do |type, name| + indexes = cursor < argument_count ? [cursor] : [] + bindings << [type, name, indexes] + cursor += 1 unless indexes.empty? + end + + trailing = parameters[(last_flexible + 1)..] || [] + trailing_start = [argument_count - trailing.size, cursor].max + middle_end = [trailing_start, argument_count].min + + parameters[first_flexible..last_flexible].each do |type, name| + indexes = if type == :rest + (cursor...middle_end).to_a + elsif cursor < middle_end + [cursor] + else + [] + end + bindings << [type, name, indexes] + cursor += indexes.size + end + + trailing.each_with_index do |(type, name), offset| + index = trailing_start + offset + bindings << [type, name, index < argument_count ? [index] : []] + end + + bindings + end + + def converter_for_rest_definition(definition) + scalar_types = Array(definition.types).compact.map do |type| + normalized = type.to_s.strip + array_inner_type(normalized) || normalized + end + build_converter_for_types(scalar_types) + end + def converter_for_definition(definition) types = Array(definition.types).compact return nil if types.empty? @@ -250,13 +329,17 @@ def converter_for_definition(definition) if normalized.start_with?('Array<') && normalized.end_with?('>') inner = normalized[6..-2].strip element_converter = converter_for_single_type(inner) - return ->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } } + return ->(value) { + list_items(value).map { |item| element_converter ? element_converter.call(item) : item } + } elsif normalized.end_with?('[]') inner = normalized[0..-3] element_converter = converter_for_single_type(inner) - return ->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } } + return ->(value) { + list_items(value).map { |item| element_converter ? element_converter.call(item) : item } + } elsif normalized.casecmp('Array').zero? - return ->(value) { TypeUtils.parse_list(value) } + return ->(value) { list_items(value) } else single = converter_for_single_type(normalized) return single if single @@ -290,15 +373,23 @@ def validate_positional_arguments(method_obj, metadata, positional_args) return if positional_args.nil? || positional_args.empty? positional_map = metadata[:positionals_map] || {} - ordered_params = method_obj.parameters.select { |type, _| %i[req opt].include?(type) } - - ordered_params.each_with_index do |(_, name), index| + positional_argument_bindings(method_obj, positional_args.size).each do |type, name, indexes| definition = positional_map[name] next unless definition - next if index >= positional_args.size - label = definition.label || definition.placeholder || name.to_s.upcase - enforce_value_against_definition(definition, positional_args[index], label) + + case type + when :req, :opt + index = indexes.first + next unless index + + enforce_value_against_definition(definition, positional_args[index], label) + when :rest + next if indexes.empty? + + values = indexes.map { |index| positional_args[index] } + enforce_value_against_definition(definition, values, label) + end end end @@ -579,7 +670,14 @@ def converter_for_single_type(type) case normalized when 'String' - ->(value) { value } + ->(value) { + converted_value = convert_arg(value) + if value.is_a?(String) && converted_value.is_a?(Numeric) + value + else + converted_value + end + } when 'Integer', 'Fixnum' ->(value) { Integer(value) } when 'Float' @@ -604,14 +702,31 @@ def converter_for_single_type(type) when 'Date' require 'date' ->(value) { Date.parse(value) } - when 'Time', 'DateTime' + when 'Time' require 'time' ->(value) { Time.parse(value) } - when 'JSON', 'Hash' + when 'DateTime' + require 'date' + ->(value) { DateTime.parse(value) } + when 'JSON' ->(value) { - return value if value.is_a?(Hash) + parsed_value = convert_arg(value) + unless parsed_value.is_a?(Hash) || parsed_value.is_a?(Array) + parsed_value = JSON.parse(value) + end + unless parsed_value.is_a?(Hash) || parsed_value.is_a?(Array) + raise ::ArgumentError, 'JSON value must be an object or array' + end + + parsed_value + } + when 'Hash' + ->(value) { + parsed_value = convert_arg(value) + parsed_value = JSON.parse(value) unless parsed_value.is_a?(Hash) + raise ::ArgumentError, 'Hash value must be an object' unless parsed_value.is_a?(Hash) - JSON.parse(value) + parsed_value } when 'Pathname' require 'pathname' @@ -624,19 +739,29 @@ def converter_for_single_type(type) if normalized.start_with?('Array<') && normalized.end_with?('>') inner = normalized[6..-2].strip element_converter = converter_for_single_type(inner) - ->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } } + ->(value) { + list_items(value).map { |item| element_converter ? element_converter.call(item) : item } + } elsif normalized.end_with?('[]') inner = normalized[0..-3] element_converter = converter_for_single_type(inner) - ->(value) { TypeUtils.parse_list(value).map { |item| element_converter ? element_converter.call(item) : item } } + ->(value) { + list_items(value).map { |item| element_converter ? element_converter.call(item) : item } + } elsif normalized == 'Array' - ->(value) { TypeUtils.parse_list(value) } + ->(value) { list_items(value) } else nil end end end + def list_items(value) + converted_value = convert_arg(value) + source = converted_value.is_a?(Array) ? converted_value : value + TypeUtils.parse_list(source) + end + def convert_option_value(keyword, value, option_meta, type_converters) if Rubycli.eval_mode? || Rubycli.json_mode? return convert_arg(value) @@ -646,17 +771,7 @@ def convert_option_value(keyword, value, option_meta, type_converters) converted_value = convert_arg(value) return converted_value unless converter - original_input = value if value.is_a?(String) - expects_list = option_meta && option_meta.types.any? { |type| - type.to_s.end_with?('[]') || type.to_s.start_with?('Array<') - } - - value_for_converter = converted_value - if expects_list && original_input && converted_value.is_a?(Numeric) && original_input.include?(',') - value_for_converter = original_input - end - - converter.call(value_for_converter) + converter.call(value) rescue StandardError => e option_label = option_meta&.long || option_meta&.short || keyword raise ArgumentError, "Value '#{value}' for option '#{option_label}' is invalid: #{e.message}" diff --git a/lib/rubycli/documentation/metadata_parser.rb b/lib/rubycli/documentation/metadata_parser.rb index bc53081..987fcec 100644 --- a/lib/rubycli/documentation/metadata_parser.rb +++ b/lib/rubycli/documentation/metadata_parser.rb @@ -571,11 +571,11 @@ def align_and_validate_parameter_docs(method_obj, metadata, defaults) method_obj.parameters.each do |type, name| case type - when :req, :opt - doc = positional_defs.shift + when :req, :opt, :rest + doc = take_positional_definition(positional_defs, name) if doc doc.param_name = name - doc.default_value = defaults[name] + doc.default_value = type == :rest ? [] : defaults[name] positional_map[name] = doc else @environment.handle_documentation_issue( @@ -587,10 +587,10 @@ def align_and_validate_parameter_docs(method_obj, metadata, defaults) fallback = PositionalDefinition.new( placeholder: name.to_s, label: name.to_s.upcase, - types: ['String'], + types: [type == :rest ? 'String[]' : 'String'], description: nil, param_name: name, - default_value: defaults[name], + default_value: type == :rest ? [] : defaults[name], inline_type_annotation: false, inline_type_text: nil, doc_format: :auto_generated, @@ -660,6 +660,18 @@ def align_and_validate_parameter_docs(method_obj, metadata, defaults) end end + def take_positional_definition(positional_defs, parameter_name) + tagged_index = positional_defs.index do |definition| + definition.doc_format == :tagged_param && definition.param_name == parameter_name + end + return positional_defs.delete_at(tagged_index) if tagged_index + + tagless_index = positional_defs.index { |definition| definition.doc_format != :tagged_param } + return positional_defs.delete_at(tagless_index) if tagless_index + + nil + end + def detail_line_for_extra_positional(doc) return nil unless doc diff --git a/lib/rubycli/help_renderer.rb b/lib/rubycli/help_renderer.rb index ff4be0b..357d41c 100644 --- a/lib/rubycli/help_renderer.rb +++ b/lib/rubycli/help_renderer.rb @@ -247,7 +247,7 @@ def literal_token?(token) end def positional_requirement(kind) - kind == :opt ? 'optional' : 'required' + %i[opt rest].include?(kind) ? 'optional' : 'required' end def positional_default(definition) @@ -272,7 +272,7 @@ def required_keyword_names(method) def ordered_positionals(method, metadata) positional_map = metadata[:positionals_map] || {} method.parameters.each_with_object([]) do |(type, name), memo| - next unless %i[req opt].include?(type) + next unless %i[req opt rest].include?(type) definition = positional_map[name] label = display_label_for(definition, name) @@ -319,7 +319,10 @@ def required_placeholder(placeholder, definition, name) def optional_placeholder(placeholder, definition, name) unless placeholder.nil? || placeholder.strip.empty? || auto_generated_placeholder?(placeholder, definition, name) - return placeholder.strip + documented = placeholder.strip + return documented if documented.start_with?('[') && documented.end_with?(']') + + return "[#{documented}]" end "[#{default_positional_label(definition, name, uppercase: true)}]" @@ -327,7 +330,10 @@ def optional_placeholder(placeholder, definition, name) def rest_placeholder(placeholder, definition, name) unless placeholder.nil? || placeholder.strip.empty? || auto_generated_placeholder?(placeholder, definition, name) - return placeholder.strip + documented = placeholder.strip + return documented if documented.start_with?('[') && documented.end_with?(']') + + return "[#{documented}]" end base = default_positional_label(definition, name, uppercase: true) diff --git a/lib/rubycli/type_utils.rb b/lib/rubycli/type_utils.rb index fe5d3af..92a13ac 100644 --- a/lib/rubycli/type_utils.rb +++ b/lib/rubycli/type_utils.rb @@ -76,7 +76,7 @@ def infer_types_from_placeholder(types, placeholder_info, include_optional_boole working = working.map do |type| next type if type.nil? || type.empty? - if boolean_type?(type) || nil_type?(type) + if (boolean_type?(type) && placeholder_info[:optional]) || nil_type?(type) type elsif type.end_with?('[]') || type.start_with?('Array<') || type.casecmp('Array').zero? array_type_present = true diff --git a/test/argument_parser_test.rb b/test/argument_parser_test.rb index bb3ad21..d9dc6c1 100644 --- a/test/argument_parser_test.rb +++ b/test/argument_parser_test.rb @@ -32,12 +32,14 @@ module StdTypeSamples # --date DATE [Date] Planned date # --moment TIME [Time] Execution timestamp + # --occurred-at MOMENT [DateTime] Calendar timestamp # --budget AMOUNT [BigDecimal] Budget amount # --input FILE [Pathname] Input file - def ingest(date:, moment:, budget:, input:) + def ingest(date:, moment:, occurred_at:, budget:, input:) { date: date, moment: moment, + occurred_at: occurred_at, budget: budget, input: input } @@ -52,6 +54,83 @@ def call(name:, verbose: false) end end +module RestParameterSamples + module_function + + # VALUES... [Symbol] Values to collect + def collect(*values) + values + end + + # LEVELS... [:info, :warn] Allowed levels + def choose(*levels) + levels + end + + # HEAD [String] Required head + # VALUES... [Symbol] Remaining values + def with_head(head, *values) + [head, values] + end + + # HEAD [String] Required head + # VALUES... [Symbol] Middle values + # TAIL [Integer] Required tail + def with_tail(head, *values, tail) + [head, values, tail] + end + + # PREFIX [Symbol] Optional prefix + # VALUE [Integer] Required value + def optional_before_required(prefix = :default, value) + [prefix, value] + end +end + +module JsonTypeSamples + module_function + + # --payload VALUE [JSON] JSON payload + def accept(payload:) + payload + end + + # --payload VALUE [Hash] Hash payload + def accept_hash(payload:) + payload + end +end + +module ScalarTypeSamples + module_function + + # CODE [String] Positional code + # --label VALUE [String] Label code + def strings(code, label:) + [code, label] + end + + # VALUE [Symbol] Symbol value + def symbol(value) + value + end + + # --codes VALUES... [String] String codes + def string_list(codes:) + codes + end + + # --flags VALUES... [Boolean] Boolean flags + def boolean_list(flags:) + flags + end + + # VALUE [String] Assignment-like text + def text(value) + value + end +end + class ArgumentParserTest < Minitest::Test def setup @environment = Rubycli::Environment.new(env: {}, argv: []) @@ -291,6 +370,7 @@ def test_standard_type_hints_convert_to_stdlib_classes args = [ '--date', '2024-12-25', '--moment', '2024-12-25T10:00:00Z', + '--occurred-at', '2024-12-25T10:00:00+09:00', '--budget', '123.45', '--input', '/tmp/data.txt' ] @@ -299,7 +379,170 @@ def test_standard_type_hints_convert_to_stdlib_classes assert_empty pos_args assert_instance_of Date, kw_args[:date] assert_instance_of Time, kw_args[:moment] + assert_instance_of DateTime, kw_args[:occurred_at] assert_instance_of BigDecimal, kw_args[:budget] assert_instance_of Pathname, kw_args[:input] + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + + def test_json_type_accepts_an_array_literal + method = JsonTypeSamples.method(:accept) + + pos_args, kw_args = @parser.parse(['--payload', '[1,2]'], method) + + assert_empty pos_args + assert_equal({ payload: [1, 2] }, kw_args) + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + + def test_json_type_rejects_scalar_json + method = JsonTypeSamples.method(:accept) + + error = assert_raises(Rubycli::ArgumentError) do + @parser.parse(['--payload', '1'], method) + end + + assert_includes error.message, 'JSON value must be an object or array' + end + + def test_hash_type_rejects_an_array_literal + method = JsonTypeSamples.method(:accept_hash) + + error = assert_raises(Rubycli::ArgumentError) do + @parser.parse(['--payload', '[1,2]'], method) + end + + assert_includes error.message, 'Hash value must be an object' + end + + def test_string_annotations_preserve_numeric_looking_tokens + method = ScalarTypeSamples.method(:strings) + + pos_args, kw_args = @parser.parse(['00123', '--label', '00456'], method) + + assert_equal ['00123'], pos_args + assert_equal({ label: '00456' }, kw_args) + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + + def test_symbol_annotation_converts_numeric_looking_token + method = ScalarTypeSamples.method(:symbol) + + pos_args, kw_args = @parser.parse(['123'], method) + + assert_equal [:"123"], pos_args + assert_empty kw_args + end + + def test_string_array_annotation_preserves_numeric_looking_token + method = ScalarTypeSamples.method(:string_list) + + pos_args, kw_args = @parser.parse(['--codes', '001'], method) + + assert_empty pos_args + assert_equal({ codes: ['001'] }, kw_args) + end + + def test_required_repeated_boolean_option_consumes_and_converts_its_value + method = ScalarTypeSamples.method(:boolean_list) + metadata = @registry.metadata_for(method) + + assert_equal ['Boolean[]'], metadata[:options].first.types + + pos_args, kw_args = @parser.parse(['--flags', 'true,false'], method) + + assert_empty pos_args + assert_equal({ flags: [true, false] }, kw_args) + assert_raises(Rubycli::ArgumentError) do + @parser.parse(['--flags', 'true,nope'], method) + end + end + + def test_assignment_like_token_remains_positional_without_matching_keyword + method = ScalarTypeSamples.method(:text) + + pos_args, kw_args = @parser.parse(['name=value'], method) + + assert_equal ['name=value'], pos_args + assert_empty kw_args + end + + def test_assignment_token_still_sets_a_matching_keyword + method = UndocumentedKeywordSamples.method(:call) + + pos_args, kw_args = @parser.parse(['name=Ruby'], method) + + assert_empty pos_args + assert_equal({ name: 'Ruby' }, kw_args) + end + + def test_assignment_token_uses_matching_keyword_type_conversion + method = ScalarTypeSamples.method(:strings) + + pos_args, kw_args = @parser.parse(['code', 'label=00456'], method) + + assert_equal ['code'], pos_args + assert_equal({ label: '00456' }, kw_args) + end + + def test_rest_parameter_metadata_converts_every_remaining_positional + method = RestParameterSamples.method(:collect) + metadata = @registry.metadata_for(method) + + assert_equal [:values], metadata[:positionals_map].keys + assert_equal ['Symbol[]'], metadata[:positionals_map][:values].types + + pos_args, kw_args = @parser.parse(%w[alpha beta], method) + + assert_equal %i[alpha beta], pos_args + assert_empty kw_args + end + + def test_rest_parameter_strict_validation_checks_every_remaining_positional + method = RestParameterSamples.method(:choose) + pos_args, kw_args = @parser.parse([':info', 'oops'], method) + @environment.enable_strict_input! + + error = assert_raises(Rubycli::ArgumentError) do + @parser.validate_inputs(method, pos_args, kw_args) + end + + assert_includes error.message, 'oops' + end + + def test_rest_parameter_conversion_allows_no_remaining_values + method = RestParameterSamples.method(:with_head) + + pos_args, kw_args = @parser.parse(['head'], method) + + assert_equal ['head'], pos_args + assert_empty kw_args + end + + def test_rest_parameter_reserves_trailing_required_arguments + method = RestParameterSamples.method(:with_tail) + + pos_args, kw_args = @parser.parse(%w[head alpha beta 7], method) + + assert_equal ['head', :alpha, :beta, 7], pos_args + assert_empty kw_args + assert_equal ['head', %i[alpha beta], 7], method.call(*pos_args) + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + + def test_optional_positional_reserves_a_following_required_argument + method = RestParameterSamples.method(:optional_before_required) + + pos_args, kw_args = @parser.parse(['7'], method) + + assert_equal [7], pos_args + assert_empty kw_args + assert_equal [:default, 7], method.call(*pos_args) + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } end end diff --git a/test/documentation_registry_test.rb b/test/documentation_registry_test.rb index fad869e..7bf4ec4 100644 --- a/test/documentation_registry_test.rb +++ b/test/documentation_registry_test.rb @@ -40,6 +40,16 @@ module InlineDocSamples def search(query:); end end +module TaggedOrderSamples + module_function + + # @param second [Integer] Second value + # @param first [String] First value + def reorder(first, second) + [first, second] + end +end + class DocumentationRegistryTest < Minitest::Test def setup @environment = Rubycli::Environment.new(env: {}, argv: []) @@ -122,6 +132,16 @@ def test_tagged_param_with_positional_conversion assert_equal ['Boolean'], verbose_opt.types end + def test_tagged_positionals_are_aligned_by_parameter_name + metadata = @registry.metadata_for(TaggedOrderSamples.method(:reorder)) + + assert_equal %i[first second], metadata[:positionals_map].keys + assert_equal ['String'], metadata[:positionals_map][:first].types + assert_equal 'First value', metadata[:positionals_map][:first].description + assert_equal ['Integer'], metadata[:positionals_map][:second].types + assert_equal 'Second value', metadata[:positionals_map][:second].description + end + def test_concise_format_includes_inline_annotations method = DocExamples::ConciseSamples.instance_method(:describe) metadata = @registry.metadata_for(method) diff --git a/test/help_renderer_test.rb b/test/help_renderer_test.rb index 8321aa9..b04a3e5 100644 --- a/test/help_renderer_test.rb +++ b/test/help_renderer_test.rb @@ -10,6 +10,25 @@ module HelpEnumSamples def report(level, format: 'text'); end end +module RestHelpSamples + module_function + + # VALUES... [Symbol] Values to collect + def collect(*values) + values + end +end + +module OptionalPositionalHelpSamples + module_function + + # PREFIX [Symbol] Optional prefix + # VALUE [Integer] Required value + def sequence(prefix = :default, value) + [prefix, value] + end +end + class HelpRendererTest < Minitest::Test def setup environment = Rubycli::Environment.new(env: {}, argv: []) @@ -95,6 +114,23 @@ def test_literal_choices_render_in_help_tables assert_includes usage, '--format= ["text", "json"]' end + def test_rest_parameter_is_optional_and_rendered_in_positional_table + method = RestHelpSamples.method(:collect) + usage = @renderer.usage_for_method('collect', method) + + assert_includes usage, 'Usage: rubycli collect [VALUES...]' + assert_includes usage, 'VALUES... [Symbol[]] optional Values to collect' + end + + def test_documented_optional_positional_is_bracketed_in_usage + method = OptionalPositionalHelpSamples.method(:sequence) + usage = @renderer.usage_for_method('sequence', method) + + assert_includes usage, 'Usage: rubycli sequence [PREFIX] VALUE' + assert_includes usage, 'PREFIX [Symbol] optional Optional prefix' + assert_includes usage, 'VALUE [Integer] required Required value' + end + private def assert_usage(expected, actual) From b4e849c69ef05408d05647777301ab527f9f138b Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 13:49:09 +0900 Subject: [PATCH 05/20] =?UTF-8?q?fix:=20=E5=AE=9F=E8=A1=8C=E7=8A=B6?= =?UTF-8?q?=E6=85=8B=E3=81=A8=E7=B5=90=E6=9E=9C=E5=87=BA=E5=8A=9B=E3=82=92?= =?UTF-8?q?=E9=9A=94=E9=9B=A2=20/=20Isolate=20runtime=20state=20and=20outp?= =?UTF-8?q?ut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++++++++ lib/rubycli/command_line.rb | 21 +++++++++++++++++++++ lib/rubycli/environment.rb | 8 ++++++++ lib/rubycli/eval_coercer.rb | 11 +++++++++-- lib/rubycli/result_emitter.rb | 2 +- test/command_line_test.rb | 31 +++++++++++++++++++++++++++++++ test/mode_coercion_test.rb | 13 +++++++++++++ test/result_emitter_test.rb | 13 +++++++++++++ 8 files changed, 104 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7928d6..4849c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ - Constructor arity errors raised by `--new` are now wrapped in Rubycli's user-facing runner error. - Positional type conversion now waits for JSON/eval coercion, matching keyword-option behavior and preserving `--new` JSON/eval inputs. - Runner tests now execute without terminating the Minitest process and assert converted command arguments instead of stubbed targets. +- `--check --new` now inspects exposed instance/class commands without running constructors, while `--check` rejects pre-scripts instead of evaluating them. +- Explicit nested constant names no longer fall back to inherited top-level constants, and malformed pre-scripts now produce contextual Rubycli errors. +- Rest, optional-before-required, and trailing-required positional arguments now follow Ruby's argument binding rules for conversion, validation, and help output. +- Documented scalar/list conversions now preserve numeric-looking strings, handle repeated booleans, return real `DateTime` values, accept JSON arrays, and reject scalar/array values where `JSON`/`Hash` shapes do not allow them. +- Assignment-like positional values remain positional unless they match a keyword, while matching assignments use the same documented conversion as long options. +- YARD positional tags are aligned by parameter name instead of comment order. +- Eval-mode local variables and command-line strict/check/result-output flags no longer leak across separate programmatic runs. +- Circular arrays/hashes returned by commands now fall back to inspected output instead of raising a JSON nesting error. ## [0.1.7] - 2025-11-12 diff --git a/lib/rubycli/command_line.rb b/lib/rubycli/command_line.rb index 743f7d6..5fd7a00 100644 --- a/lib/rubycli/command_line.rb +++ b/lib/rubycli/command_line.rb @@ -32,6 +32,9 @@ module CommandLine module_function def run(argv = ARGV) + previous_doc_check = Rubycli.environment.doc_check_mode? + previous_strict_input = Rubycli.environment.strict_input? + previous_print_result = Rubycli.environment.print_result? args = Array(argv).dup Rubycli.environment.enable_print_result! @@ -183,6 +186,24 @@ def run(argv = ARGV) rescue Rubycli::Runner::Error => e warn "[ERROR] #{e.message}" 1 + ensure + if previous_doc_check + Rubycli.environment.enable_doc_check! + else + Rubycli.environment.disable_doc_check! + end + + if previous_strict_input + Rubycli.environment.enable_strict_input! + else + Rubycli.environment.disable_strict_input! + end + + if previous_print_result + Rubycli.environment.enable_print_result! + else + Rubycli.environment.disable_print_result! + end end def likely_new_args_value?(token) diff --git a/lib/rubycli/environment.rb b/lib/rubycli/environment.rb index a641f7c..7ad0d9f 100644 --- a/lib/rubycli/environment.rb +++ b/lib/rubycli/environment.rb @@ -45,6 +45,10 @@ def strict_input? @strict_input end + def disable_strict_input! + @strict_input = false + end + def documentation_issues @documentation_issues.dup end @@ -92,6 +96,10 @@ def enable_print_result! @print_result = true end + def disable_print_result! + @print_result = false + end + private def fetch_env_value(key, default) diff --git a/lib/rubycli/eval_coercer.rb b/lib/rubycli/eval_coercer.rb index cdcd7f2..b569909 100644 --- a/lib/rubycli/eval_coercer.rb +++ b/lib/rubycli/eval_coercer.rb @@ -2,7 +2,7 @@ module Rubycli class EvalCoercer THREAD_KEY = :rubycli_eval_mode LAX_THREAD_KEY = :rubycli_eval_lax_mode - EVAL_BINDING = Object.new.instance_eval { binding } + BINDING_THREAD_KEY = :rubycli_eval_binding def eval_mode? Thread.current[THREAD_KEY] == true @@ -15,12 +15,15 @@ def eval_lax_mode? def with_eval_mode(enabled = true, lax: false) previous = Thread.current[THREAD_KEY] previous_lax = Thread.current[LAX_THREAD_KEY] + previous_binding = Thread.current[BINDING_THREAD_KEY] Thread.current[THREAD_KEY] = enabled Thread.current[LAX_THREAD_KEY] = enabled && lax + Thread.current[BINDING_THREAD_KEY] = isolated_binding if enabled yield ensure Thread.current[THREAD_KEY] = previous Thread.current[LAX_THREAD_KEY] = previous_lax + Thread.current[BINDING_THREAD_KEY] = previous_binding end def coerce_eval_value(value) @@ -44,7 +47,7 @@ def evaluate_string(expression) trimmed = expression.strip return trimmed if trimmed.empty? - EVAL_BINDING.eval(trimmed) + (Thread.current[BINDING_THREAD_KEY] || isolated_binding).eval(trimmed) rescue SyntaxError, NameError => e if eval_lax_mode? warn "[WARN] Failed to evaluate argument as Ruby (#{e.message.strip}). Passing it through because --eval-lax is enabled." @@ -53,5 +56,9 @@ def evaluate_string(expression) raise end end + + def isolated_binding + Object.new.instance_eval { binding } + end end end diff --git a/lib/rubycli/result_emitter.rb b/lib/rubycli/result_emitter.rb index cf8bab2..e0901fe 100644 --- a/lib/rubycli/result_emitter.rb +++ b/lib/rubycli/result_emitter.rb @@ -34,7 +34,7 @@ def format_result_output(result) result.inspect end end - rescue JSON::GeneratorError + rescue JSON::GeneratorError, JSON::NestingError result.inspect end end diff --git a/test/command_line_test.rb b/test/command_line_test.rb index 1142183..cd6da8b 100644 --- a/test/command_line_test.rb +++ b/test/command_line_test.rb @@ -184,6 +184,37 @@ def test_accepts_short_flag_for_check_mode assert_equal false, captured[:opts][:new] end + def test_check_mode_does_not_leak_into_a_later_programmatic_run + argv = ['--check', 'test/fixtures/doc_examples.rb'] + + Rubycli::Runner.stub(:check, 0) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + refute Rubycli.environment.doc_check_mode? + end + + def test_strict_mode_does_not_leak_into_a_later_programmatic_run + argv = ['--strict', 'test/fixtures/doc_examples.rb'] + + Rubycli::Runner.stub(:execute, nil) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + refute Rubycli.environment.strict_input? + end + + def test_print_result_mode_does_not_leak_into_a_later_programmatic_run + argv = ['test/fixtures/doc_examples.rb'] + Rubycli.environment.instance_variable_set(:@print_result, false) + + Rubycli::Runner.stub(:execute, nil) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + refute Rubycli.environment.print_result? + end + def test_pre_script_allows_space_separated_value argv = [ '--pre-script', diff --git a/test/mode_coercion_test.rb b/test/mode_coercion_test.rb index da2a32d..6e37ace 100644 --- a/test/mode_coercion_test.rb +++ b/test/mode_coercion_test.rb @@ -41,6 +41,19 @@ def test_eval_mode_handles_strings_with_embedded_quotes assert_equal({}, kw_args) end + def test_eval_binding_does_not_leak_locals_between_mode_scopes + Rubycli.with_eval_mode(true) do + assert_equal 41, Rubycli.coerce_eval_value('review_local = 41') + end + + observed = nil + Rubycli.with_eval_mode(true) do + observed = Rubycli.coerce_eval_value('defined?(review_local)') + end + + assert_nil observed + end + def test_eval_lax_mode_falls_back_to_original_string_on_syntax_error pos_args = ['https://example.com/'] kw_args = { ttl: '60*60*2' } diff --git a/test/result_emitter_test.rb b/test/result_emitter_test.rb index 990d23e..b008b8e 100644 --- a/test/result_emitter_test.rb +++ b/test/result_emitter_test.rb @@ -37,4 +37,17 @@ def test_skips_nil_and_class_results assert_equal '', output end + + def test_circular_array_falls_back_to_inspect + environment = Rubycli::Environment.new(env: { 'RUBYCLI_PRINT_RESULT' => 'true' }) + emitter = Rubycli::ResultEmitter.new(environment: environment) + result = [] + result << result + + output, _err = capture_io do + emitter.emit(result) + end + + assert_equal "[[...]]\n", output + end end From 99fbecf083e89feece6c9100ee0f425ced4f67d8 Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:29:42 +0900 Subject: [PATCH 06/20] =?UTF-8?q?fix:=20eval=E6=A7=8B=E6=96=87=E3=82=A8?= =?UTF-8?q?=E3=83=A9=E3=83=BC=E3=82=92=E6=AD=A3=E8=A6=8F=E5=8C=96=20/=20No?= =?UTF-8?q?rmalize=20eval=20syntax=20errors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/rubycli/eval_coercer.rb | 2 +- test/mode_coercion_test.rb | 43 +++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/lib/rubycli/eval_coercer.rb b/lib/rubycli/eval_coercer.rb index b569909..c90e70e 100644 --- a/lib/rubycli/eval_coercer.rb +++ b/lib/rubycli/eval_coercer.rb @@ -37,7 +37,7 @@ def coerce_eval_value(value) else value end - rescue StandardError => e + rescue SyntaxError, StandardError => e raise Rubycli::ArgumentError, "Failed to evaluate Ruby code: #{e.message}" end diff --git a/test/mode_coercion_test.rb b/test/mode_coercion_test.rb index 6e37ace..9e047c8 100644 --- a/test/mode_coercion_test.rb +++ b/test/mode_coercion_test.rb @@ -130,4 +130,47 @@ def test_json_coercion_invalid_payload end assert_match(/Failed to parse as JSON/, error.message) end + + def test_json_coercer_recursively_converts_arrays_and_hash_values + input = ['1', { 'enabled' => 'true', 'count' => 2 }] + + result = Rubycli.json_coercer.coerce_json_value(input) + + assert_equal [1, { 'enabled' => true, 'count' => 2 }], result + end + + def test_json_mode_is_restored_when_the_block_raises + error = assert_raises(RuntimeError) do + Rubycli.with_json_mode(true) do + assert Rubycli.json_mode? + raise 'boom' + end + end + + assert_equal 'boom', error.message + refute Rubycli.json_mode? + end + + def test_disabled_json_mode_remains_disabled_inside_scope + Rubycli.with_json_mode(false) do + refute Rubycli.json_mode? + end + + refute Rubycli.json_mode? + end + + def test_eval_mode_wraps_invalid_ruby_syntax_as_argument_error + pos_args = ['{'] + kw_args = {} + + error = assert_raises(Rubycli::ArgumentError) do + Rubycli.with_eval_mode(true) do + Rubycli.apply_argument_coercions(pos_args, kw_args) + end + end + + assert_includes error.message, 'Failed to evaluate Ruby code' + assert_includes error.message, 'syntax error' + refute Rubycli.eval_mode? + end end From caaab2eb1edb1259226abb044dcc9bf9ea23893d Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 14:29:42 +0900 Subject: [PATCH 07/20] =?UTF-8?q?test:=20=E3=82=AB=E3=83=90=E3=83=AC?= =?UTF-8?q?=E3=83=83=E3=82=B8=E3=82=B2=E3=83=BC=E3=83=88=E3=81=A8=E5=AE=9F?= =?UTF-8?q?=E8=A1=8C=E7=B5=8C=E8=B7=AF=E3=82=92=E5=BC=B7=E5=8C=96=20/=20En?= =?UTF-8?q?force=20coverage=20across=20runtime=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 49 +++++++++++ CHANGELOG.md | 4 + README.ja.md | 10 +++ README.md | 10 +++ test/argument_parser_test.rb | 9 ++ test/cli_test.rb | 147 ++++++++++++++++++++++++++++++++ test/command_line_test.rb | 153 ++++++++++++++++++++++++++++++++++ test/coverage_gate_test.rb | 56 +++++++++++++ test/coverage_runner.rb | 119 ++++++++++++++++++++++++++ test/result_emitter_test.rb | 57 +++++++++++++ test/runner_test.rb | 52 ++++++++++++ test/support/coverage_gate.rb | 87 +++++++++++++++++++ test/test_helper.rb | 1 + 13 files changed, 754 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 test/coverage_gate_test.rb create mode 100644 test/coverage_runner.rb create mode 100644 test/support/coverage_gate.rb diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1a5c1a1 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,49 @@ +name: Test + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Ruby ${{ matrix.ruby }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ruby: + - "3.0" + - "3.2" + - "3.4" + - "4.0" + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + - name: Set up Ruby + uses: ruby/setup-ruby@ec02537da5712d66d4d50a0f33b7eb52773b5ed1 + with: + ruby-version: ${{ matrix.ruby }} + - name: Run tests + run: ruby -Ilib:test -e 'Dir["test/*_test.rb"].sort.each { |file| require_relative file }' + + coverage: + name: Coverage + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + with: + fetch-depth: 0 + - name: Set up Ruby + uses: ruby/setup-ruby@ec02537da5712d66d4d50a0f33b7eb52773b5ed1 + with: + ruby-version: "3.4" + - name: Enforce coverage thresholds + run: ruby -Ilib:test test/coverage_runner.rb + env: + COVERAGE_BASE_REF: origin/${{ github.base_ref || github.event.repository.default_branch }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4849c02..12984c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,12 @@ - Assignment-like positional values remain positional unless they match a keyword, while matching assignments use the same documented conversion as long options. - YARD positional tags are aligned by parameter name instead of comment order. - Eval-mode local variables and command-line strict/check/result-output flags no longer leak across separate programmatic runs. +- Invalid Ruby syntax passed through strict eval mode now produces a user-facing Rubycli argument error instead of leaking a `SyntaxError` backtrace. - Circular arrays/hashes returned by commands now fall back to inspected output instead of raising a JSON nesting error. +### Testing +- Added dependency-free overall line, branch, and changed-line coverage gates plus GitHub Actions checks spanning the supported Ruby range. + ## [0.1.7] - 2025-11-12 ### Added diff --git a/README.ja.md b/README.ja.md index 7719ae3..87c6eee 100644 --- a/README.ja.md +++ b/README.ja.md @@ -508,6 +508,16 @@ rubycli --new='["a"]' \ - `examples/fallback_example.rb` / `examples/fallback_example_with_extra_docs.rb` — シグネチャからの補完とコメント不一致のデモ +## 開発時の検証 + +全テストを実行し、リポジトリのカバレッジ基準 +(全体 line 90%、branch 70%、`origin/main` から変更した実行可能行 90%) +を検査するには次を実行します。 + +```bash +ruby -Ilib:test test/coverage_runner.rb +``` + ## ライセンス MIT。[LICENSE](LICENSE) を参照してください。 diff --git a/README.md b/README.md index 7bb2605..84c3ebd 100644 --- a/README.md +++ b/README.md @@ -539,6 +539,16 @@ rubycli --new='["a"]' \ - `examples/fallback_example.rb` / `examples/fallback_example_with_extra_docs.rb` — signature fallback and doc-mismatch demos +## Development verification + +Run the full test suite and enforce the repository's coverage thresholds +(90% overall line coverage, 70% branch coverage, and 90% coverage of +executable lines changed from `origin/main`) with: + +```bash +ruby -Ilib:test test/coverage_runner.rb +``` + ## License MIT. See [LICENSE](LICENSE). diff --git a/test/argument_parser_test.rb b/test/argument_parser_test.rb index d9dc6c1..04775ae 100644 --- a/test/argument_parser_test.rb +++ b/test/argument_parser_test.rb @@ -172,6 +172,15 @@ def test_required_option_accepts_true_as_an_explicit_value assert_equal({ greeting: true }, kw_args) end + def test_double_dash_preserves_following_option_like_values_as_positionals + callable = ->(*values) { values } + + pos_args, kw_args = @parser.parse(['--', '--literal', 'name=value'], callable) + + assert_equal ['--literal', 'name=value'], pos_args + assert_empty kw_args + end + def test_undocumented_required_keyword_rejects_following_option_as_its_value method = UndocumentedKeywordSamples.method(:call) diff --git a/test/cli_test.rb b/test/cli_test.rb index 3a4dbc3..353ccfd 100644 --- a/test/cli_test.rb +++ b/test/cli_test.rb @@ -92,6 +92,153 @@ def test_help_input_skips_strict_validation assert_includes out, 'Usage:' assert_equal '', err end + + def test_top_level_help_lists_available_commands_without_invoking_them + target = Module.new do + def self.greet(name) + "Hello, #{name}" + end + end + + status = nil + out, err = capture_io do + status = @cli.run(target, ['--help']) + end + + assert_equal 0, status + assert_includes out, 'Available commands:' + assert_includes out, 'greet' + assert_equal '', err + end + + def test_run_invokes_a_documented_command_with_converted_arguments + received = nil + target = Module.new do + define_singleton_method(:repeat) do |count| + received = count + count * 2 + end + end + + status = @cli.run(target, %w[repeat 3]) + + assert_equal 0, status + assert_equal 3, received + end + + def test_missing_command_prints_help_for_non_callable_target + target = Module.new do + def self.available + :ok + end + end + + status = nil + out, _err = capture_io do + status = @cli.run(target, ['missing']) + end + + assert_equal 1, status + assert_includes out, "Command 'missing' is not available." + assert_includes out, 'available' + end + + def test_callable_target_receives_reconstructed_keyword_arguments + received = nil + target = lambda do |name:, loud: false| + received = [name, loud] + :ok + end + + status = @cli.run(target, %w[--name Ruby --loud]) + + assert_equal 0, status + assert_equal ['Ruby', true], received + end + + def test_missing_required_argument_returns_usage_in_cli_mode + target = Module.new do + def self.greet(name) + name + end + end + + status = nil + out, _err = capture_io do + status = @cli.run(target, ['greet'], true) + end + + assert_equal 1, status + assert_includes out, 'wrong number of arguments' + assert_includes out, 'Usage: rubycli greet' + end + + def test_application_argument_error_is_not_reclassified_as_usage_error + target = Module.new do + def self.explode(value) + raise ::ArgumentError, "wrong number of arguments inside #{value}" + end + end + + error = assert_raises(::ArgumentError) do + @cli.run(target, %w[explode payload], true) + end + + assert_includes error.message, 'inside payload' + end + + def test_hyphenated_command_resolves_to_snake_case_method + target = Module.new do + def self.hello_world + :ok + end + end + + method_obj = @cli.find_method(target, 'hello-world') + + assert_equal :hello_world, method_obj.name + assert_equal target.method(:hello_world), method_obj + end + + def test_instance_catalog_exposes_duplicate_instance_and_class_commands + klass = Class.new do + def run + :instance + end + + def self.run + :class + end + end + target = klass.new + + catalog = @cli.command_catalog_for(target) + + assert_equal %w[class::run run], catalog.commands.sort + assert_equal ['run'], catalog.duplicates + assert_equal :instance, catalog.lookup('instance::run').method.call + assert_equal :class, catalog.lookup('class::run').method.call + assert_equal ['run'], catalog.entries_for(:instance).map(&:command) + end + + def test_generated_accessors_are_not_exposed_as_commands + klass = Class.new do + attr_accessor :value + + def run + :ok + end + end + + assert_equal ['run'], @cli.available_commands(klass.new) + end + + def test_usage_and_description_delegate_to_documented_method_renderer + method_obj = ChoiceDocSamples.method(:report) + + assert_includes @cli.usage_for_method('report', method_obj), 'Usage: rubycli report LEVEL' + assert_equal 'LEVEL', @cli.method_description(method_obj) + end end module ChoiceDocSamples module_function diff --git a/test/command_line_test.rb b/test/command_line_test.rb index cd6da8b..630384c 100644 --- a/test/command_line_test.rb +++ b/test/command_line_test.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'test_helper' +require 'tempfile' class CommandLineTest < Minitest::Test def setup @@ -253,4 +254,156 @@ def test_debug_flag_is_rejected_with_helpful_message assert_includes err, 'RUBYCLI_DEBUG=true' end end + + def test_help_flag_returns_success_without_invoking_runner + Rubycli::Runner.stub(:execute, ->(*) { flunk 'Runner should not be invoked for help' }) do + status = nil + out, err = capture_io do + status = Rubycli::CommandLine.run(['--help']) + end + + assert_equal 0, status + assert_includes out, 'Usage: rubycli' + assert_equal '', err + end + end + + def test_long_new_flag_consumes_a_separate_list_argument + captured = nil + argv = ['--new', 'alpha,beta', 'examples/new_mode_runner.rb'] + + Rubycli::Runner.stub(:execute, ->(*args, **opts) { captured = [args, opts] }) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + assert_equal 'alpha,beta', captured.last[:new_args] + assert_equal true, captured.last[:new] + end + + def test_short_new_flag_without_constructor_value_preserves_target_path + captured = nil + argv = ['-n', 'examples/new_mode_runner.rb'] + + Rubycli::Runner.stub(:execute, ->(*args, **opts) { captured = [args, opts] }) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + assert_equal 'examples/new_mode_runner.rb', captured.first.first + assert_nil captured.last[:new_args] + assert_equal true, captured.last[:new] + end + + def test_init_alias_records_its_inline_source_context + captured = nil + argv = ['--init=current', 'test/fixtures/doc_examples.rb'] + + Rubycli::Runner.stub(:execute, ->(*args, **opts) { captured = [args, opts] }) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + assert_equal( + [{ value: 'current', context: '(inline --init)' }], + captured.last[:pre_scripts] + ) + end + + def test_pre_script_without_source_is_rejected + status = nil + out, err = capture_io do + status = Rubycli::CommandLine.run(['--pre-script']) + end + + assert_equal 1, status + assert_equal '', out + assert_includes err, '--pre-script requires a file path or inline Ruby code' + end + + def test_pre_script_file_records_its_expanded_path + captured = nil + Tempfile.create(['rubycli-pre-script', '.rb']) do |file| + file.write('current') + file.flush + argv = ['--pre-script', file.path, 'test/fixtures/doc_examples.rb'] + + Rubycli::Runner.stub(:execute, ->(*args, **opts) { captured = [args, opts] }) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + assert_equal File.expand_path(file.path), captured.last[:pre_scripts].first[:context] + end + end + + def test_auto_target_and_print_result_flags_are_forwarded_or_consumed + captured = nil + argv = ['--print-result', '--auto-target', 'test/fixtures/doc_examples.rb'] + + Rubycli::Runner.stub(:execute, ->(*args, **opts) { captured = [args, opts] }) do + assert_equal 0, Rubycli::CommandLine.run(argv) + end + + assert_equal :auto, captured.last[:constant_mode] + assert_empty captured.first[2] + end + + def test_flags_without_target_print_usage + status = nil + out, err = capture_io do + status = Rubycli::CommandLine.run(['--strict']) + end + + assert_equal 1, status + assert_includes out, 'Usage: rubycli' + assert_equal '', err + end + + def test_check_rejects_argument_modes_and_forwarded_commands + [ + [['--check', '--json-args', 'target.rb'], '--check cannot be combined'], + [['--check', 'target.rb', 'run'], '--check does not accept command arguments'] + ].each do |argv, message| + status = nil + _out, err = capture_io do + status = Rubycli::CommandLine.run(argv) + end + + assert_equal 1, status + assert_includes err, message + end + end + + def test_runner_errors_are_reported_without_backtrace + [ + Rubycli::Runner::PreScriptError.new('bad pre-script'), + Rubycli::Runner::Error.new('bad runner') + ].each do |runner_error| + status = nil + _out, err = capture_io do + Rubycli::Runner.stub(:execute, ->(*) { raise runner_error }) do + status = Rubycli::CommandLine.run(['target.rb']) + end + end + + assert_equal 1, status + assert_includes err, runner_error.message + refute_includes err, 'test/command_line_test.rb' + end + end + + def test_preexisting_environment_modes_are_restored + Rubycli.environment.enable_doc_check! + Rubycli.environment.enable_strict_input! + Rubycli.environment.enable_print_result! + + Rubycli::Runner.stub(:execute, nil) do + assert_equal 0, Rubycli::CommandLine.run(['target.rb']) + end + + assert Rubycli.environment.doc_check_mode? + assert Rubycli.environment.strict_input? + assert Rubycli.environment.print_result? + ensure + Rubycli.environment.disable_doc_check! + Rubycli.environment.disable_strict_input! + Rubycli.environment.instance_variable_set(:@print_result, @previous_print_result) + end end diff --git a/test/coverage_gate_test.rb b/test/coverage_gate_test.rb new file mode 100644 index 0000000..01bb190 --- /dev/null +++ b/test/coverage_gate_test.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'test_helper' +require_relative 'support/coverage_gate' + +class CoverageGateTest < Minitest::Test + def test_changed_lines_extracts_added_and_replaced_new_lines + diff = <<~DIFF + diff --git a/lib/example.rb b/lib/example.rb + --- a/lib/example.rb + +++ b/lib/example.rb + @@ -1 +1,2 @@ + -old + +replacement + +added + @@ -5,0 +7 @@ + +tail + DIFF + + assert_equal( + { 'lib/example.rb' => [1, 2, 7] }, + CoverageGate.changed_lines(diff) + ) + end + + def test_changed_lines_ignores_deleted_files_and_non_library_paths + diff = <<~DIFF + diff --git a/lib/removed.rb b/lib/removed.rb + --- a/lib/removed.rb + +++ /dev/null + @@ -1 +0,0 @@ + -removed + diff --git a/README.md b/README.md + --- a/README.md + +++ b/README.md + @@ -1 +1 @@ + -old + +new + DIFF + + assert_equal({}, CoverageGate.changed_lines(diff, path_prefix: 'lib/')) + end + + def test_coverage_stats_count_only_executable_changed_lines + changed_lines = { 'lib/example.rb' => [1, 2, 3, 4, 8] } + line_hits = { + 'lib/example.rb' => [nil, 1, 0, 2] + } + + stats = CoverageGate.coverage_stats(changed_lines, line_hits) + + assert_equal 2, stats.covered + assert_equal 3, stats.total + assert_equal [['lib/example.rb', 3]], stats.uncovered + end +end diff --git a/test/coverage_runner.rb b/test/coverage_runner.rb new file mode 100644 index 0000000..6a4cdda --- /dev/null +++ b/test/coverage_runner.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require 'coverage' +require_relative 'support/coverage_gate' + +MIN_LINE_COVERAGE = 90.0 +MIN_BRANCH_COVERAGE = 70.0 +MIN_CHANGED_LINE_COVERAGE = 90.0 + +Coverage.start(lines: true, branches: true) + +require_relative 'test_helper' + +def coverage_percentage(covered, total) + return 100.0 if total.zero? + + covered.fdiv(total) * 100 +end + +Minitest.after_run do + library_root = File.expand_path('../lib', __dir__) + File::SEPARATOR + rows = Coverage.result.filter_map do |path, data| + next unless path.start_with?(library_root) + + line_hits = Array(data[:lines]) + line_counts = line_hits.compact + branch_counts = (data[:branches] || {}).values.flat_map(&:values) + { + file: path.delete_prefix(library_root), + line_hits: line_hits, + covered_lines: line_counts.count(&:positive?), + total_lines: line_counts.size, + covered_branches: branch_counts.count(&:positive?), + total_branches: branch_counts.size + } + end.sort_by { |row| row[:file] } + + covered_lines = rows.sum { |row| row[:covered_lines] } + total_lines = rows.sum { |row| row[:total_lines] } + covered_branches = rows.sum { |row| row[:covered_branches] } + total_branches = rows.sum { |row| row[:total_branches] } + line_coverage = coverage_percentage(covered_lines, total_lines) + branch_coverage = coverage_percentage(covered_branches, total_branches) + coverage_base_ref = ENV.fetch('COVERAGE_BASE_REF', 'origin/main') + changed_stats = nil + changed_error = nil + + begin + diff = CoverageGate.diff_against(coverage_base_ref, chdir: File.expand_path('..', __dir__)) + changed_line_numbers = CoverageGate.changed_lines(diff) + line_hits = rows.to_h { |row| ["lib/#{row[:file]}", row[:line_hits]] } + changed_stats = CoverageGate.coverage_stats(changed_line_numbers, line_hits) + rescue CoverageGate::Error => e + changed_error = e.message + end + + puts + puts 'Coverage by file:' + rows.each do |row| + file_line_coverage = coverage_percentage(row[:covered_lines], row[:total_lines]) + file_branch_coverage = coverage_percentage(row[:covered_branches], row[:total_branches]) + puts format( + ' %-45s lines %6.2f%% (%d/%d), branches %6.2f%% (%d/%d)', + row[:file], + file_line_coverage, + row[:covered_lines], + row[:total_lines], + file_branch_coverage, + row[:covered_branches], + row[:total_branches] + ) + end + + puts format( + 'Total coverage: lines %.2f%% (%d/%d), branches %.2f%% (%d/%d)', + line_coverage, + covered_lines, + total_lines, + branch_coverage, + covered_branches, + total_branches + ) + if changed_stats + changed_line_coverage = coverage_percentage(changed_stats.covered, changed_stats.total) + puts format( + 'Changed-line coverage against %s: %.2f%% (%d/%d)', + coverage_base_ref, + changed_line_coverage, + changed_stats.covered, + changed_stats.total + ) + unless changed_stats.uncovered.empty? + puts "Uncovered changed lines: #{changed_stats.uncovered.map { |path, line| "#{path}:#{line}" }.join(', ')}" + end + else + puts "Changed-line coverage unavailable: #{changed_error}" + end + + failures = [] + failures << format('line coverage %.2f%% is below %.2f%%', line_coverage, MIN_LINE_COVERAGE) if line_coverage < MIN_LINE_COVERAGE + if branch_coverage < MIN_BRANCH_COVERAGE + failures << format('branch coverage %.2f%% is below %.2f%%', branch_coverage, MIN_BRANCH_COVERAGE) + end + if changed_stats + if changed_line_coverage < MIN_CHANGED_LINE_COVERAGE + failures << format( + 'changed-line coverage %.2f%% is below %.2f%%', + changed_line_coverage, + MIN_CHANGED_LINE_COVERAGE + ) + end + else + failures << "changed-line coverage unavailable: #{changed_error}" + end + + abort "Coverage gate failed: #{failures.join('; ')}" unless failures.empty? +end + +Dir[File.expand_path('*_test.rb', __dir__)].sort.each { |file| require file } diff --git a/test/result_emitter_test.rb b/test/result_emitter_test.rb index b008b8e..16a28f1 100644 --- a/test/result_emitter_test.rb +++ b/test/result_emitter_test.rb @@ -50,4 +50,61 @@ def test_circular_array_falls_back_to_inspect assert_equal "[[...]]\n", output end + + def test_emits_scalars_as_plain_text + environment = Rubycli::Environment.new(env: { 'RUBYCLI_PRINT_RESULT' => 'true' }) + emitter = Rubycli::ResultEmitter.new(environment: environment) + + output, _err = capture_io do + emitter.emit('text') + emitter.emit(42) + emitter.emit(true) + emitter.emit(false) + end + + assert_equal "text\n42\ntrue\nfalse\n", output + end + + def test_suppresses_empty_string_result + environment = Rubycli::Environment.new(env: { 'RUBYCLI_PRINT_RESULT' => 'true' }) + emitter = Rubycli::ResultEmitter.new(environment: environment) + + output, _err = capture_io do + emitter.emit('') + end + + assert_equal '', output + end + + def test_serializes_objects_through_to_h_or_to_ary + environment = Rubycli::Environment.new(env: { 'RUBYCLI_PRINT_RESULT' => 'true' }) + emitter = Rubycli::ResultEmitter.new(environment: environment) + hash_like = Struct.new(:name).new('Ruby') + array_like = Class.new do + def to_ary + %w[alpha beta] + end + end.new + + output, _err = capture_io do + emitter.emit(hash_like) + emitter.emit(array_like) + end + + assert_includes output, "\"name\": \"Ruby\"" + assert_includes output, '"alpha"' + assert_includes output, '"beta"' + end + + def test_falls_back_to_inspect_for_plain_objects + environment = Rubycli::Environment.new(env: { 'RUBYCLI_PRINT_RESULT' => 'true' }) + emitter = Rubycli::ResultEmitter.new(environment: environment) + result = Object.new + + output, _err = capture_io do + emitter.emit(result) + end + + assert_equal "#{result.inspect}\n", output + end end diff --git a/test/runner_test.rb b/test/runner_test.rb index 018424b..753881e 100644 --- a/test/runner_test.rb +++ b/test/runner_test.rb @@ -4,6 +4,58 @@ require 'tmpdir' class RunnerTest < Minitest::Test + def test_execute_rejects_json_and_eval_modes_before_loading_target + error = assert_raises(Rubycli::Runner::Error) do + Rubycli::Runner.execute('missing-target.rb', json: true, eval_args: true) + end + + assert_includes error.message, '--json-args cannot be combined' + refute_includes error.message, 'File not found' + end + + def test_instantiate_target_supports_keyword_constructor_arguments + target = Class.new do + attr_reader :name + + def initialize(name:) + @name = name + end + end + + instance = Rubycli::Runner.instantiate_target(target, [[], { name: 'Ruby' }]) + + assert_instance_of target, instance + assert_equal 'Ruby', instance.name + end + + def test_instantiate_target_extends_modules_and_preserves_plain_objects + extension = Module.new do + def greeting + 'hello' + end + end + plain_target = Object.new + + extended = Rubycli::Runner.instantiate_target(extension) + + assert_equal 'hello', extended.greeting + assert_same plain_target, Rubycli::Runner.instantiate_target(plain_target) + end + + def test_find_target_path_adds_rb_extension_and_reports_missing_file + Dir.mktmpdir do |dir| + path_without_extension = File.join(dir, 'extension_runner') + File.write("#{path_without_extension}.rb", "# runner\n") + + assert_equal "#{path_without_extension}.rb", Rubycli::Runner.find_target_path(path_without_extension) + + error = assert_raises(Rubycli::Runner::Error) do + Rubycli::Runner.find_target_path(File.join(dir, 'missing')) + end + assert_includes error.message, 'File not found' + end + end + def test_execute_infers_constant_and_instantiates_when_new_flag Dir.mktmpdir do |dir| file = File.join(dir, 'sample_runner.rb') diff --git a/test/support/coverage_gate.rb b/test/support/coverage_gate.rb new file mode 100644 index 0000000..fec8162 --- /dev/null +++ b/test/support/coverage_gate.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'open3' + +module CoverageGate + CoverageStats = Struct.new(:covered, :total, :uncovered, keyword_init: true) + Error = Class.new(StandardError) + + module_function + + def changed_lines(diff, path_prefix: 'lib/') + lines_by_path = Hash.new { |hash, path| hash[path] = [] } + current_path = nil + current_new_line = nil + + diff.each_line do |line| + if line.start_with?('+++ ') + path = line.delete_prefix('+++ ').strip + current_path = path == '/dev/null' ? nil : path.delete_prefix('b/') + current_path = nil unless current_path&.start_with?(path_prefix) + next + end + + if (match = line.match(/\A@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/)) + current_new_line = match[1].to_i + next + end + + next unless current_new_line + + case line + when /\A\+(?!\+\+)/ + lines_by_path[current_path] << current_new_line if current_path + current_new_line += 1 + when /\A-(?!--)/ + next + when /\A / + current_new_line += 1 + end + end + + lines_by_path.transform_values { |lines| lines.uniq.sort } + end + + def coverage_stats(changed_lines_by_path, line_hits_by_path) + covered = 0 + total = 0 + uncovered = [] + + changed_lines_by_path.each do |path, line_numbers| + line_hits = line_hits_by_path[path] + next unless line_hits + + line_numbers.each do |line_number| + hits = line_hits[line_number - 1] + next if hits.nil? + + total += 1 + if hits.positive? + covered += 1 + else + uncovered << [path, line_number] + end + end + end + + CoverageStats.new(covered: covered, total: total, uncovered: uncovered) + end + + def diff_against(base_ref, chdir:) + merge_base, merge_error, merge_status = Open3.capture3( + 'git', 'merge-base', 'HEAD', base_ref, + chdir: chdir + ) + unless merge_status.success? + raise Error, "cannot resolve coverage base #{base_ref.inspect}: #{merge_error.strip}" + end + + diff, diff_error, diff_status = Open3.capture3( + 'git', 'diff', '--unified=0', '--no-color', merge_base.strip, '--', 'lib', + chdir: chdir + ) + raise Error, "cannot read changed lines: #{diff_error.strip}" unless diff_status.success? + + diff + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index c3d7335..7007c6e 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -2,6 +2,7 @@ $LOAD_PATH.unshift(File.expand_path('../lib', __dir__)) +ENV['MT_NO_PLUGINS'] = '1' require 'minitest/autorun' require 'rubycli' From f088eb0c3a683deaa80bab4d938584c0a83595d0 Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:11:15 +0900 Subject: [PATCH 08/20] =?UTF-8?q?fix:=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=81=A7=E7=A2=BA=E8=AA=8D=E3=81=97=E3=81=9F=E5=A2=83?= =?UTF-8?q?=E7=95=8C=E4=B8=8D=E5=85=B7=E5=90=88=E3=82=92=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=20/=20Fix=20reviewed=20boundary=20regressions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 4 ++++ lib/rubycli/argument_parser.rb | 9 +++++++-- lib/rubycli/constant_capture.rb | 13 ++++++++++++- test/argument_parser_test.rb | 18 ++++++++++++++++++ test/constant_capture_test.rb | 23 +++++++++++++++++++++++ test/coverage_gate_test.rb | 13 +++++++++++++ test/support/coverage_gate.rb | 6 +++++- 7 files changed, 82 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12984c5..bc85043 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ ### Fixed - Parameterless commands now reject unexpected arguments without invoking the target method or attempting implicit return-value traversal. - Required options now report a missing value instead of consuming the following option token, while explicit values such as `true` remain valid. +- Required options accept negative exponent notation such as `-1e3` without mistaking it for another option. - Constant discovery now includes classes and modules assigned with `Class.new` / `Module.new` during the target file load. +- Repeated loads retain assigned constant aliases when the source file is unchanged. - Constructor arity errors raised by `--new` are now wrapped in Rubycli's user-facing runner error. - Positional type conversion now waits for JSON/eval coercion, matching keyword-option behavior and preserving `--new` JSON/eval inputs. - Runner tests now execute without terminating the Minitest process and assert converted command arguments instead of stubbed targets. @@ -13,6 +15,7 @@ - Explicit nested constant names no longer fall back to inherited top-level constants, and malformed pre-scripts now produce contextual Rubycli errors. - Rest, optional-before-required, and trailing-required positional arguments now follow Ruby's argument binding rules for conversion, validation, and help output. - Documented scalar/list conversions now preserve numeric-looking strings, handle repeated booleans, return real `DateTime` values, accept JSON arrays, and reject scalar/array values where `JSON`/`Hash` shapes do not allow them. +- Positional `Symbol` annotations preserve colon-prefixed symbol literals instead of embedding the colon in the symbol name. - Assignment-like positional values remain positional unless they match a keyword, while matching assignments use the same documented conversion as long options. - YARD positional tags are aligned by parameter name instead of comment order. - Eval-mode local variables and command-line strict/check/result-output flags no longer leak across separate programmatic runs. @@ -21,6 +24,7 @@ ### Testing - Added dependency-free overall line, branch, and changed-line coverage gates plus GitHub Actions checks spanning the supported Ruby range. +- Changed-line coverage now treats new library files that were never loaded by the test suite as uncovered. ## [0.1.7] - 2025-11-12 diff --git a/lib/rubycli/argument_parser.rb b/lib/rubycli/argument_parser.rb index e51dbe5..694e330 100644 --- a/lib/rubycli/argument_parser.rb +++ b/lib/rubycli/argument_parser.rb @@ -687,7 +687,10 @@ def converter_for_single_type(type) when 'Boolean', 'TrueClass', 'FalseClass' ->(value) { TypeUtils.convert_boolean(value) } when 'Symbol' - ->(value) { value.to_sym } + ->(value) { + converted_value = convert_arg(value) + converted_value.is_a?(Symbol) ? converted_value : value.to_sym + } when 'BigDecimal', 'Decimal' require 'bigdecimal' ->(value) { @@ -781,7 +784,9 @@ def looks_like_option?(token) return false unless token return false if token == '--' - token.start_with?('-') && !(token =~ /\A-?\d+(\.\d+)?\z/) + token.start_with?('-') && !token.match?( + /\A-(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\z/ + ) end end end diff --git a/lib/rubycli/constant_capture.rb b/lib/rubycli/constant_capture.rb index 3064c1a..5b179bd 100644 --- a/lib/rubycli/constant_capture.rb +++ b/lib/rubycli/constant_capture.rb @@ -1,14 +1,23 @@ # frozen_string_literal: true +require 'digest' + module Rubycli # Observes constants defined while loading a file. class ConstantCapture def initialize @captured = Hash.new { |hash, key| hash[key] = [] } + @source_fingerprints = {} end def capture(file) normalized_file = normalize(file) + source_fingerprint = Digest::SHA256.file(normalized_file).hexdigest + previous_names = if @source_fingerprints[normalized_file] == source_fingerprint + @captured[normalized_file].dup + else + [] + end @captured[normalized_file] = [] before_snapshot = constant_snapshot(normalized_file) trace = TracePoint.new(:class) do |tp| @@ -30,7 +39,9 @@ def capture(file) changed_names = after_snapshot.keys.select do |name| before_snapshot[name] != after_snapshot[name] end - @captured[normalized_file].concat(changed_names) + retained_names = previous_names.select { |name| after_snapshot.key?(name) } + @captured[normalized_file].concat(changed_names).concat(retained_names) + @source_fingerprints[normalized_file] = source_fingerprint end end diff --git a/test/argument_parser_test.rb b/test/argument_parser_test.rb index 04775ae..3e08e02 100644 --- a/test/argument_parser_test.rb +++ b/test/argument_parser_test.rb @@ -395,6 +395,15 @@ def test_standard_type_hints_convert_to_stdlib_classes assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } end + def test_required_option_accepts_negative_exponent_value + method = StdTypeSamples.method(:ingest) + + pos_args, kw_args = @parser.parse(['--budget', '-1e3'], method) + + assert_empty pos_args + assert_equal BigDecimal('-1e3'), kw_args[:budget] + end + def test_json_type_accepts_an_array_literal method = JsonTypeSamples.method(:accept) @@ -446,6 +455,15 @@ def test_symbol_annotation_converts_numeric_looking_token assert_empty kw_args end + def test_symbol_annotation_preserves_symbol_literal_value + method = ScalarTypeSamples.method(:symbol) + + pos_args, kw_args = @parser.parse([':foo'], method) + + assert_equal [:foo], pos_args + assert_empty kw_args + end + def test_string_array_annotation_preserves_numeric_looking_token method = ScalarTypeSamples.method(:string_list) diff --git a/test/constant_capture_test.rb b/test/constant_capture_test.rb index a0a3ddc..31f2422 100644 --- a/test/constant_capture_test.rb +++ b/test/constant_capture_test.rb @@ -60,6 +60,29 @@ def test_records_module_assigned_to_constant end end + def test_records_assigned_alias_when_same_file_is_loaded_twice + capture = Rubycli::ConstantCapture.new + Tempfile.create(['assigned_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureAliasSource + def self.run; end + end + CaptureAssignedAlias = CaptureAliasSource + RUBY + file.flush + + 2.times do + capture_io do + capture.capture(file.path) { load file.path } + end + assert_includes capture.constants_for(file.path), 'CaptureAssignedAlias' + end + ensure + cleanup_constant(:CaptureAssignedAlias) + cleanup_constant(:CaptureAliasSource) + end + end + private def cleanup_constant(name) diff --git a/test/coverage_gate_test.rb b/test/coverage_gate_test.rb index 01bb190..5a005ef 100644 --- a/test/coverage_gate_test.rb +++ b/test/coverage_gate_test.rb @@ -53,4 +53,17 @@ def test_coverage_stats_count_only_executable_changed_lines assert_equal 3, stats.total assert_equal [['lib/example.rb', 3]], stats.uncovered end + + def test_coverage_stats_treat_unloaded_changed_files_as_uncovered + changed_lines = { 'lib/unloaded.rb' => [1, 2] } + + stats = CoverageGate.coverage_stats(changed_lines, {}) + + assert_equal 0, stats.covered + assert_equal 2, stats.total + assert_equal( + [['lib/unloaded.rb', 1], ['lib/unloaded.rb', 2]], + stats.uncovered + ) + end end diff --git a/test/support/coverage_gate.rb b/test/support/coverage_gate.rb index fec8162..963257c 100644 --- a/test/support/coverage_gate.rb +++ b/test/support/coverage_gate.rb @@ -49,7 +49,11 @@ def coverage_stats(changed_lines_by_path, line_hits_by_path) changed_lines_by_path.each do |path, line_numbers| line_hits = line_hits_by_path[path] - next unless line_hits + unless line_hits + total += line_numbers.size + uncovered.concat(line_numbers.map { |line_number| [path, line_number] }) + next + end line_numbers.each do |line_number| hits = line_hits[line_number - 1] From a200ce31c700d20e1806dca810a0dab7a7205cd7 Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:19:11 +0900 Subject: [PATCH 09/20] =?UTF-8?q?fix:=20Ruby=E3=82=BB=E3=83=83=E3=83=88?= =?UTF-8?q?=E3=82=A2=E3=83=83=E3=83=97Action=E3=82=92=E6=9B=B4=E6=96=B0=20?= =?UTF-8?q?/=20Update=20Ruby=20setup=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1a5c1a1..90626d1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -24,7 +24,7 @@ jobs: - name: Check out repository uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 - name: Set up Ruby - uses: ruby/setup-ruby@ec02537da5712d66d4d50a0f33b7eb52773b5ed1 + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 with: ruby-version: ${{ matrix.ruby }} - name: Run tests @@ -40,7 +40,7 @@ jobs: with: fetch-depth: 0 - name: Set up Ruby - uses: ruby/setup-ruby@ec02537da5712d66d4d50a0f33b7eb52773b5ed1 + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 with: ruby-version: "3.4" - name: Enforce coverage thresholds From dc432627beb548938b63114245892d0da3b192da Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:23:03 +0900 Subject: [PATCH 10/20] =?UTF-8?q?fix:=20Ruby=203.0=E3=81=A84.0=E3=81=AECI?= =?UTF-8?q?=E4=BA=92=E6=8F=9B=E6=80=A7=E3=82=92=E4=BF=AE=E6=AD=A3=20/=20Fi?= =?UTF-8?q?x=20Ruby=203.0=20and=204.0=20CI=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 5 ++++- test/test_helper.rb | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 90626d1..6256786 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,8 +27,11 @@ jobs: uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 with: ruby-version: ${{ matrix.ruby }} + - name: Install Ruby 4 test compatibility + if: matrix.ruby == '4.0' + run: gem install minitest-mock --version 5.27.0 --no-document - name: Run tests - run: ruby -Ilib:test -e 'Dir["test/*_test.rb"].sort.each { |file| require_relative file }' + run: ruby -Ilib:test -e 'Dir["test/*_test.rb"].sort.each { |file| require File.expand_path(file) }' coverage: name: Coverage diff --git a/test/test_helper.rb b/test/test_helper.rb index 7007c6e..74b86a5 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,6 +4,7 @@ ENV['MT_NO_PLUGINS'] = '1' require 'minitest/autorun' +require 'minitest/mock' require 'rubycli' Dir[File.expand_path('fixtures/**/*.rb', __dir__)].sort.each do |path| From 9eeb9977671c758bdd01c1f333e722a7901a7976 Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:26:06 +0900 Subject: [PATCH 11/20] =?UTF-8?q?fix:=20Ruby=203.0=E3=81=AEMinitest?= =?UTF-8?q?=E4=BA=92=E6=8F=9B=E6=80=A7=E3=82=92=E5=9B=BA=E5=AE=9A=20/=20Pi?= =?UTF-8?q?n=20Ruby=203.0=20Minitest=20compatibility?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6256786..08af8c3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,6 +27,9 @@ jobs: uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 with: ruby-version: ${{ matrix.ruby }} + - name: Install Ruby 3.0 test compatibility + if: matrix.ruby == '3.0' + run: gem install minitest --version 5.26.1 --no-document - name: Install Ruby 4 test compatibility if: matrix.ruby == '4.0' run: gem install minitest-mock --version 5.27.0 --no-document From f9f322f58a220095230b29a8bad9c060d3374ced Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:56:19 +0900 Subject: [PATCH 12/20] =?UTF-8?q?fix:=20Codex=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E3=82=92=E4=BF=AE=E6=AD=A3=20/=20Fi?= =?UTF-8?q?x=20Codex=20review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yml | 2 +- CHANGELOG.md | 6 + README.ja.md | 8 +- README.md | 12 +- lib/rubycli.rb | 54 ++--- lib/rubycli/argument_parser.rb | 10 +- lib/rubycli/constant_capture.rb | 207 ++++++++++++++++++-- lib/rubycli/eval_coercer.rb | 14 +- lib/rubycli/help_renderer.rb | 4 +- test/argument_parser_test.rb | 36 ++++ test/constant_capture_test.rb | 336 ++++++++++++++++++++++++++++++++ test/help_renderer_test.rb | 16 ++ test/runner_test.rb | 156 +++++++++++++++ 13 files changed, 813 insertions(+), 48 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 08af8c3..d3e8b93 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -52,4 +52,4 @@ jobs: - name: Enforce coverage thresholds run: ruby -Ilib:test test/coverage_runner.rb env: - COVERAGE_BASE_REF: origin/${{ github.base_ref || github.event.repository.default_branch }} + COVERAGE_BASE_REF: ${{ github.event_name == 'push' && github.event.created == false && github.event.deleted == false && github.event.before || format('origin/{0}', github.base_ref || github.event.repository.default_branch) }} diff --git a/CHANGELOG.md b/CHANGELOG.md index bc85043..9c15db1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,13 @@ - Required options accept negative exponent notation such as `-1e3` without mistaking it for another option. - Constant discovery now includes classes and modules assigned with `Class.new` / `Module.new` during the target file load. - Repeated loads retain assigned constant aliases when the source file is unchanged. +- Repeated loads after source edits retain aliases created by direct, guarded, multiple, and `const_set` assignments while their active definitions remain, without reviving aliases behind newly disabled conditions. - Constructor arity errors raised by `--new` are now wrapped in Rubycli's user-facing runner error. +- Framework argument errors raised by constructors are also wrapped in the same user-facing runner error. - Positional type conversion now waits for JSON/eval coercion, matching keyword-option behavior and preserving `--new` JSON/eval inputs. - Runner tests now execute without terminating the Minitest process and assert converted command arguments instead of stubbed targets. - `--check --new` now inspects exposed instance/class commands without running constructors, while `--check` rejects pre-scripts instead of evaluating them. +- `--check` now inspects explicitly selected commands even when their methods or defining procs come from required files. - Explicit nested constant names no longer fall back to inherited top-level constants, and malformed pre-scripts now produce contextual Rubycli errors. - Rest, optional-before-required, and trailing-required positional arguments now follow Ruby's argument binding rules for conversion, validation, and help output. - Documented scalar/list conversions now preserve numeric-looking strings, handle repeated booleans, return real `DateTime` values, accept JSON arrays, and reject scalar/array values where `JSON`/`Hash` shapes do not allow them. @@ -19,12 +22,15 @@ - Assignment-like positional values remain positional unless they match a keyword, while matching assignments use the same documented conversion as long options. - YARD positional tags are aligned by parameter name instead of comment order. - Eval-mode local variables and command-line strict/check/result-output flags no longer leak across separate programmatic runs. +- Constructor and command eval arguments share one binding per Runner execution without enabling eval mode while the target file loads. +- Required options accept a lone `-` value, bare rest placeholders render with an ellipsis, and quoted `String[]` elements remain strings. - Invalid Ruby syntax passed through strict eval mode now produces a user-facing Rubycli argument error instead of leaking a `SyntaxError` backtrace. - Circular arrays/hashes returned by commands now fall back to inspected output instead of raising a JSON nesting error. ### Testing - Added dependency-free overall line, branch, and changed-line coverage gates plus GitHub Actions checks spanning the supported Ruby range. - Changed-line coverage now treats new library files that were never loaded by the test suite as uncovered. +- Push coverage compares against the previous commit and falls back to the default branch when a new ref has no previous commit or a ref is deleted. ## [0.1.7] - 2025-11-12 diff --git a/README.ja.md b/README.ja.md index 87c6eee..6d701b8 100644 --- a/README.ja.md +++ b/README.ja.md @@ -274,6 +274,8 @@ Rubycli が角括弧を補います。 受け付けます。スペース区切りの複数値(`--tags build test`)には対応しておらず、 繰り返し注記のないオプションはスカラーのままです。`--strict` 実行時は各要素の型も 検証されるため、`[String[]]` と書かれた注釈に対して `--tags [1,2]` を渡すとエラーになります。 +`--tags '["true","null"]'` のように引用された要素は、別のリテラルに見える内容でも文字列の +まま保持されます。 ### リテラル列挙(enum) @@ -417,8 +419,10 @@ rubycli -E scripts/report_runner.rb publish \ --channels '[:email, :slack]' ``` -評価は隔離された binding(`Object.new.instance_eval { binding }`)内で行われますが、 -入力そのものは信頼できる呼び出し元に限定してください。プログラムからは +評価は隔離された binding(`Object.new.instance_eval { binding }`)内で行われます。 +1回の Runner 実行では、`--new=VALUE` のコンストラクタ引数と選択したコマンドの引数を含む +すべての eval 引数が同じ binding を共有し、実行終了時に破棄されます。入力そのものは +信頼できる呼び出し元に限定してください。プログラムからは `Rubycli.with_eval_mode(true) { ... }` で切り替えられます。 `--eval-lax` / `-E` は `--eval-args` と同様に eval モードを有効にしつつ、Ruby として diff --git a/README.md b/README.md index 84c3ebd..cc76867 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,9 @@ syntax (`--tags '["build","test"]'`) and comma-delimited strings (`--tags "build,test"`) are accepted. Space-separated multi-value flags (`--tags build test`) are not supported, and options without a repeated/array hint stay scalars. `--strict` verifies each element against the documented -type, so `--tags [1,2]` fails when the docs say `[String[]]`. +type, so `--tags [1,2]` fails when the docs say `[String[]]`. Quoted elements +remain strings even when their contents look like other literals, such as +`--tags '["true","null"]'`. ### Literal choices and enums @@ -440,9 +442,11 @@ rubycli -E scripts/report_runner.rb publish \ ``` Evaluation happens inside an isolated binding -(`Object.new.instance_eval { binding }`). Treat this as unsafe input: do not -enable it for untrusted callers. Programmatic equivalent: -`Rubycli.with_eval_mode(true) { ... }`. +(`Object.new.instance_eval { binding }`). All eval arguments in one Runner +execution, including `--new=VALUE` constructor arguments and the selected +command's arguments, share that binding; it is discarded after the execution. +Treat this as unsafe input: do not enable it for untrusted callers. +Programmatic equivalent: `Rubycli.with_eval_mode(true) { ... }`. `--eval-lax` / `-E` behaves like `--eval-args`, but tokens that fail to parse as Ruby (for example a bare `https://example.com`) produce a warning and are diff --git a/lib/rubycli.rb b/lib/rubycli.rb index fbcceae..59e6ecd 100644 --- a/lib/rubycli.rb +++ b/lib/rubycli.rb @@ -221,24 +221,33 @@ def execute( raise Error, '--json-args cannot be combined with --eval-args or --eval-lax' end - runner_target, full_path = prepare_runner_target( - target_path, - class_name, - new: new, - new_args: new_args, - json_mode: json, - eval_mode: eval_args, - eval_lax: eval_lax, - pre_scripts: pre_scripts, - constant_mode: constant_mode - ) - - original_program_name = $PROGRAM_NAME + original_program_name = nil original_argv = nil - $PROGRAM_NAME = File.basename(full_path) - original_argv = ARGV.dup - ARGV.replace(Array(cli_args).dup) - run_with_modes(runner_target, json: json, eval_args: eval_args, eval_lax: eval_lax) + execution = proc do + runner_target, full_path = prepare_runner_target( + target_path, + class_name, + new: new, + new_args: new_args, + json_mode: json, + eval_mode: eval_args, + eval_lax: eval_lax, + pre_scripts: pre_scripts, + constant_mode: constant_mode + ) + + original_program_name = $PROGRAM_NAME + $PROGRAM_NAME = File.basename(full_path) + original_argv = ARGV.dup + ARGV.replace(Array(cli_args).dup) + run_with_modes(runner_target, json: json, eval_args: eval_args, eval_lax: eval_lax) + end + + if eval_args + Rubycli.eval_coercer.with_eval_binding(&execution) + else + execution.call + end ensure $PROGRAM_NAME = original_program_name if original_program_name ARGV.replace(original_argv) if original_argv @@ -372,7 +381,7 @@ def instantiate_target(target, initializer_args = nil) else target end - rescue ::ArgumentError => e + rescue ::ArgumentError, Rubycli::ArgumentError => e raise Error, "Failed to instantiate target: #{e.message}" end @@ -382,7 +391,7 @@ def run_with_modes(target, json:, eval_args:, eval_lax:) if json Rubycli.with_json_mode(true, &runner) elsif eval_args - Rubycli.with_eval_mode(true, lax: eval_lax, &runner) + Rubycli.with_eval_mode(true, lax: eval_lax, reuse_binding: true, &runner) else runner.call end @@ -399,7 +408,7 @@ def parse_initializer_arguments(raw_value, target, json_mode:, eval_mode:, eval_ keyword_args = {} Rubycli.argument_mode_controller.with_json_mode(json_mode) do - Rubycli.argument_mode_controller.with_eval_mode(eval_mode, lax: eval_lax) do + Rubycli.argument_mode_controller.with_eval_mode(eval_mode, lax: eval_lax, reuse_binding: true) do positional_args, keyword_args = Rubycli.argument_parser.parse(tokens.dup, initializer_method) Rubycli.apply_argument_coercions(positional_args, keyword_args) Rubycli.argument_parser.validate_inputs(initializer_method, positional_args, keyword_args) @@ -480,13 +489,12 @@ def documentation_methods_for(target, full_path, instantiate:) return methods end - normalized = normalize_path(full_path) - class_methods = collect_defined_methods(target.singleton_class, normalized) + class_methods = target.singleton_class.public_instance_methods(false) .map { |name| target.method(name) } .select { |method_obj| Rubycli.cli.send(:exposable_method?, method_obj) } return class_methods unless instantiate - instance_methods = collect_defined_methods(target, normalized) + instance_methods = target.public_instance_methods(false) .map { |name| target.instance_method(name) } .select { |method_obj| Rubycli.cli.send(:exposable_method?, method_obj) } target.is_a?(Class) ? instance_methods + class_methods : instance_methods diff --git a/lib/rubycli/argument_parser.rb b/lib/rubycli/argument_parser.rb index 694e330..dd8925b 100644 --- a/lib/rubycli/argument_parser.rb +++ b/lib/rubycli/argument_parser.rb @@ -743,13 +743,17 @@ def converter_for_single_type(type) inner = normalized[6..-2].strip element_converter = converter_for_single_type(inner) ->(value) { - list_items(value).map { |item| element_converter ? element_converter.call(item) : item } + list_items(value).map do |item| + inner == 'String' && item.is_a?(String) ? item : (element_converter ? element_converter.call(item) : item) + end } elsif normalized.end_with?('[]') inner = normalized[0..-3] element_converter = converter_for_single_type(inner) ->(value) { - list_items(value).map { |item| element_converter ? element_converter.call(item) : item } + list_items(value).map do |item| + inner == 'String' && item.is_a?(String) ? item : (element_converter ? element_converter.call(item) : item) + end } elsif normalized == 'Array' ->(value) { list_items(value) } @@ -782,7 +786,7 @@ def convert_option_value(keyword, value, option_meta, type_converters) def looks_like_option?(token) return false unless token - return false if token == '--' + return false if token == '--' || token == '-' token.start_with?('-') && !token.match?( /\A-(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\z/ diff --git a/lib/rubycli/constant_capture.rb b/lib/rubycli/constant_capture.rb index 5b179bd..13f6236 100644 --- a/lib/rubycli/constant_capture.rb +++ b/lib/rubycli/constant_capture.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'digest' +require 'ripper' module Rubycli # Observes constants defined while loading a file. @@ -8,26 +9,25 @@ class ConstantCapture def initialize @captured = Hash.new { |hash, key| hash[key] = [] } @source_fingerprints = {} + @assignment_definitions = {} end def capture(file) normalized_file = normalize(file) source_fingerprint = Digest::SHA256.file(normalized_file).hexdigest - previous_names = if @source_fingerprints[normalized_file] == source_fingerprint - @captured[normalized_file].dup - else - [] - end + source_unchanged = @source_fingerprints[normalized_file] == source_fingerprint + previous_names = @captured[normalized_file].dup + previous_assignment_definitions = @assignment_definitions.fetch(normalized_file, {}) + current_assignment_definitions = assigned_constant_definitions(normalized_file) + executed_lines = [] + observed_events = [] @captured[normalized_file] = [] before_snapshot = constant_snapshot(normalized_file) - trace = TracePoint.new(:class) do |tp| + trace = TracePoint.new(:class, :line, :c_return) do |tp| location = tp.path next unless location && same_file?(normalized_file, location) - constant_name = qualified_name_for(tp.self) - next unless constant_name - - @captured[normalized_file] << constant_name + observed_events << [tp.event, tp.self, tp.lineno, tp.method_id] end trace.enable @@ -35,13 +35,24 @@ def capture(file) ensure trace&.disable if normalized_file && before_snapshot + apply_trace_events(observed_events, executed_lines, normalized_file) after_snapshot = constant_snapshot(normalized_file) changed_names = after_snapshot.keys.select do |name| before_snapshot[name] != after_snapshot[name] end - retained_names = previous_names.select { |name| after_snapshot.key?(name) } + retained_names = previous_names.select do |name| + previous_definitions = previous_assignment_definitions.fetch(name, []) + current_definitions = current_assignment_definitions.fetch(name, []) + definition_active = active_definition_retained?( + previous_definitions, + current_definitions, + executed_lines + ) + after_snapshot.key?(name) && (source_unchanged || definition_active) + end @captured[normalized_file].concat(changed_names).concat(retained_names) @source_fingerprints[normalized_file] = source_fingerprint + @assignment_definitions[normalized_file] = current_assignment_definitions end end @@ -119,5 +130,179 @@ def qualified_constant_name(owner_name, constant_name) "#{owner_name}::#{constant_name}" end + + def apply_trace_events(events, executed_lines, file) + events.each do |event, target, line, method_id| + case event + when :line + executed_lines << line + when :c_return + @captured[file].concat(constants_set_at(target, file, line)) if method_id == :const_set + when :class + constant_name = qualified_name_for(target) + @captured[file] << constant_name if constant_name + end + end + end + + def constants_set_at(owner, file, line) + owner_name = owner.equal?(Object) ? '' : owner.name + return [] if owner_name.nil? || owner_name.start_with?('#<') + + safe_module_constants(owner).filter_map do |constant_name| + location = safe_const_source_location(owner, constant_name) + next unless location && same_file?(file, location[0]) && location[1] == line + + value = safe_const_get(owner, constant_name) + next unless value.is_a?(Module) + + qualified_constant_name(owner_name, constant_name) + end + end + + def active_definition_retained?(previous_definitions, current_definitions, executed_lines) + previous_definitions.any? do |previous| + current_definitions.any? do |current| + next false unless previous[:signature] == current[:signature] + + !current[:ambiguous_line] && executed_lines.include?(current[:line]) + end + end + end + + def assigned_constant_definitions(file) + syntax_tree = Ripper.sexp(File.read(file)) + return {} unless syntax_tree + + collect_assigned_constant_definitions(syntax_tree, [], [], {}) + end + + def collect_assigned_constant_definitions(node, namespace, contexts, definitions) + return definitions unless node.is_a?(Array) + + case node.first + when :module + nested_name = constant_name_from_node(node[1], namespace) + collect_assigned_constant_definitions(node[2], Array(nested_name&.split('::')), contexts, definitions) + when :class + nested_name = constant_name_from_node(node[1], namespace) + collect_assigned_constant_definitions(node[3], Array(nested_name&.split('::')), contexts, definitions) + when :assign, :opassign + signature = [node.first, assignment_operator(node), contexts.map(&:first)] + collect_assignment_targets(node[1], namespace, definitions, signature, contexts) + node.drop(2).each do |child| + collect_assigned_constant_definitions(child, namespace, contexts, definitions) + end + when :massign + signature = [node.first, nil, contexts.map(&:first)] + collect_assignment_targets(node[1], namespace, definitions, signature, contexts) + node.drop(2).each do |child| + collect_assigned_constant_definitions(child, namespace, contexts, definitions) + end + when :if, :unless + collect_assigned_constant_definitions(node[1], namespace, contexts, definitions) + condition = canonical_syntax(node[1]) + condition_line = source_line(node[1]) + then_context = contexts + [[[node.first, :then, condition], condition_line]] + else_context = contexts + [[[node.first, :else, condition], condition_line]] + collect_assigned_constant_definitions(node[2], namespace, then_context, definitions) + collect_assigned_constant_definitions(node[3], namespace, else_context, definitions) + when :while, :until + collect_assigned_constant_definitions(node[1], namespace, contexts, definitions) + body_context = contexts + [[[node.first, canonical_syntax(node[1])], source_line(node[1])]] + collect_assigned_constant_definitions(node[2], namespace, body_context, definitions) + when :if_mod, :unless_mod, :while_mod, :until_mod + collect_assigned_constant_definitions(node[1], namespace, contexts, definitions) + branch_context = contexts + [[[node.first, canonical_syntax(node[1])], source_line(node[1])]] + collect_assigned_constant_definitions(node[2], namespace, branch_context, definitions) + when :ifop + collect_assigned_constant_definitions(node[1], namespace, contexts, definitions) + condition = canonical_syntax(node[1]) + condition_line = source_line(node[1]) + then_context = contexts + [[[node.first, :then, condition], condition_line]] + else_context = contexts + [[[node.first, :else, condition], condition_line]] + collect_assigned_constant_definitions(node[2], namespace, then_context, definitions) + collect_assigned_constant_definitions(node[3], namespace, else_context, definitions) + when :binary + collect_assigned_constant_definitions(node[1], namespace, contexts, definitions) + right_contexts = contexts + if %i[&& ||].include?(node[2]) + right_contexts += [[[node.first, node[2], canonical_syntax(node[1])], source_line(node[1])]] + end + collect_assigned_constant_definitions(node[3], namespace, right_contexts, definitions) + when :case + collect_assigned_constant_definitions(node[1], namespace, contexts, definitions) + case_context = contexts + [[[node.first, canonical_syntax(node[1])], source_line(node[1])]] + collect_assigned_constant_definitions(node[2], namespace, case_context, definitions) + when :for + collect_assigned_constant_definitions(node[2], namespace, contexts, definitions) + body_context = contexts + [[[node.first, canonical_syntax(node[2])], source_line(node[1])]] + collect_assigned_constant_definitions(node[3], namespace, body_context, definitions) + when :lambda, :do_block, :brace_block + lazy_context = contexts + [[[node.first], source_line(node)]] + node.drop(1).each do |child| + collect_assigned_constant_definitions(child, namespace, lazy_context, definitions) + end + else + node.each do |child| + collect_assigned_constant_definitions(child, namespace, contexts, definitions) + end + end + + definitions + end + + def collect_assignment_targets(node, namespace, definitions, signature, contexts) + assigned_name = constant_name_from_node(node, namespace) + if assigned_name + line = source_line(node) + # A line event can precede a skipped postfix/short-circuit assignment. + ambiguous_line = contexts.any? { |context| context[1] == line } + definition = { signature: signature, line: line, ambiguous_line: ambiguous_line } + definitions[assigned_name] = Array(definitions[assigned_name]) | [definition] + elsif node.is_a?(Array) + node.each { |child| collect_assignment_targets(child, namespace, definitions, signature, contexts) } + end + end + + def assignment_operator(node) + node.first == :opassign ? node.dig(2, 1) : nil + end + + def canonical_syntax(node) + return node unless node.is_a?(Array) + return [node[0], node[1]] if node.first.to_s.start_with?('@') + + node.map { |child| canonical_syntax(child) } + end + + def source_line(node) + return nil unless node.is_a?(Array) + return node.dig(2, 0) if node.first.to_s.start_with?('@') + + node.each do |child| + line = source_line(child) + return line if line + end + nil + end + + def constant_name_from_node(node, namespace) + return nil unless node.is_a?(Array) + + case node.first + when :var_field, :const_ref, :var_ref + constant_name_from_node(node[1], namespace) + when :@const + (namespace + [node[1]]).join('::') + when :const_path_field, :const_path_ref + parent_name = constant_name_from_node(node[1], namespace) + child_name = node.dig(2, 1) + [parent_name, child_name].compact.join('::') + when :top_const_field, :top_const_ref + node.dig(1, 1) + end + end + end end diff --git a/lib/rubycli/eval_coercer.rb b/lib/rubycli/eval_coercer.rb index c90e70e..c95aca0 100644 --- a/lib/rubycli/eval_coercer.rb +++ b/lib/rubycli/eval_coercer.rb @@ -12,13 +12,15 @@ def eval_lax_mode? Thread.current[LAX_THREAD_KEY] == true end - def with_eval_mode(enabled = true, lax: false) + def with_eval_mode(enabled = true, lax: false, reuse_binding: false) previous = Thread.current[THREAD_KEY] previous_lax = Thread.current[LAX_THREAD_KEY] previous_binding = Thread.current[BINDING_THREAD_KEY] Thread.current[THREAD_KEY] = enabled Thread.current[LAX_THREAD_KEY] = enabled && lax - Thread.current[BINDING_THREAD_KEY] = isolated_binding if enabled + if enabled + Thread.current[BINDING_THREAD_KEY] = reuse_binding && previous_binding ? previous_binding : isolated_binding + end yield ensure Thread.current[THREAD_KEY] = previous @@ -26,6 +28,14 @@ def with_eval_mode(enabled = true, lax: false) Thread.current[BINDING_THREAD_KEY] = previous_binding end + def with_eval_binding + previous_binding = Thread.current[BINDING_THREAD_KEY] + Thread.current[BINDING_THREAD_KEY] = isolated_binding + yield + ensure + Thread.current[BINDING_THREAD_KEY] = previous_binding + end + def coerce_eval_value(value) case value when String diff --git a/lib/rubycli/help_renderer.rb b/lib/rubycli/help_renderer.rb index 357d41c..f80ac33 100644 --- a/lib/rubycli/help_renderer.rb +++ b/lib/rubycli/help_renderer.rb @@ -331,8 +331,8 @@ def optional_placeholder(placeholder, definition, name) def rest_placeholder(placeholder, definition, name) unless placeholder.nil? || placeholder.strip.empty? || auto_generated_placeholder?(placeholder, definition, name) documented = placeholder.strip - return documented if documented.start_with?('[') && documented.end_with?(']') - + documented = documented[1..-2].strip if documented.start_with?('[') && documented.end_with?(']') + documented = "#{documented}..." unless documented.end_with?('...') return "[#{documented}]" end diff --git a/test/argument_parser_test.rb b/test/argument_parser_test.rb index 3e08e02..301a0d7 100644 --- a/test/argument_parser_test.rb +++ b/test/argument_parser_test.rb @@ -120,6 +120,11 @@ def string_list(codes:) codes end + # --codes VALUES... [Array] Generic string codes + def generic_string_list(codes:) + codes + end + # --flags VALUES... [Boolean] Boolean flags def boolean_list(flags:) flags @@ -172,6 +177,15 @@ def test_required_option_accepts_true_as_an_explicit_value assert_equal({ greeting: true }, kw_args) end + def test_required_option_accepts_lone_dash_as_its_value + method = StdTypeSamples.method(:ingest) + + pos_args, kw_args = @parser.parse(['--input', '-'], method) + + assert_empty pos_args + assert_equal Pathname.new('-'), kw_args[:input] + end + def test_double_dash_preserves_following_option_like_values_as_positionals callable = ->(*values) { values } @@ -473,6 +487,28 @@ def test_string_array_annotation_preserves_numeric_looking_token assert_equal({ codes: ['001'] }, kw_args) end + def test_string_array_annotation_preserves_quoted_boolean_and_null_tokens + method = ScalarTypeSamples.method(:string_list) + + pos_args, kw_args = @parser.parse(['--codes', '["true","null"]'], method) + + assert_empty pos_args + assert_equal({ codes: %w[true null] }, kw_args) + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + + def test_generic_string_array_annotation_preserves_quoted_boolean_and_null_tokens + method = ScalarTypeSamples.method(:generic_string_list) + + pos_args, kw_args = @parser.parse(['--codes', '["true","null"]'], method) + + assert_empty pos_args + assert_equal({ codes: %w[true null] }, kw_args) + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + def test_required_repeated_boolean_option_consumes_and_converts_its_value method = ScalarTypeSamples.method(:boolean_list) metadata = @registry.metadata_for(method) diff --git a/test/constant_capture_test.rb b/test/constant_capture_test.rb index 31f2422..75269a7 100644 --- a/test/constant_capture_test.rb +++ b/test/constant_capture_test.rb @@ -83,6 +83,342 @@ def self.run; end end end + def test_records_assigned_alias_after_source_file_is_edited + capture = Rubycli::ConstantCapture.new + Tempfile.create(['edited_assigned_alias', '.rb']) do |file| + source = <<~RUBY + module CaptureEditedAliasSource + def self.run; end + end + CaptureEditedAssignedAlias = CaptureEditedAliasSource + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureEditedAssignedAlias' + ensure + cleanup_constant(:CaptureEditedAssignedAlias) + cleanup_constant(:CaptureEditedAliasSource) + end + end + + def test_records_guarded_constant_after_source_file_is_edited + capture = Rubycli::ConstantCapture.new + Tempfile.create(['edited_guarded_constant', '.rb']) do |file| + source = <<~RUBY + CaptureGuardedRunner ||= Class.new do + def self.run; end + end + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureGuardedRunner' + ensure + cleanup_constant(:CaptureGuardedRunner) + end + end + + def test_does_not_retain_assigned_alias_removed_from_edited_source + capture = Rubycli::ConstantCapture.new + Tempfile.create(['removed_assigned_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureRemovedAliasSource + def self.run; end + end + CaptureRemovedAssignedAlias = CaptureRemovedAliasSource + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write(<<~RUBY) + module CaptureRemovedAliasSource + def self.run; end + end + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureRemovedAliasSource' + refute_includes capture.constants_for(file.path), 'CaptureRemovedAssignedAlias' + ensure + cleanup_constant(:CaptureRemovedAssignedAlias) + cleanup_constant(:CaptureRemovedAliasSource) + end + end + + def test_records_qualified_and_absolute_aliases_after_source_file_is_edited + capture = Rubycli::ConstantCapture.new + Tempfile.create(['edited_qualified_aliases', '.rb']) do |file| + source = <<~RUBY + module CaptureQualifiedAliasOwner; end + module CaptureQualifiedAliasSource + def self.run; end + end + CaptureQualifiedAliasOwner::Runner = CaptureQualifiedAliasSource + ::CaptureAbsoluteAssignedAlias = CaptureQualifiedAliasSource + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureQualifiedAliasOwner::Runner' + assert_includes capture.constants_for(file.path), 'CaptureAbsoluteAssignedAlias' + ensure + if Object.const_defined?(:CaptureQualifiedAliasOwner) + owner = Object.const_get(:CaptureQualifiedAliasOwner) + owner.send(:remove_const, :Runner) if owner.const_defined?(:Runner, false) + end + cleanup_constant(:CaptureAbsoluteAssignedAlias) + cleanup_constant(:CaptureQualifiedAliasOwner) + cleanup_constant(:CaptureQualifiedAliasSource) + end + end + + def test_records_multiple_assigned_aliases_after_source_file_is_edited + capture = Rubycli::ConstantCapture.new + Tempfile.create(['edited_multiple_aliases', '.rb']) do |file| + source = <<~RUBY + module CaptureMultipleAliasSource + def self.run; end + end + CaptureMultipleAliasOne, CaptureMultipleAliasTwo = CaptureMultipleAliasSource, CaptureMultipleAliasSource + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureMultipleAliasOne' + assert_includes capture.constants_for(file.path), 'CaptureMultipleAliasTwo' + ensure + cleanup_constant(:CaptureMultipleAliasOne) + cleanup_constant(:CaptureMultipleAliasTwo) + cleanup_constant(:CaptureMultipleAliasSource) + end + end + + def test_does_not_retain_alias_when_edited_assignment_is_not_executed + capture = Rubycli::ConstantCapture.new + Tempfile.create(['skipped_edited_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureSkippedAliasSource + def self.run; end + end + CaptureSkippedAssignedAlias = CaptureSkippedAliasSource + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write(<<~RUBY) + module CaptureSkippedAliasSource + def self.run; end + end + if false + CaptureSkippedAssignedAlias = CaptureSkippedAliasSource + end + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CaptureSkippedAssignedAlias' + ensure + cleanup_constant(:CaptureSkippedAssignedAlias) + cleanup_constant(:CaptureSkippedAliasSource) + end + end + + def test_retains_multiline_conditional_alias_when_assignment_executes_after_edit + capture = Rubycli::ConstantCapture.new + Tempfile.create(['executed_conditional_edited_alias', '.rb']) do |file| + source = <<~RUBY + module CaptureExecutedConditionalAliasSource + def self.run; end + end + enabled = true + if enabled + CaptureExecutedConditionalAssignedAlias = CaptureExecutedConditionalAliasSource + end + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureExecutedConditionalAssignedAlias' + ensure + cleanup_constant(:CaptureExecutedConditionalAssignedAlias) + cleanup_constant(:CaptureExecutedConditionalAliasSource) + end + end + + def test_does_not_retain_multiline_conditional_alias_when_runtime_guard_changes + capture = Rubycli::ConstantCapture.new + Tempfile.create(['disabled_conditional_edited_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureDisabledConditionalAliasSource + def self.run; end + end + enabled = true + if enabled + CaptureDisabledConditionalAssignedAlias = CaptureDisabledConditionalAliasSource + end + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write(<<~RUBY) + module CaptureDisabledConditionalAliasSource + def self.run; end + end + enabled = false + if enabled + CaptureDisabledConditionalAssignedAlias = CaptureDisabledConditionalAliasSource + end + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CaptureDisabledConditionalAssignedAlias' + ensure + cleanup_constant(:CaptureDisabledConditionalAssignedAlias) + cleanup_constant(:CaptureDisabledConditionalAliasSource) + end + end + + def test_does_not_retain_alias_when_edited_assignment_is_short_circuited + capture = Rubycli::ConstantCapture.new + Tempfile.create(['short_circuited_edited_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureShortCircuitedAliasSource + def self.run; end + end + CaptureShortCircuitedAssignedAlias = CaptureShortCircuitedAliasSource + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write(<<~RUBY) + module CaptureShortCircuitedAliasSource + def self.run; end + end + false && (CaptureShortCircuitedAssignedAlias = CaptureShortCircuitedAliasSource) + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CaptureShortCircuitedAssignedAlias' + ensure + cleanup_constant(:CaptureShortCircuitedAssignedAlias) + cleanup_constant(:CaptureShortCircuitedAliasSource) + end + end + + def test_does_not_retain_alias_when_edited_postfix_assignment_is_skipped + capture = Rubycli::ConstantCapture.new + Tempfile.create(['postfix_skipped_edited_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CapturePostfixSkippedAliasSource + def self.run; end + end + CapturePostfixSkippedAssignedAlias = CapturePostfixSkippedAliasSource + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write(<<~RUBY) + module CapturePostfixSkippedAliasSource + def self.run; end + end + CapturePostfixSkippedAssignedAlias = CapturePostfixSkippedAliasSource if false + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CapturePostfixSkippedAssignedAlias' + ensure + cleanup_constant(:CapturePostfixSkippedAssignedAlias) + cleanup_constant(:CapturePostfixSkippedAliasSource) + end + end + + def test_records_const_set_alias_after_source_file_is_edited + capture = Rubycli::ConstantCapture.new + Tempfile.create(['edited_const_set_alias', '.rb']) do |file| + source = <<~RUBY + module CaptureConstSetAliasSource + def self.run; end + end + Object.const_set(:CaptureConstSetAssignedAlias, CaptureConstSetAliasSource) + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureConstSetAssignedAlias' + ensure + cleanup_constant(:CaptureConstSetAssignedAlias) + cleanup_constant(:CaptureConstSetAliasSource) + end + end + private def cleanup_constant(name) diff --git a/test/help_renderer_test.rb b/test/help_renderer_test.rb index b04a3e5..d4fe92f 100644 --- a/test/help_renderer_test.rb +++ b/test/help_renderer_test.rb @@ -19,6 +19,15 @@ def collect(*values) end end +module BareRestHelpSamples + module_function + + # ITEMS [String] Items to collect + def collect(*items) + items + end +end + module OptionalPositionalHelpSamples module_function @@ -122,6 +131,13 @@ def test_rest_parameter_is_optional_and_rendered_in_positional_table assert_includes usage, 'VALUES... [Symbol[]] optional Values to collect' end + def test_rest_parameter_usage_adds_ellipsis_to_bare_documented_placeholder + method = BareRestHelpSamples.method(:collect) + usage = @renderer.usage_for_method('collect', method) + + assert_includes usage, 'Usage: rubycli collect [ITEMS...]' + end + def test_documented_optional_positional_is_bracketed_in_usage method = OptionalPositionalHelpSamples.method(:sequence) usage = @renderer.usage_for_method('sequence', method) diff --git a/test/runner_test.rb b/test/runner_test.rb index 753881e..36aca19 100644 --- a/test/runner_test.rb +++ b/test/runner_test.rb @@ -139,6 +139,20 @@ def initialize(required); end assert_includes error.message, 'wrong number of arguments' end + def test_framework_argument_error_from_initializer_is_wrapped_as_runner_error + target = Class.new do + def initialize + raise Rubycli::ArgumentError, 'invalid constructor value' + end + end + + error = assert_raises(Rubycli::Runner::Error) do + Rubycli::Runner.instantiate_target(target) + end + + assert_equal 'Failed to instantiate target: invalid constructor value', error.message + end + def test_auto_mode_selects_single_constant_when_names_differ Dir.mktmpdir do |dir| file = File.join(dir, 'cli_entry.rb') @@ -503,6 +517,79 @@ def self.run(values) end end + def test_eval_binding_is_shared_between_initializer_and_command_within_one_execution + Dir.mktmpdir do |dir| + file = File.join(dir, 'shared_eval_binding_runner.rb') + File.write(file, <<~RUBY) + class SharedEvalBindingRunner + attr_reader :seed + + # SEED [Integer] + def initialize(seed) + @seed = seed + end + + # VALUE [Integer] + def add(value) + seed + value + end + end + RUBY + + captured = nil + run_without_exit = ->(target, *_args) { Rubycli.cli.run(target, ARGV.dup, false) } + Rubycli.stub(:run, run_without_exit) do + Rubycli.stub(:call_target, ->(method, pos_args, _kw_args) { + captured = [method.receiver.seed, pos_args.first] + nil + }) do + Rubycli::Runner.execute( + file, + nil, + ['add', 'shared_seed + 2'], + new: true, + new_args: 'shared_seed = 40', + eval_args: true, + constant_mode: :strict + ) + end + end + + assert_equal [40, 42], captured + ensure + Object.send(:remove_const, :SharedEvalBindingRunner) if Object.const_defined?(:SharedEvalBindingRunner) + end + end + + def test_eval_mode_is_disabled_while_loading_the_target_file + Dir.mktmpdir do |dir| + file = File.join(dir, 'eval_load_mode_runner.rb') + File.write(file, <<~RUBY) + class EvalLoadModeRunner + LOADED_WITH_EVAL_MODE = Rubycli.eval_mode? + + def self.run + :ok + end + end + RUBY + + Rubycli.stub(:run, ->(*) {}) do + Rubycli::Runner.execute( + file, + 'EvalLoadModeRunner', + [], + eval_args: true, + constant_mode: :strict + ) + end + + assert_equal false, EvalLoadModeRunner::LOADED_WITH_EVAL_MODE + ensure + Object.send(:remove_const, :EvalLoadModeRunner) if Object.const_defined?(:EvalLoadModeRunner) + end + end + def test_error_lists_candidates_when_multiple_callable_constants_exist Dir.mktmpdir do |dir| file = File.join(dir, 'multi_runner.rb') @@ -562,6 +649,75 @@ def self.run(name) end end + def test_check_lints_explicit_class_methods_defined_in_a_required_file + Dir.mktmpdir do |dir| + dependency = File.join(dir, 'dependency_check_runner.rb') + entry = File.join(dir, 'dependency_check_entry.rb') + File.write(dependency, <<~RUBY) + class DependencyCheckRunner + # @param name [String] Documented name + # @param extra [String] This param does not exist + def self.run(name) + name + end + end + RUBY + File.write(entry, "require #{dependency.inspect}\n") + + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + + status = nil + _out, _err = capture_io do + status = Rubycli::Runner.check(entry, 'DependencyCheckRunner') + end + + assert_equal 1, status + refute_empty Rubycli.environment.documentation_issues + ensure + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + Rubycli.environment.disable_doc_check! + Object.send(:remove_const, :DependencyCheckRunner) if Object.const_defined?(:DependencyCheckRunner) + end + end + + def test_check_lints_singleton_method_defined_from_an_external_proc + Dir.mktmpdir do |dir| + dependency = File.join(dir, 'external_check_proc.rb') + entry = File.join(dir, 'external_proc_check_runner.rb') + File.write(dependency, <<~RUBY) + # @param name [String] Documented name + # @param extra [String] This param does not exist + ExternalCheckProc = proc { |name| name } + RUBY + File.write(entry, <<~RUBY) + require #{dependency.inspect} + + class ExternalProcCheckRunner + define_singleton_method(:run, &ExternalCheckProc) + end + RUBY + + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + + status = nil + _out, _err = capture_io do + status = Rubycli::Runner.check(entry, 'ExternalProcCheckRunner') + end + + assert_equal 1, status + refute_empty Rubycli.environment.documentation_issues + ensure + Rubycli.documentation_registry.reset! + Rubycli.environment.clear_documentation_issues! + Rubycli.environment.disable_doc_check! + Object.send(:remove_const, :ExternalProcCheckRunner) if Object.const_defined?(:ExternalProcCheckRunner) + Object.send(:remove_const, :ExternalCheckProc) if Object.const_defined?(:ExternalCheckProc) + end + end + def test_check_new_lints_instance_methods_without_instantiating Dir.mktmpdir do |dir| file = File.join(dir, 'side_effect_check_runner.rb') From 20729eaffdcd23422d40464a429cf93047ef7c67 Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:31:58 +0900 Subject: [PATCH 13/20] =?UTF-8?q?fix:=20=E6=9C=80=E6=96=B0=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=20/=20Fix=20latest=20review=20findings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- lib/rubycli/argument_parser.rb | 8 ++- lib/rubycli/constant_capture.rb | 111 ++++++++++++++++++++++++++++++-- test/argument_parser_test.rb | 32 +++++++++ test/constant_capture_test.rb | 56 ++++++++++++++++ 5 files changed, 199 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c15db1..29a532d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,7 @@ - YARD positional tags are aligned by parameter name instead of comment order. - Eval-mode local variables and command-line strict/check/result-output flags no longer leak across separate programmatic runs. - Constructor and command eval arguments share one binding per Runner execution without enabling eval mode while the target file loads. -- Required options accept a lone `-` value, bare rest placeholders render with an ellipsis, and quoted `String[]` elements remain strings. +- Required options accept a lone `-` value, bare rest placeholders render with an ellipsis, and quoted positional/option `String[]` elements remain strings. - Invalid Ruby syntax passed through strict eval mode now produces a user-facing Rubycli argument error instead of leaking a `SyntaxError` backtrace. - Circular arrays/hashes returned by commands now fall back to inspected output instead of raising a JSON nesting error. diff --git a/lib/rubycli/argument_parser.rb b/lib/rubycli/argument_parser.rb index dd8925b..b0aea6e 100644 --- a/lib/rubycli/argument_parser.rb +++ b/lib/rubycli/argument_parser.rb @@ -330,13 +330,17 @@ def converter_for_definition(definition) inner = normalized[6..-2].strip element_converter = converter_for_single_type(inner) return ->(value) { - list_items(value).map { |item| element_converter ? element_converter.call(item) : item } + list_items(value).map do |item| + inner == 'String' && item.is_a?(String) ? item : (element_converter ? element_converter.call(item) : item) + end } elsif normalized.end_with?('[]') inner = normalized[0..-3] element_converter = converter_for_single_type(inner) return ->(value) { - list_items(value).map { |item| element_converter ? element_converter.call(item) : item } + list_items(value).map do |item| + inner == 'String' && item.is_a?(String) ? item : (element_converter ? element_converter.call(item) : item) + end } elsif normalized.casecmp('Array').zero? return ->(value) { list_items(value) } diff --git a/lib/rubycli/constant_capture.rb b/lib/rubycli/constant_capture.rb index 13f6236..0d5e5f6 100644 --- a/lib/rubycli/constant_capture.rb +++ b/lib/rubycli/constant_capture.rb @@ -20,10 +20,11 @@ def capture(file) previous_assignment_definitions = @assignment_definitions.fetch(normalized_file, {}) current_assignment_definitions = assigned_constant_definitions(normalized_file) executed_lines = [] + assignment_events = Hash.new { |hash, line| hash[line] = [] } observed_events = [] @captured[normalized_file] = [] before_snapshot = constant_snapshot(normalized_file) - trace = TracePoint.new(:class, :line, :c_return) do |tp| + trace = TracePoint.new(:class, :line, :c_call, :c_return) do |tp| location = tp.path next unless location && same_file?(normalized_file, location) @@ -35,7 +36,7 @@ def capture(file) ensure trace&.disable if normalized_file && before_snapshot - apply_trace_events(observed_events, executed_lines, normalized_file) + apply_trace_events(observed_events, executed_lines, assignment_events, normalized_file) after_snapshot = constant_snapshot(normalized_file) changed_names = after_snapshot.keys.select do |name| before_snapshot[name] != after_snapshot[name] @@ -46,7 +47,8 @@ def capture(file) definition_active = active_definition_retained?( previous_definitions, current_definitions, - executed_lines + executed_lines, + assignment_events ) after_snapshot.key?(name) && (source_unchanged || definition_active) end @@ -131,11 +133,15 @@ def qualified_constant_name(owner_name, constant_name) "#{owner_name}::#{constant_name}" end - def apply_trace_events(events, executed_lines, file) + def apply_trace_events(events, executed_lines, assignment_events, file) events.each do |event, target, line, method_id| case event when :line executed_lines << line + when :c_call + if method_id == :const_added || (method_id == :warn && target.equal?(Warning)) + assignment_events[line] << method_id + end when :c_return @captured[file].concat(constants_set_at(target, file, line)) if method_id == :const_set when :class @@ -160,12 +166,16 @@ def constants_set_at(owner, file, line) end end - def active_definition_retained?(previous_definitions, current_definitions, executed_lines) + def active_definition_retained?(previous_definitions, current_definitions, executed_lines, assignment_events) previous_definitions.any? do |previous| current_definitions.any? do |current| next false unless previous[:signature] == current[:signature] - !current[:ambiguous_line] && executed_lines.include?(current[:line]) + events = assignment_events[current[:line]] + assignment_observed = events.include?(:const_added) || events.count(:warn) >= 2 + current[:persistent] || + assignment_observed || + (!current[:ambiguous_line] && executed_lines.include?(current[:line])) end end end @@ -199,6 +209,11 @@ def collect_assigned_constant_definitions(node, namespace, contexts, definitions node.drop(2).each do |child| collect_assigned_constant_definitions(child, namespace, contexts, definitions) end + when :method_add_arg, :command_call, :command + collect_const_set_definition(node, namespace, contexts, definitions) + node.each do |child| + collect_assigned_constant_definitions(child, namespace, contexts, definitions) + end when :if, :unless collect_assigned_constant_definitions(node[1], namespace, contexts, definitions) condition = canonical_syntax(node[1]) @@ -258,7 +273,7 @@ def collect_assignment_targets(node, namespace, definitions, signature, contexts line = source_line(node) # A line event can precede a skipped postfix/short-circuit assignment. ambiguous_line = contexts.any? { |context| context[1] == line } - definition = { signature: signature, line: line, ambiguous_line: ambiguous_line } + definition = { signature: signature, line: line, ambiguous_line: ambiguous_line, persistent: false } definitions[assigned_name] = Array(definitions[assigned_name]) | [definition] elsif node.is_a?(Array) node.each { |child| collect_assignment_targets(child, namespace, definitions, signature, contexts) } @@ -269,6 +284,88 @@ def assignment_operator(node) node.first == :opassign ? node.dig(2, 1) : nil end + def collect_const_set_definition(node, namespace, contexts, definitions) + receiver, method_name, arguments = call_parts(node) + return unless method_name == 'const_set' + + constant_name = literal_constant_name(arguments.first) + return unless constant_name + + owner_name = if receiver.nil? + namespace.join('::') + elsif object_receiver?(receiver) + '' + else + constant_name_from_node(receiver, namespace) + end + return if owner_name.nil? + + assigned_name = [owner_name, constant_name].reject(&:empty?).join('::') + signature = [:const_set, nil, contexts.map(&:first)] + line = source_line(node) + definition = { + signature: signature, + line: line, + ambiguous_line: false, + persistent: contexts.empty? || const_defined_guard?(contexts, assigned_name) + } + definitions[assigned_name] = Array(definitions[assigned_name]) | [definition] + end + + def call_parts(node) + case node.first + when :method_add_arg + call = node[1] + receiver = call[1] if call&.first == :call + method_token = call&.first == :call ? call[3] : call&.[](1) + [receiver, method_token&.[](1), call_arguments(node[2])] + when :command_call + [node[1], node.dig(3, 1), call_arguments(node[4])] + when :command + [nil, node.dig(1, 1), call_arguments(node[2])] + end + end + + def call_arguments(node) + return [] unless node.is_a?(Array) + return call_arguments(node[1]) if node.first == :arg_paren + return Array(node[1]) if node.first == :args_add_block + + [] + end + + def literal_constant_name(node) + return nil unless node.is_a?(Array) + return nil unless %i[symbol_literal string_literal].include?(node.first) + + token = find_syntax_token(node) { |type, _value| %i[@const @ident @tstring_content].include?(type) } + token&.[](1) + end + + def object_receiver?(node) + node&.first == :var_ref && node.dig(1, 0) == :@const && node.dig(1, 1) == 'Object' + end + + def const_defined_guard?(contexts, assigned_name) + constant_name = assigned_name.split('::').last + contexts.any? do |context, _line| + has_method = find_syntax_token(context) { |_type, value| value == 'const_defined?' } + has_constant = find_syntax_token(context) { |_type, value| value == constant_name } + has_method && has_constant + end + end + + def find_syntax_token(node, &predicate) + return nil unless node.is_a?(Array) + return node if node.first.to_s.start_with?('@') && predicate.call(node[0], node[1]) + + node.each do |child| + token = find_syntax_token(child, &predicate) + return token if token + end + nil + end + def canonical_syntax(node) return node unless node.is_a?(Array) return [node[0], node[1]] if node.first.to_s.start_with?('@') diff --git a/test/argument_parser_test.rb b/test/argument_parser_test.rb index 301a0d7..bdbfea4 100644 --- a/test/argument_parser_test.rb +++ b/test/argument_parser_test.rb @@ -125,6 +125,16 @@ def generic_string_list(codes:) codes end + # ITEMS [String[]] Positional string codes + def positional_string_list(items) + items + end + + # ITEMS [Array] Generic positional string codes + def generic_positional_string_list(items) + items + end + # --flags VALUES... [Boolean] Boolean flags def boolean_list(flags:) flags @@ -509,6 +519,28 @@ def test_generic_string_array_annotation_preserves_quoted_boolean_and_null_token assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } end + def test_positional_string_array_preserves_quoted_boolean_and_null_tokens + method = ScalarTypeSamples.method(:positional_string_list) + + pos_args, kw_args = @parser.parse(['["true","null"]'], method) + + assert_equal [%w[true null]], pos_args + assert_empty kw_args + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + + def test_generic_positional_string_array_preserves_quoted_boolean_and_null_tokens + method = ScalarTypeSamples.method(:generic_positional_string_list) + + pos_args, kw_args = @parser.parse(['["true","null"]'], method) + + assert_equal [%w[true null]], pos_args + assert_empty kw_args + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + def test_required_repeated_boolean_option_consumes_and_converts_its_value method = ScalarTypeSamples.method(:boolean_list) metadata = @registry.metadata_for(method) diff --git a/test/constant_capture_test.rb b/test/constant_capture_test.rb index 75269a7..8251bcc 100644 --- a/test/constant_capture_test.rb +++ b/test/constant_capture_test.rb @@ -392,6 +392,33 @@ def self.run; end end end + def test_retains_alias_when_edited_postfix_assignment_executes + capture = Rubycli::ConstantCapture.new + Tempfile.create(['postfix_executed_edited_alias', '.rb']) do |file| + source = <<~RUBY + module CapturePostfixExecutedAliasSource + def self.run; end + end + CapturePostfixExecutedAssignedAlias = CapturePostfixExecutedAliasSource if true + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CapturePostfixExecutedAssignedAlias' + ensure + cleanup_constant(:CapturePostfixExecutedAssignedAlias) + cleanup_constant(:CapturePostfixExecutedAliasSource) + end + end + def test_records_const_set_alias_after_source_file_is_edited capture = Rubycli::ConstantCapture.new Tempfile.create(['edited_const_set_alias', '.rb']) do |file| @@ -419,6 +446,35 @@ def self.run; end end end + def test_retains_guarded_const_set_alias_after_source_file_is_edited + capture = Rubycli::ConstantCapture.new + Tempfile.create(['edited_guarded_const_set_alias', '.rb']) do |file| + source = <<~RUBY + module CaptureGuardedConstSetAliasSource + def self.run; end + end + unless Object.const_defined?(:CaptureGuardedConstSetAssignedAlias, false) + Object.const_set(:CaptureGuardedConstSetAssignedAlias, CaptureGuardedConstSetAliasSource) + end + RUBY + file.write(source) + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write("#{source}\n# harmless edit\n") + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureGuardedConstSetAssignedAlias' + ensure + cleanup_constant(:CaptureGuardedConstSetAssignedAlias) + cleanup_constant(:CaptureGuardedConstSetAliasSource) + end + end + private def cleanup_constant(name) From bee411e32ab718cb75342e0882d848c011649c7c Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:48:31 +0900 Subject: [PATCH 14/20] =?UTF-8?q?fix:=20=E5=AE=9F=E8=A1=8C=E5=88=86?= =?UTF-8?q?=E5=B2=90=E3=81=AE=E5=AE=9A=E6=95=B0=E6=8D=95=E6=8D=89=E3=82=92?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20/=20Fix=20runtime=20branch=20capture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- lib/rubycli/argument_parser.rb | 9 +---- lib/rubycli/constant_capture.rb | 17 +++++---- test/argument_parser_test.rb | 17 ++++++++- test/constant_capture_test.rb | 67 +++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29a532d..9529329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ - `--check` now inspects explicitly selected commands even when their methods or defining procs come from required files. - Explicit nested constant names no longer fall back to inherited top-level constants, and malformed pre-scripts now produce contextual Rubycli errors. - Rest, optional-before-required, and trailing-required positional arguments now follow Ruby's argument binding rules for conversion, validation, and help output. -- Documented scalar/list conversions now preserve numeric-looking strings, handle repeated booleans, return real `DateTime` values, accept JSON arrays, and reject scalar/array values where `JSON`/`Hash` shapes do not allow them. +- Documented scalar/list conversions now preserve numeric-, boolean-, and null-looking strings, handle repeated booleans, return real `DateTime` values, accept JSON arrays, and reject scalar/array values where `JSON`/`Hash` shapes do not allow them. - Positional `Symbol` annotations preserve colon-prefixed symbol literals instead of embedding the colon in the symbol name. - Assignment-like positional values remain positional unless they match a keyword, while matching assignments use the same documented conversion as long options. - YARD positional tags are aligned by parameter name instead of comment order. diff --git a/lib/rubycli/argument_parser.rb b/lib/rubycli/argument_parser.rb index b0aea6e..888054d 100644 --- a/lib/rubycli/argument_parser.rb +++ b/lib/rubycli/argument_parser.rb @@ -674,14 +674,7 @@ def converter_for_single_type(type) case normalized when 'String' - ->(value) { - converted_value = convert_arg(value) - if value.is_a?(String) && converted_value.is_a?(Numeric) - value - else - converted_value - end - } + ->(value) { value } when 'Integer', 'Fixnum' ->(value) { Integer(value) } when 'Float' diff --git a/lib/rubycli/constant_capture.rb b/lib/rubycli/constant_capture.rb index 0d5e5f6..dfe7e0b 100644 --- a/lib/rubycli/constant_capture.rb +++ b/lib/rubycli/constant_capture.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require 'digest' require 'ripper' module Rubycli @@ -8,14 +7,11 @@ module Rubycli class ConstantCapture def initialize @captured = Hash.new { |hash, key| hash[key] = [] } - @source_fingerprints = {} @assignment_definitions = {} end def capture(file) normalized_file = normalize(file) - source_fingerprint = Digest::SHA256.file(normalized_file).hexdigest - source_unchanged = @source_fingerprints[normalized_file] == source_fingerprint previous_names = @captured[normalized_file].dup previous_assignment_definitions = @assignment_definitions.fetch(normalized_file, {}) current_assignment_definitions = assigned_constant_definitions(normalized_file) @@ -50,10 +46,9 @@ def capture(file) executed_lines, assignment_events ) - after_snapshot.key?(name) && (source_unchanged || definition_active) + after_snapshot.key?(name) && definition_active end @captured[normalized_file].concat(changed_names).concat(retained_names) - @source_fingerprints[normalized_file] = source_fingerprint @assignment_definitions[normalized_file] = current_assignment_definitions end end @@ -253,7 +248,7 @@ def collect_assigned_constant_definitions(node, namespace, contexts, definitions collect_assigned_constant_definitions(node[2], namespace, contexts, definitions) body_context = contexts + [[[node.first, canonical_syntax(node[2])], source_line(node[1])]] collect_assigned_constant_definitions(node[3], namespace, body_context, definitions) - when :lambda, :do_block, :brace_block + when :def, :defs, :lambda, :do_block, :brace_block lazy_context = contexts + [[[node.first], source_line(node)]] node.drop(1).each do |child| collect_assigned_constant_definitions(child, namespace, lazy_context, definitions) @@ -307,7 +302,8 @@ def collect_const_set_definition(node, namespace, contexts, definitions) signature: signature, line: line, ambiguous_line: false, - persistent: contexts.empty? || const_defined_guard?(contexts, assigned_name) + persistent: contexts.empty? || + (const_defined_guard?(contexts, assigned_name) && !lazy_context?(contexts)) } definitions[assigned_name] = Array(definitions[assigned_name]) | [definition] end @@ -355,6 +351,11 @@ def const_defined_guard?(contexts, assigned_name) end end + def lazy_context?(contexts) + lazy_types = %i[def defs lambda do_block brace_block] + contexts.any? { |context, _line| lazy_types.include?(context.first) } + end + def find_syntax_token(node, &predicate) return nil unless node.is_a?(Array) return node if node.first.to_s.start_with?('@') && predicate.call(node[0], node[1]) diff --git a/test/argument_parser_test.rb b/test/argument_parser_test.rb index bdbfea4..acbb9a5 100644 --- a/test/argument_parser_test.rb +++ b/test/argument_parser_test.rb @@ -178,13 +178,13 @@ def test_required_option_rejects_following_option_as_its_value assert_includes error.message, "Option '--greeting' requires a value" end - def test_required_option_accepts_true_as_an_explicit_value + def test_required_string_option_accepts_true_as_an_explicit_value method = DocExamples::TaggedSamples.new.method(:greet) pos_args, kw_args = @parser.parse(['Alice', '--greeting', 'true'], method) assert_equal ['Alice'], pos_args - assert_equal({ greeting: true }, kw_args) + assert_equal({ greeting: 'true' }, kw_args) end def test_required_option_accepts_lone_dash_as_its_value @@ -470,6 +470,19 @@ def test_string_annotations_preserve_numeric_looking_tokens assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } end + def test_string_annotations_preserve_boolean_and_null_looking_tokens + method = ScalarTypeSamples.method(:strings) + + %w[true false null].each do |token| + pos_args, kw_args = @parser.parse([token, '--label', token], method) + + assert_equal [token], pos_args + assert_equal({ label: token }, kw_args) + @environment.enable_strict_input! + assert_silent { @parser.validate_inputs(method, pos_args, kw_args) } + end + end + def test_symbol_annotation_converts_numeric_looking_token method = ScalarTypeSamples.method(:symbol) diff --git a/test/constant_capture_test.rb b/test/constant_capture_test.rb index 8251bcc..e4828e7 100644 --- a/test/constant_capture_test.rb +++ b/test/constant_capture_test.rb @@ -330,6 +330,39 @@ def self.run; end end end + def test_does_not_retain_alias_from_inactive_branch_when_source_is_unchanged + capture = Rubycli::ConstantCapture.new + previous_mode = ENV['RUBYCLI_CAPTURE_MODE'] + Tempfile.create(['unchanged_dynamic_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureDynamicAliasSource + def self.run; end + end + if ENV['RUBYCLI_CAPTURE_MODE'] == 'a' + CaptureDynamicAliasA = CaptureDynamicAliasSource + else + CaptureDynamicAliasB = CaptureDynamicAliasSource + end + RUBY + file.flush + + ENV['RUBYCLI_CAPTURE_MODE'] = 'a' + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureDynamicAliasA' + + ENV['RUBYCLI_CAPTURE_MODE'] = 'b' + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CaptureDynamicAliasA' + assert_includes capture.constants_for(file.path), 'CaptureDynamicAliasB' + ensure + ENV['RUBYCLI_CAPTURE_MODE'] = previous_mode + cleanup_constant(:CaptureDynamicAliasA) + cleanup_constant(:CaptureDynamicAliasB) + cleanup_constant(:CaptureDynamicAliasSource) + end + end + def test_does_not_retain_alias_when_edited_assignment_is_short_circuited capture = Rubycli::ConstantCapture.new Tempfile.create(['short_circuited_edited_alias', '.rb']) do |file| @@ -475,6 +508,40 @@ def self.run; end end end + def test_does_not_retain_const_set_moved_into_uninvoked_method + capture = Rubycli::ConstantCapture.new + Tempfile.create(['deferred_const_set_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureDeferredConstSetAliasSource + def self.run; end + end + Object.const_set(:CaptureDeferredConstSetAssignedAlias, CaptureDeferredConstSetAliasSource) + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + file.rewind + file.truncate(0) + file.write(<<~RUBY) + module CaptureDeferredConstSetAliasSource + def self.run; end + end + def capture_install_deferred_alias + Object.const_set(:CaptureDeferredConstSetAssignedAlias, CaptureDeferredConstSetAliasSource) + end + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CaptureDeferredConstSetAssignedAlias' + ensure + Object.send(:remove_method, :capture_install_deferred_alias) if Object.private_method_defined?(:capture_install_deferred_alias) + cleanup_constant(:CaptureDeferredConstSetAssignedAlias) + cleanup_constant(:CaptureDeferredConstSetAliasSource) + end + end + private def cleanup_constant(name) From e8be1b6feb1d1dd343255e420bc039fd089913d3 Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:06:26 +0900 Subject: [PATCH 15/20] =?UTF-8?q?fix:=20=E5=AE=9A=E6=95=B0guard=E3=81=A8?= =?UTF-8?q?=E8=B2=A0=E6=95=B0=E5=80=A4=E3=82=92=E4=BF=AE=E6=AD=A3=20/=20Fi?= =?UTF-8?q?x=20constant=20guards=20and=20negatives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- lib/rubycli/argument_parser.rb | 6 ++-- lib/rubycli/constant_capture.rb | 27 ++++++++++++++---- test/argument_parser_test.rb | 26 +++++++++++++++++ test/constant_capture_test.rb | 50 +++++++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9529329..a1c82e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Fixed - Parameterless commands now reject unexpected arguments without invoking the target method or attempting implicit return-value traversal. - Required options now report a missing value instead of consuming the following option token, while explicit values such as `true` remain valid. -- Required options accept negative exponent notation such as `-1e3` without mistaking it for another option. +- Required options accept negative exponent and radix notation such as `-1e3` and `-0x10` without mistaking them for another option. - Constant discovery now includes classes and modules assigned with `Class.new` / `Module.new` during the target file load. - Repeated loads retain assigned constant aliases when the source file is unchanged. - Repeated loads after source edits retain aliases created by direct, guarded, multiple, and `const_set` assignments while their active definitions remain, without reviving aliases behind newly disabled conditions. diff --git a/lib/rubycli/argument_parser.rb b/lib/rubycli/argument_parser.rb index 888054d..8bb8b1f 100644 --- a/lib/rubycli/argument_parser.rb +++ b/lib/rubycli/argument_parser.rb @@ -785,9 +785,11 @@ def looks_like_option?(token) return false unless token return false if token == '--' || token == '-' - token.start_with?('-') && !token.match?( - /\A-(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\z/ + negative_decimal = token.match?(/\A-(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\z/) + negative_radix = token.match?( + /\A-0(?:[xX][0-9a-fA-F_]+|[bB][01_]+|[oO][0-7_]+|[dD][0-9_]+)\z/ ) + token.start_with?('-') && !negative_decimal && !negative_radix end end end diff --git a/lib/rubycli/constant_capture.rb b/lib/rubycli/constant_capture.rb index dfe7e0b..b110912 100644 --- a/lib/rubycli/constant_capture.rb +++ b/lib/rubycli/constant_capture.rb @@ -268,7 +268,13 @@ def collect_assignment_targets(node, namespace, definitions, signature, contexts line = source_line(node) # A line event can precede a skipped postfix/short-circuit assignment. ambiguous_line = contexts.any? { |context| context[1] == line } - definition = { signature: signature, line: line, ambiguous_line: ambiguous_line, persistent: false } + persistent = constant_existence_guard?(contexts, assigned_name) && !lazy_context?(contexts) + definition = { + signature: signature, + line: line, + ambiguous_line: ambiguous_line, + persistent: persistent + } definitions[assigned_name] = Array(definitions[assigned_name]) | [definition] elsif node.is_a?(Array) node.each { |child| collect_assignment_targets(child, namespace, definitions, signature, contexts) } @@ -298,12 +304,13 @@ def collect_const_set_definition(node, namespace, contexts, definitions) assigned_name = [owner_name, constant_name].reject(&:empty?).join('::') signature = [:const_set, nil, contexts.map(&:first)] line = source_line(node) + ambiguous_line = contexts.any? { |context| context[1] == line } definition = { signature: signature, line: line, - ambiguous_line: false, + ambiguous_line: ambiguous_line, persistent: contexts.empty? || - (const_defined_guard?(contexts, assigned_name) && !lazy_context?(contexts)) + (constant_existence_guard?(contexts, assigned_name) && !lazy_context?(contexts)) } definitions[assigned_name] = Array(definitions[assigned_name]) | [definition] end @@ -342,15 +349,25 @@ def object_receiver?(node) node&.first == :var_ref && node.dig(1, 0) == :@const && node.dig(1, 1) == 'Object' end - def const_defined_guard?(contexts, assigned_name) + def constant_existence_guard?(contexts, assigned_name) constant_name = assigned_name.split('::').last contexts.any? do |context, _line| has_method = find_syntax_token(context) { |_type, value| value == 'const_defined?' } has_constant = find_syntax_token(context) { |_type, value| value == constant_name } - has_method && has_constant + defined_guard = syntax_node_with_token?(context, :defined, constant_name) + (has_method && has_constant) || defined_guard end end + def syntax_node_with_token?(node, node_type, token_value) + return false unless node.is_a?(Array) + if node.first == node_type + return true if find_syntax_token(node) { |_type, value| value == token_value } + end + + node.any? { |child| syntax_node_with_token?(child, node_type, token_value) } + end + def lazy_context?(contexts) lazy_types = %i[def defs lambda do_block brace_block] contexts.any? { |context, _line| lazy_types.include?(context.first) } diff --git a/test/argument_parser_test.rb b/test/argument_parser_test.rb index acbb9a5..06d2a21 100644 --- a/test/argument_parser_test.rb +++ b/test/argument_parser_test.rb @@ -101,6 +101,15 @@ def accept_hash(payload:) end end +module IntegerOptionSamples + module_function + + # --count VALUE [Integer] Required count + def count(count:) + count + end +end + module ScalarTypeSamples module_function @@ -428,6 +437,23 @@ def test_required_option_accepts_negative_exponent_value assert_equal BigDecimal('-1e3'), kw_args[:budget] end + def test_required_integer_option_accepts_negative_radix_values + method = IntegerOptionSamples.method(:count) + + { + '-0x10' => -16, + '-0x1_0' => -16, + '-0b10' => -2, + '-0o10' => -8, + '-0d10' => -10 + }.each do |token, expected| + pos_args, kw_args = @parser.parse(['--count', token], method) + + assert_empty pos_args + assert_equal({ count: expected }, kw_args) + end + end + def test_json_type_accepts_an_array_literal method = JsonTypeSamples.method(:accept) diff --git a/test/constant_capture_test.rb b/test/constant_capture_test.rb index e4828e7..9d0510a 100644 --- a/test/constant_capture_test.rb +++ b/test/constant_capture_test.rb @@ -135,6 +135,29 @@ def self.run; end end end + def test_retains_defined_guarded_alias_when_same_file_is_loaded_twice + capture = Rubycli::ConstantCapture.new + Tempfile.create(['defined_guarded_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureDefinedGuardedAliasSource + def self.run; end + end + unless defined?(CaptureDefinedGuardedAssignedAlias) + CaptureDefinedGuardedAssignedAlias = CaptureDefinedGuardedAliasSource + end + RUBY + file.flush + + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureDefinedGuardedAssignedAlias' + end + ensure + cleanup_constant(:CaptureDefinedGuardedAssignedAlias) + cleanup_constant(:CaptureDefinedGuardedAliasSource) + end + end + def test_does_not_retain_assigned_alias_removed_from_edited_source capture = Rubycli::ConstantCapture.new Tempfile.create(['removed_assigned_alias', '.rb']) do |file| @@ -542,6 +565,33 @@ def capture_install_deferred_alias end end + def test_does_not_retain_postfix_const_set_when_runtime_guard_changes + capture = Rubycli::ConstantCapture.new + previous_mode = ENV['RUBYCLI_CONST_SET_MODE'] + Tempfile.create(['postfix_const_set_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CapturePostfixConstSetAliasSource + def self.run; end + end + Object.const_set(:CapturePostfixConstSetAssignedAlias, CapturePostfixConstSetAliasSource) if ENV['RUBYCLI_CONST_SET_MODE'] == 'on' + RUBY + file.flush + + ENV['RUBYCLI_CONST_SET_MODE'] = 'on' + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CapturePostfixConstSetAssignedAlias' + + ENV['RUBYCLI_CONST_SET_MODE'] = 'off' + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CapturePostfixConstSetAssignedAlias' + ensure + ENV['RUBYCLI_CONST_SET_MODE'] = previous_mode + cleanup_constant(:CapturePostfixConstSetAssignedAlias) + cleanup_constant(:CapturePostfixConstSetAliasSource) + end + end + private def cleanup_constant(name) From a6ed557ac90ba4afabd2d9089d3bdb8d8dfad7b8 Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:15:34 +0900 Subject: [PATCH 16/20] =?UTF-8?q?fix:=20=E5=AE=9A=E6=95=B0=E5=86=8D?= =?UTF-8?q?=E8=AA=AD=E8=BE=BC=E3=81=AE=E5=AE=9F=E8=A1=8C=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E3=82=92=E5=8E=B3=E5=AF=86=E5=8C=96=20/=20Track=20completed=20?= =?UTF-8?q?constant=20definitions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- lib/rubycli/constant_capture.rb | 201 ++++++++++++++++++++++++++------ test/constant_capture_test.rb | 181 ++++++++++++++++++++++++++++ 3 files changed, 346 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1c82e3..7b2f851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ - Required options accept negative exponent and radix notation such as `-1e3` and `-0x10` without mistaking them for another option. - Constant discovery now includes classes and modules assigned with `Class.new` / `Module.new` during the target file load. - Repeated loads retain assigned constant aliases when the source file is unchanged. -- Repeated loads after source edits retain aliases created by direct, guarded, multiple, and `const_set` assignments while their active definitions remain, without reviving aliases behind newly disabled conditions. +- Repeated loads retain aliases from completed direct, multiple, and `const_set` assignments or matching fully qualified existence guards, without reviving failed assignments or aliases behind disabled conditions. - Constructor arity errors raised by `--new` are now wrapped in Rubycli's user-facing runner error. - Framework argument errors raised by constructors are also wrapped in the same user-facing runner error. - Positional type conversion now waits for JSON/eval coercion, matching keyword-option behavior and preserving `--new` JSON/eval inputs. diff --git a/lib/rubycli/constant_capture.rb b/lib/rubycli/constant_capture.rb index b110912..44f1ac3 100644 --- a/lib/rubycli/constant_capture.rb +++ b/lib/rubycli/constant_capture.rb @@ -15,12 +15,12 @@ def capture(file) previous_names = @captured[normalized_file].dup previous_assignment_definitions = @assignment_definitions.fetch(normalized_file, {}) current_assignment_definitions = assigned_constant_definitions(normalized_file) - executed_lines = [] + executed_line_contexts = Hash.new { |hash, line| hash[line] = [] } assignment_events = Hash.new { |hash, line| hash[line] = [] } observed_events = [] @captured[normalized_file] = [] before_snapshot = constant_snapshot(normalized_file) - trace = TracePoint.new(:class, :line, :c_call, :c_return) do |tp| + trace = TracePoint.new(:class, :line, :call, :return, :b_call, :b_return, :c_call, :c_return) do |tp| location = tp.path next unless location && same_file?(normalized_file, location) @@ -32,7 +32,12 @@ def capture(file) ensure trace&.disable if normalized_file && before_snapshot - apply_trace_events(observed_events, executed_lines, assignment_events, normalized_file) + apply_trace_events( + observed_events, + executed_line_contexts, + assignment_events, + normalized_file + ) after_snapshot = constant_snapshot(normalized_file) changed_names = after_snapshot.keys.select do |name| before_snapshot[name] != after_snapshot[name] @@ -43,7 +48,7 @@ def capture(file) definition_active = active_definition_retained?( previous_definitions, current_definitions, - executed_lines, + executed_line_contexts, assignment_events ) after_snapshot.key?(name) && definition_active @@ -128,11 +133,18 @@ def qualified_constant_name(owner_name, constant_name) "#{owner_name}::#{constant_name}" end - def apply_trace_events(events, executed_lines, assignment_events, file) + def apply_trace_events(events, executed_line_contexts, assignment_events, file) + active_context_events = [] events.each do |event, target, line, method_id| case event when :line - executed_lines << line + executed_line_contexts[line] << active_context_events.dup + when :call, :b_call + active_context_events << event + when :return + remove_active_context_event(active_context_events, :call) + when :b_return + remove_active_context_event(active_context_events, :b_call) when :c_call if method_id == :const_added || (method_id == :warn && target.equal?(Warning)) assignment_events[line] << method_id @@ -146,6 +158,11 @@ def apply_trace_events(events, executed_lines, assignment_events, file) end end + def remove_active_context_event(active_context_events, event) + index = active_context_events.rindex(event) + active_context_events.delete_at(index) if index + end + def constants_set_at(owner, file, line) owner_name = owner.equal?(Object) ? '' : owner.name return [] if owner_name.nil? || owner_name.start_with?('#<') @@ -161,20 +178,37 @@ def constants_set_at(owner, file, line) end end - def active_definition_retained?(previous_definitions, current_definitions, executed_lines, assignment_events) + def active_definition_retained?( + previous_definitions, + current_definitions, + executed_line_contexts, + assignment_events + ) previous_definitions.any? do |previous| current_definitions.any? do |current| next false unless previous[:signature] == current[:signature] events = assignment_events[current[:line]] assignment_observed = events.include?(:const_added) || events.count(:warn) >= 2 - current[:persistent] || - assignment_observed || - (!current[:ambiguous_line] && executed_lines.include?(current[:line])) + persistence_observed = current[:persistence_line] && + !current[:persistence_ambiguous] && + executed_line_contexts[current[:persistence_line]].any? do |active_context_events| + context_requirements_met?( + active_context_events, + current[:required_context_events] + ) + end + assignment_observed || persistence_observed end end end + def context_requirements_met?(active_context_events, required_context_events) + required_context_events.uniq.all? do |event| + active_context_events.count(event) >= required_context_events.count(event) + end + end + def assigned_constant_definitions(file) syntax_tree = Ripper.sexp(File.read(file)) return {} unless syntax_tree @@ -248,11 +282,24 @@ def collect_assigned_constant_definitions(node, namespace, contexts, definitions collect_assigned_constant_definitions(node[2], namespace, contexts, definitions) body_context = contexts + [[[node.first, canonical_syntax(node[2])], source_line(node[1])]] collect_assigned_constant_definitions(node[3], namespace, body_context, definitions) - when :def, :defs, :lambda, :do_block, :brace_block - lazy_context = contexts + [[[node.first], source_line(node)]] + when :method_add_block + collect_assigned_constant_definitions(node[1], namespace, contexts, definitions) + lazy_context = contexts + [[[:block], source_line(node[1]), :b_call]] + collect_assigned_constant_definitions(node[2], namespace, lazy_context, definitions) + when :def, :defs + lazy_context = contexts + [[[node.first], source_line(node), :call]] node.drop(1).each do |child| collect_assigned_constant_definitions(child, namespace, lazy_context, definitions) end + when :lambda + lazy_context = contexts + [[[node.first], source_line(node), :b_call]] + node.drop(1).each do |child| + collect_assigned_constant_definitions(child, namespace, lazy_context, definitions) + end + when :do_block, :brace_block + node.drop(1).each do |child| + collect_assigned_constant_definitions(child, namespace, contexts, definitions) + end else node.each do |child| collect_assigned_constant_definitions(child, namespace, contexts, definitions) @@ -266,14 +313,19 @@ def collect_assignment_targets(node, namespace, definitions, signature, contexts assigned_name = constant_name_from_node(node, namespace) if assigned_name line = source_line(node) - # A line event can precede a skipped postfix/short-circuit assignment. - ambiguous_line = contexts.any? { |context| context[1] == line } - persistent = constant_existence_guard?(contexts, assigned_name) && !lazy_context?(contexts) + persistence = persistence_metadata( + contexts, + assigned_name, + namespace, + line, + signature[1] == '||=' + ) definition = { signature: signature, line: line, - ambiguous_line: ambiguous_line, - persistent: persistent + persistence_line: persistence[:line], + persistence_ambiguous: persistence[:ambiguous], + required_context_events: persistence[:required_context_events] } definitions[assigned_name] = Array(definitions[assigned_name]) | [definition] elsif node.is_a?(Array) @@ -292,7 +344,7 @@ def collect_const_set_definition(node, namespace, contexts, definitions) constant_name = literal_constant_name(arguments.first) return unless constant_name - owner_name = if receiver.nil? + owner_name = if receiver.nil? || self_receiver?(receiver) namespace.join('::') elsif object_receiver?(receiver) '' @@ -304,13 +356,13 @@ def collect_const_set_definition(node, namespace, contexts, definitions) assigned_name = [owner_name, constant_name].reject(&:empty?).join('::') signature = [:const_set, nil, contexts.map(&:first)] line = source_line(node) - ambiguous_line = contexts.any? { |context| context[1] == line } + persistence = persistence_metadata(contexts, assigned_name, namespace, line, false) definition = { signature: signature, line: line, - ambiguous_line: ambiguous_line, - persistent: contexts.empty? || - (constant_existence_guard?(contexts, assigned_name) && !lazy_context?(contexts)) + persistence_line: persistence[:line], + persistence_ambiguous: persistence[:ambiguous], + required_context_events: persistence[:required_context_events] } definitions[assigned_name] = Array(definitions[assigned_name]) | [definition] end @@ -349,28 +401,103 @@ def object_receiver?(node) node&.first == :var_ref && node.dig(1, 0) == :@const && node.dig(1, 1) == 'Object' end - def constant_existence_guard?(contexts, assigned_name) - constant_name = assigned_name.split('::').last - contexts.any? do |context, _line| - has_method = find_syntax_token(context) { |_type, value| value == 'const_defined?' } - has_constant = find_syntax_token(context) { |_type, value| value == constant_name } - defined_guard = syntax_node_with_token?(context, :defined, constant_name) - (has_method && has_constant) || defined_guard + def self_receiver?(node) + node&.first == :var_ref && node.dig(1, 0) == :@kw && node.dig(1, 1) == 'self' + end + + def persistence_metadata(contexts, assigned_name, namespace, assignment_line, short_circuit_assignment) + guard = contexts.reverse.find do |context| + missing_constant_guard?(context.first, assigned_name, namespace) + end + persistence_line = guard&.[](1) + persistence_line ||= assignment_line if short_circuit_assignment + return { line: nil, ambiguous: false, required_context_events: [] } unless persistence_line + + same_line_contexts = contexts.select { |context| context[1] == persistence_line } + ambiguous = same_line_contexts.any? do |context| + !lazy_context?(context) && + !missing_constant_guard?(context.first, assigned_name, namespace) end + required_context_events = same_line_contexts.filter_map do |context| + context[2] + end + { + line: persistence_line, + ambiguous: ambiguous, + required_context_events: required_context_events + } + end + + def missing_constant_guard?(context, assigned_name, namespace) + condition, expected = guarded_condition(context) + return false unless condition + + condition, expected = unwrap_guard_condition(condition, expected) + expected == false && guarded_constant_name(condition, namespace) == assigned_name end - def syntax_node_with_token?(node, node_type, token_value) - return false unless node.is_a?(Array) - if node.first == node_type - return true if find_syntax_token(node) { |_type, value| value == token_value } + def guarded_condition(context) + case context.first + when :if + [context[2], context[1] == :then] + when :unless + [context[2], context[1] != :then] + when :if_mod + [context[1], true] + when :unless_mod + [context[1], false] + when :ifop + [context[2], context[1] == :then] + when :binary + [context[2], context[1] == :'&&'] + end + end + + def unwrap_guard_condition(condition, expected) + loop do + case condition&.first + when :paren + expressions = condition[1] + break unless expressions.is_a?(Array) && expressions.length == 1 + + condition = expressions.first + when :unary + break unless condition[1] == :! + + condition = condition[2] + expected = !expected + else + break + end end + [condition, expected] + end + + def guarded_constant_name(condition, namespace) + if condition&.first == :defined + return constant_name_from_node(condition[1], namespace) + end + + receiver, method_name, arguments = call_parts(condition) + return nil unless method_name == 'const_defined?' + + constant_name = literal_constant_name(arguments.first) + return nil unless constant_name + + owner_name = if receiver.nil? || self_receiver?(receiver) + namespace.join('::') + elsif object_receiver?(receiver) + '' + else + constant_name_from_node(receiver, namespace) + end + return nil if owner_name.nil? - node.any? { |child| syntax_node_with_token?(child, node_type, token_value) } + [owner_name, constant_name].reject(&:empty?).join('::') end - def lazy_context?(contexts) - lazy_types = %i[def defs lambda do_block brace_block] - contexts.any? { |context, _line| lazy_types.include?(context.first) } + def lazy_context?(context) + !context[2].nil? end def find_syntax_token(node, &predicate) diff --git a/test/constant_capture_test.rb b/test/constant_capture_test.rb index 9d0510a..9a972a4 100644 --- a/test/constant_capture_test.rb +++ b/test/constant_capture_test.rb @@ -531,6 +531,157 @@ def self.run; end end end + def test_retains_self_const_set_alias_inside_namespace + capture = Rubycli::ConstantCapture.new + Tempfile.create(['self_const_set_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureSelfConstSetAliasSource + def self.run; end + end + module CaptureSelfConstSetOwner + self.const_set(:Runner, CaptureSelfConstSetAliasSource) unless const_defined?(:Runner, false) + end + RUBY + file.flush + + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureSelfConstSetOwner::Runner' + end + ensure + if Object.const_defined?(:CaptureSelfConstSetOwner) + owner = Object.const_get(:CaptureSelfConstSetOwner) + owner.send(:remove_const, :Runner) if owner.const_defined?(:Runner, false) + end + cleanup_constant(:CaptureSelfConstSetOwner) + cleanup_constant(:CaptureSelfConstSetAliasSource) + end + end + + def test_does_not_match_existence_guard_for_another_namespace + capture = Rubycli::ConstantCapture.new + Tempfile.create(['qualified_guard_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureQualifiedGuardAliasSource + def self.run; end + end + module CaptureGuardedOwner; end + module CaptureAssignedOwner; end + unless defined?(CaptureGuardedOwner::Runner) + CaptureAssignedOwner::Runner = CaptureQualifiedGuardAliasSource + end + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + Object.const_get(:CaptureGuardedOwner).const_set(:Runner, Module.new) + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CaptureAssignedOwner::Runner' + ensure + %i[CaptureGuardedOwner CaptureAssignedOwner].each do |owner_name| + next unless Object.const_defined?(owner_name) + + owner = Object.const_get(owner_name) + owner.send(:remove_const, :Runner) if owner.const_defined?(:Runner, false) + end + cleanup_constant(:CaptureGuardedOwner) + cleanup_constant(:CaptureAssignedOwner) + cleanup_constant(:CaptureQualifiedGuardAliasSource) + end + end + + def test_retains_guarded_alias_in_immediately_executed_block + capture = Rubycli::ConstantCapture.new + Tempfile.create(['immediate_block_guarded_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureImmediateBlockAliasSource + def self.run; end + end + 1.times do; CaptureImmediateBlockAssignedAlias = CaptureImmediateBlockAliasSource unless defined?(CaptureImmediateBlockAssignedAlias); end + RUBY + file.flush + + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureImmediateBlockAssignedAlias' + end + ensure + cleanup_constant(:CaptureImmediateBlockAssignedAlias) + cleanup_constant(:CaptureImmediateBlockAliasSource) + end + end + + def test_does_not_retain_guarded_alias_in_uninvoked_block + capture = Rubycli::ConstantCapture.new + previous_mode = ENV['RUBYCLI_BLOCK_CAPTURE_MODE'] + Tempfile.create(['conditional_block_guarded_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureConditionalBlockAliasSource + def self.run; end + end + install = proc do; CaptureConditionalBlockAssignedAlias = CaptureConditionalBlockAliasSource unless defined?(CaptureConditionalBlockAssignedAlias); end + install.call if ENV['RUBYCLI_BLOCK_CAPTURE_MODE'] == 'on' + RUBY + file.flush + + ENV['RUBYCLI_BLOCK_CAPTURE_MODE'] = 'on' + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureConditionalBlockAssignedAlias' + end + + ENV['RUBYCLI_BLOCK_CAPTURE_MODE'] = 'off' + capture_io { capture.capture(file.path) { load file.path } } + refute_includes capture.constants_for(file.path), 'CaptureConditionalBlockAssignedAlias' + ensure + ENV['RUBYCLI_BLOCK_CAPTURE_MODE'] = previous_mode + cleanup_constant(:CaptureConditionalBlockAssignedAlias) + cleanup_constant(:CaptureConditionalBlockAliasSource) + end + end + + def test_retains_aliases_for_equivalent_existence_guard_forms + capture = Rubycli::ConstantCapture.new + Tempfile.create(['equivalent_guard_forms', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureEquivalentGuardSource + def self.run; end + end + module CaptureEquivalentGuardOwner; end + CaptureEquivalentGuardOwner.const_defined?(:Runner, false) || (CaptureEquivalentGuardOwner::Runner = CaptureEquivalentGuardSource) + CaptureNegatedGuardAlias = CaptureEquivalentGuardSource if !(defined?(CaptureNegatedGuardAlias)) + defined?(CaptureTernaryGuardAlias) ? nil : (CaptureTernaryGuardAlias = CaptureEquivalentGuardSource) + install = -> { CaptureLambdaGuardAlias = CaptureEquivalentGuardSource unless defined?(CaptureLambdaGuardAlias) } + install.call + RUBY + file.flush + + expected_names = %w[ + CaptureEquivalentGuardOwner::Runner + CaptureNegatedGuardAlias + CaptureTernaryGuardAlias + CaptureLambdaGuardAlias + ] + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + expected_names.each do |name| + assert_includes capture.constants_for(file.path), name + end + end + ensure + if Object.const_defined?(:CaptureEquivalentGuardOwner) + owner = Object.const_get(:CaptureEquivalentGuardOwner) + owner.send(:remove_const, :Runner) if owner.const_defined?(:Runner, false) + end + cleanup_constant(:CaptureEquivalentGuardOwner) + cleanup_constant(:CaptureEquivalentGuardSource) + cleanup_constant(:CaptureNegatedGuardAlias) + cleanup_constant(:CaptureTernaryGuardAlias) + cleanup_constant(:CaptureLambdaGuardAlias) + end + end + def test_does_not_retain_const_set_moved_into_uninvoked_method capture = Rubycli::ConstantCapture.new Tempfile.create(['deferred_const_set_alias', '.rb']) do |file| @@ -592,6 +743,36 @@ def self.run; end end end + def test_does_not_retain_assignment_when_rhs_raises_before_assignment + capture = Rubycli::ConstantCapture.new + previous_failure = ENV['RUBYCLI_CAPTURE_FAILURE'] + Tempfile.create(['raising_assignment_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureRaisingAliasSource + def self.run; end + end + begin + CaptureRaisingAssignedAlias = (ENV['RUBYCLI_CAPTURE_FAILURE'] == 'yes' ? raise('boom') : CaptureRaisingAliasSource) + rescue RuntimeError + end + RUBY + file.flush + + ENV['RUBYCLI_CAPTURE_FAILURE'] = 'no' + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureRaisingAssignedAlias' + + ENV['RUBYCLI_CAPTURE_FAILURE'] = 'yes' + capture_io { capture.capture(file.path) { load file.path } } + + refute_includes capture.constants_for(file.path), 'CaptureRaisingAssignedAlias' + ensure + ENV['RUBYCLI_CAPTURE_FAILURE'] = previous_failure + cleanup_constant(:CaptureRaisingAssignedAlias) + cleanup_constant(:CaptureRaisingAliasSource) + end + end + private def cleanup_constant(name) From b51369827c43d467380ed5db7b34984f45f2c304 Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:32:05 +0900 Subject: [PATCH 17/20] =?UTF-8?q?fix:=20=E8=BF=BD=E5=8A=A0=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E3=81=AE=E5=A2=83=E7=95=8C=E5=85=A5?= =?UTF-8?q?=E5=8A=9B=E3=82=92=E5=87=A6=E7=90=86=20/=20Handle=20review=20ed?= =?UTF-8?q?ge=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- lib/rubycli/argument_parser.rb | 5 ++++- lib/rubycli/constant_capture.rb | 15 +++++++++++++++ test/argument_parser_test.rb | 9 +++++++++ test/constant_capture_test.rb | 6 ++++++ 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b2f851..4eb129c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ### Fixed - Parameterless commands now reject unexpected arguments without invoking the target method or attempting implicit return-value traversal. - Required options now report a missing value instead of consuming the following option token, while explicit values such as `true` remain valid. -- Required options accept negative exponent and radix notation such as `-1e3` and `-0x10` without mistaking them for another option. +- Required options accept negative exponent, digit-separated decimal, and radix notation such as `-1e3`, `-1_000`, and `-0x10` without mistaking them for another option. - Constant discovery now includes classes and modules assigned with `Class.new` / `Module.new` during the target file load. - Repeated loads retain assigned constant aliases when the source file is unchanged. - Repeated loads retain aliases from completed direct, multiple, and `const_set` assignments or matching fully qualified existence guards, without reviving failed assignments or aliases behind disabled conditions. diff --git a/lib/rubycli/argument_parser.rb b/lib/rubycli/argument_parser.rb index 8bb8b1f..edb10c3 100644 --- a/lib/rubycli/argument_parser.rb +++ b/lib/rubycli/argument_parser.rb @@ -785,7 +785,10 @@ def looks_like_option?(token) return false unless token return false if token == '--' || token == '-' - negative_decimal = token.match?(/\A-(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?\z/) + decimal_digits = '\d(?:_?\d)*' + negative_decimal = token.match?( + /\A-(?:#{decimal_digits}(?:\.(?:#{decimal_digits})?)?|\.#{decimal_digits})(?:[eE][+-]?#{decimal_digits})?\z/ + ) negative_radix = token.match?( /\A-0(?:[xX][0-9a-fA-F_]+|[bB][01_]+|[oO][0-7_]+|[dD][0-9_]+)\z/ ) diff --git a/lib/rubycli/constant_capture.rb b/lib/rubycli/constant_capture.rb index 44f1ac3..0ef006c 100644 --- a/lib/rubycli/constant_capture.rb +++ b/lib/rubycli/constant_capture.rb @@ -466,6 +466,17 @@ def unwrap_guard_condition(condition, expected) condition = condition[2] expected = !expected + when :call + break unless condition.dig(3, 1) == 'nil?' + + condition = condition[1] + expected = !expected + when :binary + left, operator, right = condition.drop(1) + break unless operator == :== && nil_literal?(right) + + condition = left + expected = !expected else break end @@ -473,6 +484,10 @@ def unwrap_guard_condition(condition, expected) [condition, expected] end + def nil_literal?(node) + node&.first == :var_ref && node.dig(1, 0) == :@kw && node.dig(1, 1) == 'nil' + end + def guarded_constant_name(condition, namespace) if condition&.first == :defined return constant_name_from_node(condition[1], namespace) diff --git a/test/argument_parser_test.rb b/test/argument_parser_test.rb index 06d2a21..c2e0887 100644 --- a/test/argument_parser_test.rb +++ b/test/argument_parser_test.rb @@ -454,6 +454,15 @@ def test_required_integer_option_accepts_negative_radix_values end end + def test_required_integer_option_accepts_negative_decimal_with_separator + method = IntegerOptionSamples.method(:count) + + pos_args, kw_args = @parser.parse(['--count', '-1_000'], method) + + assert_empty pos_args + assert_equal({ count: -1000 }, kw_args) + end + def test_json_type_accepts_an_array_literal method = JsonTypeSamples.method(:accept) diff --git a/test/constant_capture_test.rb b/test/constant_capture_test.rb index 9a972a4..ea00e09 100644 --- a/test/constant_capture_test.rb +++ b/test/constant_capture_test.rb @@ -652,6 +652,8 @@ module CaptureEquivalentGuardOwner; end CaptureEquivalentGuardOwner.const_defined?(:Runner, false) || (CaptureEquivalentGuardOwner::Runner = CaptureEquivalentGuardSource) CaptureNegatedGuardAlias = CaptureEquivalentGuardSource if !(defined?(CaptureNegatedGuardAlias)) defined?(CaptureTernaryGuardAlias) ? nil : (CaptureTernaryGuardAlias = CaptureEquivalentGuardSource) + CaptureNilPredicateGuardAlias = CaptureEquivalentGuardSource if defined?(CaptureNilPredicateGuardAlias).nil? + CaptureNilEqualityGuardAlias = CaptureEquivalentGuardSource if defined?(CaptureNilEqualityGuardAlias) == nil install = -> { CaptureLambdaGuardAlias = CaptureEquivalentGuardSource unless defined?(CaptureLambdaGuardAlias) } install.call RUBY @@ -661,6 +663,8 @@ module CaptureEquivalentGuardOwner; end CaptureEquivalentGuardOwner::Runner CaptureNegatedGuardAlias CaptureTernaryGuardAlias + CaptureNilPredicateGuardAlias + CaptureNilEqualityGuardAlias CaptureLambdaGuardAlias ] 2.times do @@ -678,6 +682,8 @@ module CaptureEquivalentGuardOwner; end cleanup_constant(:CaptureEquivalentGuardSource) cleanup_constant(:CaptureNegatedGuardAlias) cleanup_constant(:CaptureTernaryGuardAlias) + cleanup_constant(:CaptureNilPredicateGuardAlias) + cleanup_constant(:CaptureNilEqualityGuardAlias) cleanup_constant(:CaptureLambdaGuardAlias) end end From a668fd1b36b825f8757d178d8bc7f361fcd47a80 Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 19:52:41 +0900 Subject: [PATCH 18/20] =?UTF-8?q?fix:=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E5=A2=83=E7=95=8C=E3=81=AE=E8=A7=A3=E6=9E=90=E3=82=92?= =?UTF-8?q?=E8=A3=9C=E5=BC=B7=20/=20Harden=20review=20edge=20parsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 5 ++-- lib/rubycli/argument_parser.rb | 41 ++++++++++++++++++++++++------ lib/rubycli/constant_capture.rb | 44 ++++++++++++++++++++++++++++++--- test/argument_parser_test.rb | 32 ++++++++++++++++++++++++ test/constant_capture_test.rb | 32 ++++++++++++++++++++++++ test/coverage_gate_test.rb | 15 +++++++++++ test/support/coverage_gate.rb | 19 ++++++++++++++ 7 files changed, 175 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb129c..848b385 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,10 @@ - Parameterless commands now reject unexpected arguments without invoking the target method or attempting implicit return-value traversal. - Required options now report a missing value instead of consuming the following option token, while explicit values such as `true` remain valid. - Required options accept negative exponent, digit-separated decimal, and radix notation such as `-1e3`, `-1_000`, and `-0x10` without mistaking them for another option. +- Required options in eval mode accept space-separated lambda and unary expressions without consuming known option tokens as values. - Constant discovery now includes classes and modules assigned with `Class.new` / `Module.new` during the target file load. - Repeated loads retain assigned constant aliases when the source file is unchanged. -- Repeated loads retain aliases from completed direct, multiple, and `const_set` assignments or matching fully qualified existence guards, without reviving failed assignments or aliases behind disabled conditions. +- Repeated loads retain aliases from completed direct, multiple, and `const_set` assignments or matching fully qualified existence guards, including receivers found by Ruby's lexical constant fallback, without reviving failed assignments or aliases behind disabled conditions. - Constructor arity errors raised by `--new` are now wrapped in Rubycli's user-facing runner error. - Framework argument errors raised by constructors are also wrapped in the same user-facing runner error. - Positional type conversion now waits for JSON/eval coercion, matching keyword-option behavior and preserving `--new` JSON/eval inputs. @@ -29,7 +30,7 @@ ### Testing - Added dependency-free overall line, branch, and changed-line coverage gates plus GitHub Actions checks spanning the supported Ruby range. -- Changed-line coverage now treats new library files that were never loaded by the test suite as uncovered. +- Changed-line coverage now treats new library files that were never loaded by the test suite as uncovered, including non-ASCII paths quoted by Git. - Push coverage compares against the previous commit and falls back to the default branch when a new ref has no previous commit or a ref is deleted. ## [0.1.7] - 2025-11-12 diff --git a/lib/rubycli/argument_parser.rb b/lib/rubycli/argument_parser.rb index edb10c3..4a2c97b 100644 --- a/lib/rubycli/argument_parser.rb +++ b/lib/rubycli/argument_parser.rb @@ -1,4 +1,5 @@ require 'did_you_mean' +require 'ripper' require_relative 'type_utils' require_relative 'arguments/token_stream' @@ -30,6 +31,9 @@ def parse(args, method = nil) cli_aliases = build_cli_alias_map(option_defs) option_lookup = build_option_lookup(option_defs) type_converters = build_type_converter_map(option_defs) + known_option_names = ( + kw_param_names + cli_aliases.keys + option_lookup.keys.map(&:to_s) + ).uniq stream = Arguments::TokenStream.new(args) @@ -52,7 +56,8 @@ def parse(args, method = nil) cli_aliases, option_lookup, type_converters, - required_kw_param_names + required_kw_param_names, + known_option_names ) elsif assignment_token_for_method?(token, method, kw_param_names) stream.advance @@ -122,7 +127,8 @@ def process_option_token( cli_aliases, option_lookup, type_converters, - required_kw_param_names + required_kw_param_names, + known_option_names ) token =~ /\A-{1,2}([a-zA-Z0-9_-]+)(?:=(.*))?\z/ cli_key = Regexp.last_match(1).tr('-', '_') @@ -149,10 +155,11 @@ def process_option_token( capture_option_value( option_meta, stream, - requires_value + requires_value, + known_option_names ) elsif requires_value - capture_required_option_value(option_label, stream) + capture_required_option_value(option_label, stream, known_option_names) elsif (next_token = stream.current) && !looks_like_option?(next_token) stream.consume else @@ -173,16 +180,18 @@ def process_option_token( kw_args[final_key_sym] = converted_value end - def capture_required_option_value(option_label, stream) + def capture_required_option_value(option_label, stream, known_option_names) next_token = stream.current - if next_token.nil? || looks_like_option?(next_token) + option_like_value = looks_like_option?(next_token) && + !valid_eval_option_value?(next_token, known_option_names) + if next_token.nil? || option_like_value raise ArgumentError, "Option '#{option_label}' requires a value" end stream.consume end - def capture_option_value(option_meta, stream, requires_value) + def capture_option_value(option_meta, stream, requires_value, known_option_names) if option_meta[:boolean_flag] if (next_token = stream.current) && TypeUtils.boolean_string?(next_token) return stream.consume @@ -196,7 +205,7 @@ def capture_option_value(option_meta, stream, requires_value) elsif requires_value == false return 'true' elsif requires_value - return capture_required_option_value(option_meta.long, stream) + return capture_required_option_value(option_meta.long, stream, known_option_names) elsif (next_token = stream.current) && !looks_like_option?(next_token) return stream.consume else @@ -204,6 +213,22 @@ def capture_option_value(option_meta, stream, requires_value) end end + def valid_eval_option_value?(token, known_option_names) + return false unless Rubycli.eval_mode? + return false if known_option_token?(token, known_option_names) + + !Ripper.sexp(token).nil? + end + + def known_option_token?(token, known_option_names) + return false unless token&.match?(/\A-{1,2}[a-zA-Z0-9_-]+\z/) + + key = token.delete_prefix('--').delete_prefix('-').tr('-', '_') + return true if known_option_names.include?(key) + + known_option_names.one? { |name| name.start_with?(key) } + end + def process_assignment_token(token, kw_args, option_lookup, type_converters) key, value = split_assignment_token(token) keyword = key.to_sym diff --git a/lib/rubycli/constant_capture.rb b/lib/rubycli/constant_capture.rb index 0ef006c..fef9657 100644 --- a/lib/rubycli/constant_capture.rb +++ b/lib/rubycli/constant_capture.rb @@ -14,7 +14,6 @@ def capture(file) normalized_file = normalize(file) previous_names = @captured[normalized_file].dup previous_assignment_definitions = @assignment_definitions.fetch(normalized_file, {}) - current_assignment_definitions = assigned_constant_definitions(normalized_file) executed_line_contexts = Hash.new { |hash, line| hash[line] = [] } assignment_events = Hash.new { |hash, line| hash[line] = [] } observed_events = [] @@ -32,6 +31,7 @@ def capture(file) ensure trace&.disable if normalized_file && before_snapshot + current_assignment_definitions = assigned_constant_definitions(normalized_file) apply_trace_events( observed_events, executed_line_contexts, @@ -349,7 +349,7 @@ def collect_const_set_definition(node, namespace, contexts, definitions) elsif object_receiver?(receiver) '' else - constant_name_from_node(receiver, namespace) + receiver_constant_name_from_node(receiver, namespace) end return if owner_name.nil? @@ -504,13 +504,51 @@ def guarded_constant_name(condition, namespace) elsif object_receiver?(receiver) '' else - constant_name_from_node(receiver, namespace) + receiver_constant_name_from_node(receiver, namespace) end return nil if owner_name.nil? [owner_name, constant_name].reject(&:empty?).join('::') end + def receiver_constant_name_from_node(node, namespace) + return nil unless node.is_a?(Array) + + case node.first + when :var_ref, :const_ref + receiver_constant_name_from_node(node[1], namespace) + when :@const + resolve_lexical_constant_name(node[1], namespace) + when :const_path_ref + parent_name = receiver_constant_name_from_node(node[1], namespace) + child_name = node.dig(2, 1) + [parent_name, child_name].compact.join('::') + when :top_const_ref + node.dig(1, 1) + end + end + + def resolve_lexical_constant_name(constant_name, namespace) + namespace.length.downto(0) do |depth| + candidate = (namespace.first(depth) + [constant_name]).join('::') + return candidate if constant_path_defined?(candidate) + end + + (namespace + [constant_name]).join('::') + end + + def constant_path_defined?(name) + parts = name.split('::') + parts.reduce(Object) do |owner, part| + return false unless owner.is_a?(Module) && owner.const_defined?(part, false) + + owner.const_get(part, false) + end + true + rescue StandardError + false + end + def lazy_context?(context) !context[2].nil? end diff --git a/test/argument_parser_test.rb b/test/argument_parser_test.rb index c2e0887..43d6e9d 100644 --- a/test/argument_parser_test.rb +++ b/test/argument_parser_test.rb @@ -110,6 +110,14 @@ def count(count:) end end +module EvalRequiredOptionSamples + module_function + + def run(callback:, verbose: false) + [callback, verbose] + end +end + module ScalarTypeSamples module_function @@ -340,6 +348,30 @@ def test_preserves_keyword_values_under_eval_mode end end + def test_eval_mode_accepts_option_looking_required_values + method = EvalRequiredOptionSamples.method(:run) + + Rubycli.with_eval_mode(true) do + _pos_args, lambda_args = @parser.parse(['--callback', '-> { 42 }'], method) + _pos_args, unary_args = @parser.parse(['--callback', '-some_value'], method) + + assert_equal({ callback: '-> { 42 }' }, lambda_args) + assert_equal({ callback: '-some_value' }, unary_args) + end + end + + def test_eval_mode_does_not_consume_known_option_as_required_value + method = EvalRequiredOptionSamples.method(:run) + + error = assert_raises(Rubycli::ArgumentError) do + Rubycli.with_eval_mode(true) do + @parser.parse(['--callback', '--verbose'], method) + end + end + + assert_includes error.message, "Option '--callback' requires a value" + end + def test_validate_inputs_warns_when_values_outside_choices method = ValidationSamples.method(:check) warnings = [] diff --git a/test/constant_capture_test.rb b/test/constant_capture_test.rb index ea00e09..cc78d81 100644 --- a/test/constant_capture_test.rb +++ b/test/constant_capture_test.rb @@ -558,6 +558,38 @@ module CaptureSelfConstSetOwner end end + def test_resolves_const_set_receiver_through_lexical_fallback + capture = Rubycli::ConstantCapture.new + Tempfile.create(['fallback_const_set_alias', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureFallbackConstSetSource + def self.run; end + end + module CaptureFallbackConstSetRoot + module Owner; end + end + module CaptureFallbackConstSetNamespace + CaptureFallbackConstSetRoot::Owner.const_set(:Runner, CaptureFallbackConstSetSource) unless CaptureFallbackConstSetRoot::Owner.const_defined?(:Runner, false) + end + RUBY + file.flush + + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureFallbackConstSetRoot::Owner::Runner' + end + ensure + if Object.const_defined?(:CaptureFallbackConstSetRoot) + root = Object.const_get(:CaptureFallbackConstSetRoot) + owner = root.const_get(:Owner, false) if root.const_defined?(:Owner, false) + owner.send(:remove_const, :Runner) if owner&.const_defined?(:Runner, false) + end + cleanup_constant(:CaptureFallbackConstSetNamespace) + cleanup_constant(:CaptureFallbackConstSetRoot) + cleanup_constant(:CaptureFallbackConstSetSource) + end + end + def test_does_not_match_existence_guard_for_another_namespace capture = Rubycli::ConstantCapture.new Tempfile.create(['qualified_guard_alias', '.rb']) do |file| diff --git a/test/coverage_gate_test.rb b/test/coverage_gate_test.rb index 5a005ef..d135949 100644 --- a/test/coverage_gate_test.rb +++ b/test/coverage_gate_test.rb @@ -41,6 +41,21 @@ def test_changed_lines_ignores_deleted_files_and_non_library_paths assert_equal({}, CoverageGate.changed_lines(diff, path_prefix: 'lib/')) end + def test_changed_lines_decodes_git_quoted_paths + diff = <<~'DIFF' + diff --git "a/lib/\343\203\254\343\203\223\343\203\245\343\203\274.rb" "b/lib/\343\203\254\343\203\223\343\203\245\343\203\274.rb" + --- /dev/null + +++ "b/lib/\343\203\254\343\203\223\343\203\245\343\203\274.rb" + @@ -0,0 +1 @@ + +puts :untested + DIFF + + assert_equal( + { 'lib/レビュー.rb' => [1] }, + CoverageGate.changed_lines(diff) + ) + end + def test_coverage_stats_count_only_executable_changed_lines changed_lines = { 'lib/example.rb' => [1, 2, 3, 4, 8] } line_hits = { diff --git a/test/support/coverage_gate.rb b/test/support/coverage_gate.rb index 963257c..fb4390a 100644 --- a/test/support/coverage_gate.rb +++ b/test/support/coverage_gate.rb @@ -16,6 +16,7 @@ def changed_lines(diff, path_prefix: 'lib/') diff.each_line do |line| if line.start_with?('+++ ') path = line.delete_prefix('+++ ').strip + path = decode_git_path(path) current_path = path == '/dev/null' ? nil : path.delete_prefix('b/') current_path = nil unless current_path&.start_with?(path_prefix) next @@ -42,6 +43,24 @@ def changed_lines(diff, path_prefix: 'lib/') lines_by_path.transform_values { |lines| lines.uniq.sort } end + def decode_git_path(path) + return path unless path.start_with?('"') && path.end_with?('"') + + escaped = path.byteslice(1, path.bytesize - 2).b + decoded = escaped.gsub(/\\(?:[0-7]{3}|.)/n) do |sequence| + escape = sequence.byteslice(1..) + if escape.match?(/\A[0-7]{3}\z/) + [escape.to_i(8)].pack('C') + else + { + 'a' => "\a", 'b' => "\b", 't' => "\t", 'n' => "\n", + 'v' => "\v", 'f' => "\f", 'r' => "\r", '\\' => '\\', '"' => '"' + }.fetch(escape, escape) + end + end + decoded.force_encoding(Encoding::UTF_8) + end + def coverage_stats(changed_lines_by_path, line_hits_by_path) covered = 0 total = 0 From cb9c8e0648cabfe8cd24faeaaf7a20a71c4217a0 Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:07:35 +0900 Subject: [PATCH 19/20] =?UTF-8?q?fix:=20=E9=9D=99=E7=9A=84=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E3=81=AE=E5=89=AF=E4=BD=9C=E7=94=A8=E3=82=92=E9=98=B2?= =?UTF-8?q?=E6=AD=A2=20/=20Avoid=20analysis=20side=20effects?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 1 + lib/rubycli/argument_parser.rb | 5 ++-- lib/rubycli/constant_capture.rb | 11 ++++--- test/argument_parser_test.rb | 12 +++++--- test/constant_capture_test.rb | 53 +++++++++++++++++++++++++++++++++ 5 files changed, 72 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 848b385..f644d1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Constant discovery now includes classes and modules assigned with `Class.new` / `Module.new` during the target file load. - Repeated loads retain assigned constant aliases when the source file is unchanged. - Repeated loads retain aliases from completed direct, multiple, and `const_set` assignments or matching fully qualified existence guards, including receivers found by Ruby's lexical constant fallback, without reviving failed assignments or aliases behind disabled conditions. +- Constant discovery analyzes the preloaded source without triggering autoloads, so inactive autoload branches and self-removing target files do not add side effects or fail after a successful load. - Constructor arity errors raised by `--new` are now wrapped in Rubycli's user-facing runner error. - Framework argument errors raised by constructors are also wrapped in the same user-facing runner error. - Positional type conversion now waits for JSON/eval coercion, matching keyword-option behavior and preserving `--new` JSON/eval inputs. diff --git a/lib/rubycli/argument_parser.rb b/lib/rubycli/argument_parser.rb index 4a2c97b..cf8d43c 100644 --- a/lib/rubycli/argument_parser.rb +++ b/lib/rubycli/argument_parser.rb @@ -221,9 +221,10 @@ def valid_eval_option_value?(token, known_option_names) end def known_option_token?(token, known_option_names) - return false unless token&.match?(/\A-{1,2}[a-zA-Z0-9_-]+\z/) + match = token&.match(/\A-{1,2}([a-zA-Z0-9_-]+)(?:=.*)?\z/) + return false unless match - key = token.delete_prefix('--').delete_prefix('-').tr('-', '_') + key = match[1].tr('-', '_') return true if known_option_names.include?(key) known_option_names.one? { |name| name.start_with?(key) } diff --git a/lib/rubycli/constant_capture.rb b/lib/rubycli/constant_capture.rb index fef9657..d79beca 100644 --- a/lib/rubycli/constant_capture.rb +++ b/lib/rubycli/constant_capture.rb @@ -12,6 +12,7 @@ def initialize def capture(file) normalized_file = normalize(file) + source = File.read(normalized_file) previous_names = @captured[normalized_file].dup previous_assignment_definitions = @assignment_definitions.fetch(normalized_file, {}) executed_line_contexts = Hash.new { |hash, line| hash[line] = [] } @@ -31,7 +32,7 @@ def capture(file) ensure trace&.disable if normalized_file && before_snapshot - current_assignment_definitions = assigned_constant_definitions(normalized_file) + current_assignment_definitions = assigned_constant_definitions(source) apply_trace_events( observed_events, executed_line_contexts, @@ -209,8 +210,8 @@ def context_requirements_met?(active_context_events, required_context_events) end end - def assigned_constant_definitions(file) - syntax_tree = Ripper.sexp(File.read(file)) + def assigned_constant_definitions(source) + syntax_tree = Ripper.sexp(source) return {} unless syntax_tree collect_assigned_constant_definitions(syntax_tree, [], [], {}) @@ -539,8 +540,10 @@ def resolve_lexical_constant_name(constant_name, namespace) def constant_path_defined?(name) parts = name.split('::') - parts.reduce(Object) do |owner, part| + parts.each_with_index.reduce(Object) do |owner, (part, index)| return false unless owner.is_a?(Module) && owner.const_defined?(part, false) + return true if index == parts.length - 1 + return false if owner.autoload?(part, false) owner.const_get(part, false) end diff --git a/test/argument_parser_test.rb b/test/argument_parser_test.rb index 43d6e9d..a8f7068 100644 --- a/test/argument_parser_test.rb +++ b/test/argument_parser_test.rb @@ -363,13 +363,17 @@ def test_eval_mode_accepts_option_looking_required_values def test_eval_mode_does_not_consume_known_option_as_required_value method = EvalRequiredOptionSamples.method(:run) - error = assert_raises(Rubycli::ArgumentError) do - Rubycli.with_eval_mode(true) do + Rubycli.with_eval_mode(true) do + plain_error = assert_raises(Rubycli::ArgumentError) do @parser.parse(['--callback', '--verbose'], method) end - end + embedded_error = assert_raises(Rubycli::ArgumentError) do + @parser.parse(['--callback', '--verbose=1'], method) + end - assert_includes error.message, "Option '--callback' requires a value" + assert_includes plain_error.message, "Option '--callback' requires a value" + assert_includes embedded_error.message, "Option '--callback' requires a value" + end end def test_validate_inputs_warns_when_values_outside_choices diff --git a/test/constant_capture_test.rb b/test/constant_capture_test.rb index cc78d81..0fdf982 100644 --- a/test/constant_capture_test.rb +++ b/test/constant_capture_test.rb @@ -590,6 +590,59 @@ module CaptureFallbackConstSetNamespace end end + def test_does_not_trigger_autoload_while_resolving_const_set_receiver + capture = Rubycli::ConstantCapture.new + previous_marker = ENV['RUBYCLI_AUTOLOAD_CAPTURED'] + Tempfile.create(['autoload_side_effect', '.rb']) do |autoload_file| + autoload_file.write(<<~RUBY) + ENV['RUBYCLI_AUTOLOAD_CAPTURED'] = 'yes' + module CaptureAutoloadConstSetOwner; end + RUBY + autoload_file.flush + + Tempfile.create(['autoload_const_set_alias', '.rb']) do |target_file| + target_file.write(<<~RUBY) + autoload :CaptureAutoloadConstSetOwner, #{autoload_file.path.dump} + if false + CaptureAutoloadConstSetOwner.const_set(:Runner, Module.new) + end + module CaptureAutoloadActualRunner + def self.run; end + end + RUBY + target_file.flush + + capture_io { capture.capture(target_file.path) { load target_file.path } } + + assert_includes capture.constants_for(target_file.path), 'CaptureAutoloadActualRunner' + assert_nil ENV['RUBYCLI_AUTOLOAD_CAPTURED'] + end + ensure + ENV['RUBYCLI_AUTOLOAD_CAPTURED'] = previous_marker + cleanup_constant(:CaptureAutoloadConstSetOwner) + cleanup_constant(:CaptureAutoloadActualRunner) + end + end + + def test_uses_preloaded_source_when_target_deletes_itself + capture = Rubycli::ConstantCapture.new + Tempfile.create(['self_deleting_capture', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureSelfDeletingRunner + def self.run; end + end + File.delete(__FILE__) + RUBY + file.flush + + capture_io { capture.capture(file.path) { load file.path } } + + assert_includes capture.constants_for(file.path), 'CaptureSelfDeletingRunner' + ensure + cleanup_constant(:CaptureSelfDeletingRunner) + end + end + def test_does_not_match_existence_guard_for_another_namespace capture = Rubycli::ConstantCapture.new Tempfile.create(['qualified_guard_alias', '.rb']) do |file| From 6941eabde0fe7e1ffcb1e1c2caa55656154681e4 Mon Sep 17 00:00:00 2001 From: inakaegg <52376271+inakaegg@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:29:33 +0900 Subject: [PATCH 20/20] =?UTF-8?q?fix:=20=E5=AD=98=E5=9C=A8=E3=82=AC?= =?UTF-8?q?=E3=83=BC=E3=83=89=E3=81=AE=E8=A7=A3=E6=B1=BA=E7=AF=84=E5=9B=B2?= =?UTF-8?q?=E3=82=92=E6=8B=A1=E5=BC=B5=20/=20Resolve=20loop=20and=20qualif?= =?UTF-8?q?ied=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 2 +- lib/rubycli/constant_capture.rb | 6 +++- test/constant_capture_test.rb | 55 +++++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f644d1a..344465f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ - Required options in eval mode accept space-separated lambda and unary expressions without consuming known option tokens as values. - Constant discovery now includes classes and modules assigned with `Class.new` / `Module.new` during the target file load. - Repeated loads retain assigned constant aliases when the source file is unchanged. -- Repeated loads retain aliases from completed direct, multiple, and `const_set` assignments or matching fully qualified existence guards, including receivers found by Ruby's lexical constant fallback, without reviving failed assignments or aliases behind disabled conditions. +- Repeated loads retain aliases from completed direct, multiple, and `const_set` assignments or matching fully qualified existence guards, including loop initializers and receivers found by Ruby's lexical constant fallback, without reviving failed assignments or aliases behind disabled conditions. - Constant discovery analyzes the preloaded source without triggering autoloads, so inactive autoload branches and self-removing target files do not add side effects or fail after a successful load. - Constructor arity errors raised by `--new` are now wrapped in Rubycli's user-facing runner error. - Framework argument errors raised by constructors are also wrapped in the same user-facing runner error. diff --git a/lib/rubycli/constant_capture.rb b/lib/rubycli/constant_capture.rb index d79beca..53577d5 100644 --- a/lib/rubycli/constant_capture.rb +++ b/lib/rubycli/constant_capture.rb @@ -447,6 +447,10 @@ def guarded_condition(context) [context[1], true] when :unless_mod [context[1], false] + when :while, :while_mod + [context[1], true] + when :until, :until_mod + [context[1], false] when :ifop [context[2], context[1] == :then] when :binary @@ -594,7 +598,7 @@ def constant_name_from_node(node, namespace) when :@const (namespace + [node[1]]).join('::') when :const_path_field, :const_path_ref - parent_name = constant_name_from_node(node[1], namespace) + parent_name = receiver_constant_name_from_node(node[1], namespace) child_name = node.dig(2, 1) [parent_name, child_name].compact.join('::') when :top_const_field, :top_const_ref diff --git a/test/constant_capture_test.rb b/test/constant_capture_test.rb index 0fdf982..74dae15 100644 --- a/test/constant_capture_test.rb +++ b/test/constant_capture_test.rb @@ -773,6 +773,61 @@ module CaptureEquivalentGuardOwner; end end end + def test_retains_aliases_initialized_by_loop_existence_guards + skip 'Ruby 2.x does not reliably emit loop-condition line events' if Gem::Version.new(RUBY_VERSION) < Gem::Version.new('3.0') + + capture = Rubycli::ConstantCapture.new + Tempfile.create(['loop_guarded_aliases', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureLoopGuardSource + def self.run; end + end + while !defined?(CaptureWhileGuardAlias) + CaptureWhileGuardAlias = CaptureLoopGuardSource + end + CaptureUntilGuardAlias = CaptureLoopGuardSource until defined?(CaptureUntilGuardAlias) + RUBY + file.flush + + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureWhileGuardAlias' + assert_includes capture.constants_for(file.path), 'CaptureUntilGuardAlias' + end + ensure + cleanup_constant(:CaptureWhileGuardAlias) + cleanup_constant(:CaptureUntilGuardAlias) + cleanup_constant(:CaptureLoopGuardSource) + end + end + + def test_retains_namespace_qualified_defined_guard + capture = Rubycli::ConstantCapture.new + Tempfile.create(['namespace_qualified_guard', '.rb']) do |file| + file.write(<<~RUBY) + module CaptureQualifiedGuardSource + def self.run; end + end + module CaptureQualifiedGuardNamespace + Runner = CaptureQualifiedGuardSource unless defined?(CaptureQualifiedGuardNamespace::Runner) + end + RUBY + file.flush + + 2.times do + capture_io { capture.capture(file.path) { load file.path } } + assert_includes capture.constants_for(file.path), 'CaptureQualifiedGuardNamespace::Runner' + end + ensure + if Object.const_defined?(:CaptureQualifiedGuardNamespace) + owner = Object.const_get(:CaptureQualifiedGuardNamespace) + owner.send(:remove_const, :Runner) if owner.const_defined?(:Runner, false) + end + cleanup_constant(:CaptureQualifiedGuardNamespace) + cleanup_constant(:CaptureQualifiedGuardSource) + end + end + def test_does_not_retain_const_set_moved_into_uninvoked_method capture = Rubycli::ConstantCapture.new Tempfile.create(['deferred_const_set_alias', '.rb']) do |file|