diff --git a/package.json b/package.json index 1849cb599a..f6d60b37f3 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "devDependencies": { "@ast-grep/lang-go": "catalog:", "@ast-grep/lang-python": "catalog:", + "@ast-grep/lang-ruby": "catalog:", "@ast-grep/napi": "catalog:", "@changesets/changelog-github": "catalog:", "@changesets/cli": "catalog:", diff --git a/packages/sdk-ruby/ESTIMATE.md b/packages/sdk-ruby/ESTIMATE.md index 27da2456e8..61c5dcc5fc 100644 --- a/packages/sdk-ruby/ESTIMATE.md +++ b/packages/sdk-ruby/ESTIMATE.md @@ -16,34 +16,34 @@ Python and Go SDKs are built. ## What the spike proved (de-risked) -| Risk | Outcome | -|---|---| -| No JSON-Schema→Ruby codegen exists | Custom stdlib-only generator, 274 LOC, covers all 234 defs + method registries, `--check` drift mode wired into `just generate`/`just check` | -| Wire casing | Free — the schema is already snake_case; opaque containers pass through verbatim (fixture round-trip tested) | -| Sync Ruby ↔ bidirectional RPC | Background-reader-thread model (Go-style) works; `websocket-driver` + own TCP/SSL socket, no reactor framework needed | -| Chrome without Playwright | Hand-rolled launcher ported from Python's `browser.py` works headless + headed on macOS | -| Extension packaging | Ruby zip is **byte-identical** to the Python SDK's deterministic archive (same SHA-256) | -| No Browserbase Ruby SDK exists | Hand-rolled 4-endpoint REST client (~130 LOC) suffices, mirroring Go | -| End-to-end | `goto → observe → act → extract` verified live on a local Chrome (transport) and on a Browserbase session (full AI loop incl. extension upload + Model Gateway + session release) | +| Risk | Outcome | +| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| No JSON-Schema→Ruby codegen exists | Custom stdlib-only generator, 274 LOC, covers all 234 defs + method registries, `--check` drift mode wired into `just generate`/`just check` | +| Wire casing | Free — the schema is already snake_case; opaque containers pass through verbatim (fixture round-trip tested) | +| Sync Ruby ↔ bidirectional RPC | Background-reader-thread model (Go-style) works; `websocket-driver` + own TCP/SSL socket, no reactor framework needed | +| Chrome without Playwright | Hand-rolled launcher ported from Python's `browser.py` works headless + headed on macOS | +| Extension packaging | Ruby zip is **byte-identical** to the Python SDK's deterministic archive (same SHA-256) | +| No Browserbase Ruby SDK exists | Hand-rolled 4-endpoint REST client (~130 LOC) suffices, mirroring Go | +| End-to-end | `goto → observe → act → extract` verified live on a local Chrome (transport) and on a Browserbase session (full AI loop incl. extension upload + Model Gateway + session release) | Spike size: ~2,400 hand-written LOC + ~2,000 generated + ~800 tests. Python comparison: ~4,900 hand-written + ~3,600 generated — a fair proxy for the finished Ruby size. ## Work breakdown to production parity -| # | Item | Weeks | -|---|---|---| -| A | Walking skeleton (this spike) | 2.5–3 *(sunk)* | -| B | Full method surface (~62 remaining: context 14, page ~26, locator 17, response 6, clipboard, webmcp, file upload, callback_batch passthrough) — mechanical wrapper + tests per method, batched by namespace | 3–4 | -| C | Client-side LLM (`llm.generate` inbound handler, message/tool unions, custom-LLM example) | 1 | -| D | Validation hardening: strict unions, input ergonomics, RBS signatures, thread-safety soak | 1–1.5 | -| E | 11-example set + `example-parity` compliance | 0.5–1 | -| F | Parity tooling: ast-grep Ruby lane (`@ast-grep/lang-ruby` availability is the biggest unknown; prism-based fallback +0.5–1 wk) + extend `rules/ast-grep/*` | 1–1.5 | -| G | Docs: Ruby tabs across `docs/v4/reference/*` + guides + `sdk-reference.test.ts` | 1 | -| H | Release + CI: turbo task, changesets version proxy, `sync-ruby-version.ts`, extension embedding in the gem, RubyGems trusted publishing + alpha lane, CI matrix (Ruby 3.2–3.4 × macOS/Linux), **Windows launcher** | 1.5–2 | -| I | Beta hardening buffer (real-world sites, large payloads, memory/soak) | 1–1.5 | -| | **Total beyond spike** | **10–13.5** | -| | **Total including spike** | **~12.5–16.5** | +| # | Item | Weeks | +| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------- | +| A | Walking skeleton (this spike) | 2.5–3 _(sunk)_ | +| B | Full method surface (~62 remaining: context 14, page ~26, locator 17, response 6, clipboard, webmcp, file upload, callback_batch passthrough) — mechanical wrapper + tests per method, batched by namespace | 3–4 | +| C | Client-side LLM (`llm.generate` inbound handler, message/tool unions, custom-LLM example) | 1 | +| D | Validation hardening: strict unions, input ergonomics, RBS signatures, thread-safety soak | 1–1.5 | +| E | 11-example set + `example-parity` compliance | 0.5–1 | +| F | Parity tooling: ast-grep Ruby lane (`@ast-grep/lang-ruby` availability is the biggest unknown; prism-based fallback +0.5–1 wk) + extend `rules/ast-grep/*` | 1–1.5 | +| G | Docs: Ruby tabs across `docs/v4/reference/*` + guides + `sdk-reference.test.ts` | 1 | +| H | Release + CI: turbo task, changesets version proxy, `sync-ruby-version.ts`, extension embedding in the gem, RubyGems trusted publishing + alpha lane, CI matrix (Ruby 3.2–3.4 × macOS/Linux), **Windows launcher** | 1.5–2 | +| I | Beta hardening buffer (real-world sites, large payloads, memory/soak) | 1–1.5 | +| | **Total beyond spike** | **10–13.5** | +| | **Total including spike** | **~12.5–16.5** | ## Ongoing cost diff --git a/packages/sdk-ruby/README.md b/packages/sdk-ruby/README.md index 82fba3e859..fee68b9392 100644 --- a/packages/sdk-ruby/README.md +++ b/packages/sdk-ruby/README.md @@ -50,8 +50,8 @@ end to end but deliberately implements only a sliver of the API surface. and WebMCP (`tools` → invoke/result/cancel). - **Locator** — `page.locator(selector)` → all 17 locator methods (`click/fill/type/hover/scroll_to/count/text_content/inner_text/inner_html/ - input_value/visible?/checked?/centroid/highlight/send_click_event/ - select_option/set_input_files` + `first`/`nth`; file uploads take paths or +input_value/visible?/checked?/centroid/highlight/send_click_event/ +select_option/set_input_files` + `first`/`nth`; file uploads take paths or `Stagehand::FilePayload`, 50 MiB/file). - **Context** — `pages/new_page/active_page/set_active_page/close`, cookies (`cookies/add_cookies/clear_cookies` with String/Regexp filters), clipboard @@ -87,27 +87,27 @@ Browserbase sessions. ### Examples -Ruby ports of the canonical example set (`packages/sdk-{ts,python,go}/examples`), -each runnable as `bundle exec ruby examples/.rb [--browserbase]`: - -| Example | Notes | -|---|---| -| `act.rb`, `observe.rb`, `extract.rb` | example.com, mirror the Python examples | -| `model_gateway.rb` | Browserbase-only; no model configured (Gateway picks one) | -| `caching.rb` | Browserbase-only; `cache: true` + `metadata.cache` round-trip | -| `custom_logging.rb` | `on_log:` callback appending JSONL to `stagehand.jsonl` | -| `file_upload.rb` | mirrors the Python example; no LLM needed (runs local or Browserbase) | -| `page_interactions.rb` | locator fill/type/click/readers, evaluate, screenshot, history; no LLM needed | -| `hybrid_news.rb` | AI extract + deterministic locators/pagination/screenshot on Hacker News | -| `search_flow.rb` | locator-driven search on DuckDuckGo + AI extraction of the results | -| `batch.rb` | mirrors the Python example; callback batch, no LLM needed | -| `page_events.rb` | mirrors the Python example; page.on console events + AI extract | -| `context_and_response.rb` | Response/cookies/clipboard/viewport/snapshot tour; no LLM needed | -| `custom_llm.rb` | mirrors the Python example; bring-your-own-LLM via `llm.generate` (OPENAI_API_KEY or AI_GATEWAY_API_KEY) | -| `webmcp.rb` | mirrors the Python example; page-registered tools via `page.tools` → invoke → result; no LLM needed | -| `demo.rb`, `arctic_observe.rb` | spike walkthroughs (not part of the canonical set) | - -That completes the canonical example set from `packages/sdk-{ts,python,go}/examples`. +The canonical example set (`packages/sdk-{ts,python,go}/examples`), each +self-contained and runnable as `bundle exec ruby examples/.rb +[--browserbase]`. The `example-parity` ast-grep lane keeps their inventory and +SDK operations aligned with the sibling SDKs. + +| Example | Notes | +| ------------------------------------ | ----------------------------------------------------------------------------- | +| `act.rb`, `observe.rb`, `extract.rb` | example.com; local runs need OPENAI_API_KEY, `--browserbase` uses the Gateway | +| `model_gateway.rb` | Browserbase-only; no model configured (Gateway picks one) | +| `caching.rb` | Browserbase-only; `cache: true` + `metadata.cache` round-trip | +| `custom_logging.rb` | `on_log:` callback appending JSONL to `stagehand.jsonl` | +| `file_upload.rb` | locator.set_input_files + evaluate; no LLM needed | +| `batch.rb` | callback batch, no LLM needed | +| `page_events.rb` | page.on console events + AI extract | +| `custom_llm.rb` | bring-your-own-LLM via `llm.generate` (OPENAI_API_KEY or AI_GATEWAY_API_KEY) | +| `webmcp.rb` | page-registered tools via `page.tools` → invoke → result; no LLM needed | + +`examples/extras/` holds Ruby-only walkthroughs outside the canonical set +(`demo.rb`, `page_interactions.rb`, `context_and_response.rb`, +`hybrid_news.rb`, `search_flow.rb`, `arctic_observe.rb`) plus their shared +`example_helpers.rb`. ## Development @@ -133,5 +133,7 @@ ruby scripts/generate.rb # regenerate models (also part of `just generate`) - Inbound work (request handlers, notification listeners including `page.on` blocks) runs on one dispatcher thread: handlers may issue RPC calls, but a slow handler delays later inbound work (Python runs these concurrently). -- Not wired into ast-grep parity tests, docs tabs, CI, or release tooling - (each is priced in `ESTIMATE.md`). +- Wired into the ast-grep parity lanes (`rules/ast-grep/{rpc,cdp,example,sdk}-parity`); + the `sdk-field-pipeline` and `sdk-client-schema-parity` Ruby lanes land with + the docs tabs. Docs, CI, and release tooling are still pending (priced in + `ESTIMATE.md`). diff --git a/packages/sdk-ruby/examples/act.rb b/packages/sdk-ruby/examples/act.rb index 791ce758da..565172a3cd 100644 --- a/packages/sdk-ruby/examples/act.rb +++ b/packages/sdk-ruby/examples/act.rb @@ -1,15 +1,46 @@ # frozen_string_literal: true # Ruby port of packages/sdk-python/examples/act.py. +# +# ruby examples/act.rb # local Chrome (needs OPENAI_API_KEY) +# ruby examples/act.rb --browserbase # Browserbase (Model Gateway, no provider key) -require_relative "example_helpers" +require "json" -ExampleHelpers.with_stagehand do |stagehand, page| - page.goto("https://example.com") +require_relative "../lib/stagehand" - result = stagehand.act("Click the link that provides more information about Example Domain") +openai_api_key = ENV.fetch("OPENAI_API_KEY", "") - puts JSON.pretty_generate(result.data.to_wire) +browser = + if ARGV.include?("--browserbase") + puts "Creating a Browserbase session..." + Stagehand::Browserbase.launch(api_key: ENV.fetch("BROWSERBASE_API_KEY")) + else + abort "Local runs need OPENAI_API_KEY (the Browserbase Model Gateway is session-only)." if openai_api_key.empty? + puts "Launching local Chrome..." + Stagehand::LocalBrowser.launch(headless: ENV["HEADED"].nil?) + end - raise "act() failed: #{result.data.message}" unless result.data.success +begin + create_options = { browser: browser, log_level: ENV.fetch("STAGEHAND_LOG_LEVEL", "warn") } + unless openai_api_key.empty? + create_options[:model] = "openai/gpt-5.4-mini" + create_options[:model_api_key] = openai_api_key + end + stagehand = Stagehand.create(**create_options) + begin + page = browser.context.pages.first + raise "Stagehand initialized without an active page" if page.nil? + page.goto("https://example.com") + + result = stagehand.act("Click the link that provides more information about Example Domain") + + puts JSON.pretty_generate(result.data.to_wire) + + raise "act() failed: #{result.data.message}" unless result.data.success + ensure + stagehand.close + end +ensure + browser.close end diff --git a/packages/sdk-ruby/examples/batch.rb b/packages/sdk-ruby/examples/batch.rb index a91cb14a2f..ec3adadcc9 100644 --- a/packages/sdk-ruby/examples/batch.rb +++ b/packages/sdk-ruby/examples/batch.rb @@ -10,13 +10,11 @@ require "json" require_relative "../lib/stagehand" -require_relative "example_helpers" browser = if ARGV.include?("--browserbase") - api_key = ExampleHelpers.env("BROWSERBASE_API_KEY") or abort "Set BROWSERBASE_API_KEY." puts "Creating a Browserbase session..." - Stagehand::Browserbase.launch(api_key: api_key) + Stagehand::Browserbase.launch(api_key: ENV.fetch("BROWSERBASE_API_KEY")) else puts "Launching local Chrome..." Stagehand::LocalBrowser.launch(headless: ENV["HEADED"].nil?) diff --git a/packages/sdk-ruby/examples/caching.rb b/packages/sdk-ruby/examples/caching.rb index 5d96f0176d..66c7c214ed 100644 --- a/packages/sdk-ruby/examples/caching.rb +++ b/packages/sdk-ruby/examples/caching.rb @@ -2,8 +2,12 @@ # Ruby port of packages/sdk-python/examples/caching.py. Server-side caching # requires a Browserbase browser session, so this always runs there. +# +# ruby examples/caching.rb # needs BROWSERBASE_API_KEY -require_relative "example_helpers" +require "json" + +require_relative "../lib/stagehand" COMPANIES = { "type" => "object", @@ -25,28 +29,40 @@ "additionalProperties" => false, }.freeze -ExampleHelpers.with_stagehand(force_browserbase: true) do |stagehand, page| - page.goto("https://aigrant.com") - - extract_companies = lambda do - start = Process.clock_gettime(Process::CLOCK_MONOTONIC) - result = stagehand.extract( - "Extract the names and descriptions of the first five companies listed on the page", - schema: COMPANIES, - cache: true, - ) - [result, ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round] - end +puts "Creating a Browserbase session..." +browser = Stagehand::Browserbase.launch(api_key: ENV.fetch("BROWSERBASE_API_KEY")) + +begin + stagehand = Stagehand.create(browser: browser, log_level: ENV.fetch("STAGEHAND_LOG_LEVEL", "warn")) + begin + page = browser.context.pages.first + raise "Stagehand initialized without an active page" if page.nil? + page.goto("https://aigrant.com") - describe_cache = ->(result) { result.metadata.cache ? JSON.generate(result.metadata.cache.to_wire) : "disabled" } + extract_companies = lambda do + start = Process.clock_gettime(Process::CLOCK_MONOTONIC) + result = stagehand.extract( + "Extract the names and descriptions of the first five companies listed on the page", + schema: COMPANIES, + cache: true, + ) + [result, ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round] + end - first, first_duration_ms = extract_companies.call - puts "First extraction (#{first_duration_ms}ms):" - puts JSON.pretty_generate(first.data) - puts "Cache: #{describe_cache.call(first)}" + describe_cache = ->(result) { result.metadata.cache ? JSON.generate(result.metadata.cache.to_wire) : "disabled" } - second, second_duration_ms = extract_companies.call - puts "Second extraction (#{second_duration_ms}ms):" - puts JSON.pretty_generate(second.data) - puts "Cache: #{describe_cache.call(second)}" + first, first_duration_ms = extract_companies.call + puts "First extraction (#{first_duration_ms}ms):" + puts JSON.pretty_generate(first.data) + puts "Cache: #{describe_cache.call(first)}" + + second, second_duration_ms = extract_companies.call + puts "Second extraction (#{second_duration_ms}ms):" + puts JSON.pretty_generate(second.data) + puts "Cache: #{describe_cache.call(second)}" + ensure + stagehand.close + end +ensure + browser.close end diff --git a/packages/sdk-ruby/examples/custom_llm.rb b/packages/sdk-ruby/examples/custom_llm.rb index 74906ee9de..db913422a4 100644 --- a/packages/sdk-ruby/examples/custom_llm.rb +++ b/packages/sdk-ruby/examples/custom_llm.rb @@ -18,15 +18,14 @@ require "uri" require_relative "../lib/stagehand" -require_relative "example_helpers" -if ExampleHelpers.env("OPENAI_API_KEY") +if !ENV.fetch("OPENAI_API_KEY", "").empty? LLM_URL = URI("https://api.openai.com/v1/chat/completions") - LLM_API_KEY = ExampleHelpers.env("OPENAI_API_KEY") + LLM_API_KEY = ENV.fetch("OPENAI_API_KEY") LLM_MODEL = "gpt-5.4-mini" -elsif ExampleHelpers.env("AI_GATEWAY_API_KEY") +elsif !ENV.fetch("AI_GATEWAY_API_KEY", "").empty? LLM_URL = URI("https://ai-gateway.vercel.sh/v1/chat/completions") - LLM_API_KEY = ExampleHelpers.env("AI_GATEWAY_API_KEY") + LLM_API_KEY = ENV.fetch("AI_GATEWAY_API_KEY") LLM_MODEL = "openai/gpt-5.4-mini" else abort "Set OPENAI_API_KEY or AI_GATEWAY_API_KEY." @@ -115,9 +114,8 @@ def generate_with_openai(params) browser = if ARGV.include?("--browserbase") - api_key = ExampleHelpers.env("BROWSERBASE_API_KEY") or abort "Set BROWSERBASE_API_KEY." puts "Creating a Browserbase session..." - Stagehand::Browserbase.launch(api_key: api_key) + Stagehand::Browserbase.launch(api_key: ENV.fetch("BROWSERBASE_API_KEY")) else puts "Launching local Chrome..." Stagehand::LocalBrowser.launch(headless: ENV["HEADED"].nil?) diff --git a/packages/sdk-ruby/examples/custom_logging.rb b/packages/sdk-ruby/examples/custom_logging.rb index 21ed08dc35..28e418eaa3 100644 --- a/packages/sdk-ruby/examples/custom_logging.rb +++ b/packages/sdk-ruby/examples/custom_logging.rb @@ -3,15 +3,46 @@ # Ruby port of packages/sdk-python/examples/custom_logging.py: every # stagehand.log notification is appended to stagehand.jsonl via on_log # (in addition to the client's default stderr echo). +# +# ruby examples/custom_logging.rb # local Chrome (needs OPENAI_API_KEY) +# ruby examples/custom_logging.rb --browserbase # Browserbase (Model Gateway) -require_relative "example_helpers" +require "json" + +require_relative "../lib/stagehand" + +openai_api_key = ENV.fetch("OPENAI_API_KEY", "") + +browser = + if ARGV.include?("--browserbase") + puts "Creating a Browserbase session..." + Stagehand::Browserbase.launch(api_key: ENV.fetch("BROWSERBASE_API_KEY")) + else + abort "Local runs need OPENAI_API_KEY (the Browserbase Model Gateway is session-only)." if openai_api_key.empty? + puts "Launching local Chrome..." + Stagehand::LocalBrowser.launch(headless: ENV["HEADED"].nil?) + end File.open("stagehand.jsonl", "a", encoding: "utf-8") do |log_file| on_log = ->(log) { log_file.puts(JSON.generate(log.to_wire)) } - ExampleHelpers.with_stagehand(log_level: "info", on_log: on_log) do |stagehand, page| - page.goto("https://example.com") - puts JSON.pretty_generate(stagehand.observe("Find the Learn more link").data.map(&:to_wire)) + begin + create_options = { browser: browser, log_level: "info", on_log: on_log } + unless openai_api_key.empty? + create_options[:model] = "openai/gpt-5.4-mini" + create_options[:model_api_key] = openai_api_key + end + stagehand = Stagehand.create(**create_options) + begin + page = browser.context.pages.first + raise "Stagehand initialized without an active page" if page.nil? + page.goto("https://example.com") + puts JSON.pretty_generate(stagehand.observe("Find the Learn more link").data.map(&:to_wire)) + ensure + stagehand.close + end + ensure + browser.close end end diff --git a/packages/sdk-ruby/examples/extract.rb b/packages/sdk-ruby/examples/extract.rb index 00a893d5f6..35c91ff60a 100644 --- a/packages/sdk-ruby/examples/extract.rb +++ b/packages/sdk-ruby/examples/extract.rb @@ -1,9 +1,14 @@ # frozen_string_literal: true # Ruby port of packages/sdk-python/examples/extract.py. The schema is a plain -# JSON Schema Hash (the spike has no Pydantic/zod equivalent). +# JSON Schema Hash (Ruby has no Pydantic/zod equivalent). +# +# ruby examples/extract.rb # local Chrome (needs OPENAI_API_KEY) +# ruby examples/extract.rb --browserbase # Browserbase (Model Gateway, no provider key) -require_relative "example_helpers" +require "json" + +require_relative "../lib/stagehand" PAGE_INFO = { "type" => "object", @@ -15,10 +20,36 @@ "additionalProperties" => false, }.freeze -ExampleHelpers.with_stagehand do |stagehand, page| - page.goto("https://example.com") +openai_api_key = ENV.fetch("OPENAI_API_KEY", "") + +browser = + if ARGV.include?("--browserbase") + puts "Creating a Browserbase session..." + Stagehand::Browserbase.launch(api_key: ENV.fetch("BROWSERBASE_API_KEY")) + else + abort "Local runs need OPENAI_API_KEY (the Browserbase Model Gateway is session-only)." if openai_api_key.empty? + puts "Launching local Chrome..." + Stagehand::LocalBrowser.launch(headless: ENV["HEADED"].nil?) + end + +begin + create_options = { browser: browser, log_level: ENV.fetch("STAGEHAND_LOG_LEVEL", "warn") } + unless openai_api_key.empty? + create_options[:model] = "openai/gpt-5.4-mini" + create_options[:model_api_key] = openai_api_key + end + stagehand = Stagehand.create(**create_options) + begin + page = browser.context.pages.first + raise "Stagehand initialized without an active page" if page.nil? + page.goto("https://example.com") - result = stagehand.extract("Extract the page heading and description", schema: PAGE_INFO) + result = stagehand.extract("Extract the page heading and description", schema: PAGE_INFO) - puts JSON.pretty_generate(result.data) + puts JSON.pretty_generate(result.data) + ensure + stagehand.close + end +ensure + browser.close end diff --git a/packages/sdk-ruby/examples/arctic_observe.rb b/packages/sdk-ruby/examples/extras/arctic_observe.rb similarity index 98% rename from packages/sdk-ruby/examples/arctic_observe.rb rename to packages/sdk-ruby/examples/extras/arctic_observe.rb index fb89de6452..3fdcf88480 100644 --- a/packages/sdk-ruby/examples/arctic_observe.rb +++ b/packages/sdk-ruby/examples/extras/arctic_observe.rb @@ -9,7 +9,7 @@ # # Flow: goto -> observe (nav) -> observe (search box) -> observe (product links). -require_relative "../lib/stagehand" +require_relative "../../lib/stagehand" SITE = ENV.fetch("ARCTIC_URL", "https://www.everythingarcticcatoffroad.com/") diff --git a/packages/sdk-ruby/examples/context_and_response.rb b/packages/sdk-ruby/examples/extras/context_and_response.rb similarity index 98% rename from packages/sdk-ruby/examples/context_and_response.rb rename to packages/sdk-ruby/examples/extras/context_and_response.rb index 6b055dedc4..642e2e5482 100644 --- a/packages/sdk-ruby/examples/context_and_response.rb +++ b/packages/sdk-ruby/examples/extras/context_and_response.rb @@ -9,7 +9,7 @@ # ruby examples/context_and_response.rb # local Chrome # ruby examples/context_and_response.rb --browserbase # Browserbase session -require_relative "../lib/stagehand" +require_relative "../../lib/stagehand" require_relative "example_helpers" browser = diff --git a/packages/sdk-ruby/examples/demo.rb b/packages/sdk-ruby/examples/extras/demo.rb similarity index 98% rename from packages/sdk-ruby/examples/demo.rb rename to packages/sdk-ruby/examples/extras/demo.rb index a924309d69..a28d1cb3de 100644 --- a/packages/sdk-ruby/examples/demo.rb +++ b/packages/sdk-ruby/examples/extras/demo.rb @@ -10,7 +10,7 @@ # ANTHROPIC_API_KEY / GOOGLE_API_KEY. On Browserbase the model may be omitted # entirely (Model Gateway picks one). -require_relative "../lib/stagehand" +require_relative "../../lib/stagehand" def env(name) value = ENV.fetch(name, nil) diff --git a/packages/sdk-ruby/examples/example_helpers.rb b/packages/sdk-ruby/examples/extras/example_helpers.rb similarity index 98% rename from packages/sdk-ruby/examples/example_helpers.rb rename to packages/sdk-ruby/examples/extras/example_helpers.rb index f92f2ad14e..ed853ad42c 100644 --- a/packages/sdk-ruby/examples/example_helpers.rb +++ b/packages/sdk-ruby/examples/extras/example_helpers.rb @@ -11,7 +11,7 @@ require "json" -require_relative "../lib/stagehand" +require_relative "../../lib/stagehand" module ExampleHelpers module_function diff --git a/packages/sdk-ruby/examples/hybrid_news.rb b/packages/sdk-ruby/examples/extras/hybrid_news.rb similarity index 100% rename from packages/sdk-ruby/examples/hybrid_news.rb rename to packages/sdk-ruby/examples/extras/hybrid_news.rb diff --git a/packages/sdk-ruby/examples/page_interactions.rb b/packages/sdk-ruby/examples/extras/page_interactions.rb similarity index 100% rename from packages/sdk-ruby/examples/page_interactions.rb rename to packages/sdk-ruby/examples/extras/page_interactions.rb diff --git a/packages/sdk-ruby/examples/search_flow.rb b/packages/sdk-ruby/examples/extras/search_flow.rb similarity index 100% rename from packages/sdk-ruby/examples/search_flow.rb rename to packages/sdk-ruby/examples/extras/search_flow.rb diff --git a/packages/sdk-ruby/examples/file_upload.rb b/packages/sdk-ruby/examples/file_upload.rb index 8b57088ba8..ac96445c6d 100644 --- a/packages/sdk-ruby/examples/file_upload.rb +++ b/packages/sdk-ruby/examples/file_upload.rb @@ -8,7 +8,7 @@ require "tmpdir" -require_relative "example_helpers" +require_relative "../lib/stagehand" Dir.mktmpdir("stagehand-upload-") do |directory| file_path = File.join(directory, "hello.txt") @@ -16,9 +16,8 @@ browser = if ARGV.include?("--browserbase") - api_key = ExampleHelpers.env("BROWSERBASE_API_KEY") or abort "Set BROWSERBASE_API_KEY." puts "Creating a Browserbase session..." - Stagehand::Browserbase.launch(api_key: api_key) + Stagehand::Browserbase.launch(api_key: ENV.fetch("BROWSERBASE_API_KEY")) else puts "Launching local Chrome..." Stagehand::LocalBrowser.launch(headless: ENV["HEADED"].nil?) diff --git a/packages/sdk-ruby/examples/model_gateway.rb b/packages/sdk-ruby/examples/model_gateway.rb index 6d6709ad5f..3936691678 100644 --- a/packages/sdk-ruby/examples/model_gateway.rb +++ b/packages/sdk-ruby/examples/model_gateway.rb @@ -4,7 +4,9 @@ # configured, the Browserbase Model Gateway selects one automatically for each # inference call; the Browserbase API key and session authenticate it. -require_relative "example_helpers" +require "json" + +require_relative "../lib/stagehand" PAGE_INFO = { "type" => "object", @@ -16,7 +18,7 @@ "additionalProperties" => false, }.freeze -api_key = ExampleHelpers.env("BROWSERBASE_API_KEY") or abort "Set BROWSERBASE_API_KEY." +api_key = ENV.fetch("BROWSERBASE_API_KEY") puts "Creating a Browserbase session..." browser = Stagehand::Browserbase.launch(api_key: api_key) diff --git a/packages/sdk-ruby/examples/observe.rb b/packages/sdk-ruby/examples/observe.rb index e0588b06a4..894a50b0c0 100644 --- a/packages/sdk-ruby/examples/observe.rb +++ b/packages/sdk-ruby/examples/observe.rb @@ -1,15 +1,46 @@ # frozen_string_literal: true # Ruby port of packages/sdk-python/examples/observe.py. +# +# ruby examples/observe.rb # local Chrome (needs OPENAI_API_KEY) +# ruby examples/observe.rb --browserbase # Browserbase (Model Gateway, no provider key) -require_relative "example_helpers" +require "json" -ExampleHelpers.with_stagehand do |stagehand, page| - page.goto("https://example.com") +require_relative "../lib/stagehand" - result = stagehand.observe("Find the link that provides more information about Example Domain") +openai_api_key = ENV.fetch("OPENAI_API_KEY", "") - puts JSON.pretty_generate(result.data.map(&:to_wire)) +browser = + if ARGV.include?("--browserbase") + puts "Creating a Browserbase session..." + Stagehand::Browserbase.launch(api_key: ENV.fetch("BROWSERBASE_API_KEY")) + else + abort "Local runs need OPENAI_API_KEY (the Browserbase Model Gateway is session-only)." if openai_api_key.empty? + puts "Launching local Chrome..." + Stagehand::LocalBrowser.launch(headless: ENV["HEADED"].nil?) + end - raise "observe() returned no matching actions" if result.data.empty? +begin + create_options = { browser: browser, log_level: ENV.fetch("STAGEHAND_LOG_LEVEL", "warn") } + unless openai_api_key.empty? + create_options[:model] = "openai/gpt-5.4-mini" + create_options[:model_api_key] = openai_api_key + end + stagehand = Stagehand.create(**create_options) + begin + page = browser.context.pages.first + raise "Stagehand initialized without an active page" if page.nil? + page.goto("https://example.com") + + result = stagehand.observe("Find the link that provides more information about Example Domain") + + puts JSON.pretty_generate(result.data.map(&:to_wire)) + + raise "observe() returned no matching actions" if result.data.empty? + ensure + stagehand.close + end +ensure + browser.close end diff --git a/packages/sdk-ruby/examples/page_events.rb b/packages/sdk-ruby/examples/page_events.rb index 4a1d16572d..7a4d8f003e 100644 --- a/packages/sdk-ruby/examples/page_events.rb +++ b/packages/sdk-ruby/examples/page_events.rb @@ -4,10 +4,12 @@ # console events via page.on, trigger one with evaluate, then AI-extract the # page content. # -# ruby examples/page_events.rb --browserbase # (AI needs a model; Gateway on Browserbase) -# ruby examples/page_events.rb # local Chrome + provider key +# ruby examples/page_events.rb # local Chrome (needs OPENAI_API_KEY) +# ruby examples/page_events.rb --browserbase # Browserbase (Model Gateway) -require_relative "example_helpers" +require "json" + +require_relative "../lib/stagehand" PAGE_INFO = { "type" => "object", @@ -19,22 +21,49 @@ "additionalProperties" => false, }.freeze -ExampleHelpers.with_stagehand do |stagehand, page| - console_events = Thread::Queue.new - subscription = page.on("console") do |event| - # Runs on the RPC dispatcher thread: keep it quick, hand the event over. - console_events << event if event.params.is_a?(Hash) && event.params["type"] == "log" +openai_api_key = ENV.fetch("OPENAI_API_KEY", "") + +browser = + if ARGV.include?("--browserbase") + puts "Creating a Browserbase session..." + Stagehand::Browserbase.launch(api_key: ENV.fetch("BROWSERBASE_API_KEY")) + else + abort "Local runs need OPENAI_API_KEY (the Browserbase Model Gateway is session-only)." if openai_api_key.empty? + puts "Launching local Chrome..." + Stagehand::LocalBrowser.launch(headless: ENV["HEADED"].nil?) end +begin + create_options = { browser: browser, log_level: ENV.fetch("STAGEHAND_LOG_LEVEL", "warn") } + unless openai_api_key.empty? + create_options[:model] = "openai/gpt-5.4-mini" + create_options[:model_api_key] = openai_api_key + end + stagehand = Stagehand.create(**create_options) begin - page.goto("https://example.com") - page.evaluate('console.log("stagehand-page-on-example"); "emitted"') - event = console_events.pop(timeout: 10) - raise "console event did not arrive within 10s" if event.nil? + page = stagehand.browser.context.active_page() + raise "Stagehand initialized without an active page" if page.nil? + + console_events = Thread::Queue.new + subscription = page.on("console") do |event| + # Runs on the RPC dispatcher thread: keep it quick, hand the event over. + console_events << event if event.params.is_a?(Hash) && event.params["type"] == "log" + end + + begin + page.goto("https://example.com") + page.evaluate('console.log("stagehand-page-on-example"); "emitted"') + event = console_events.pop(timeout: 10) + raise "console event did not arrive within 10s" if event.nil? - result = stagehand.extract("Extract the page heading and description", schema: PAGE_INFO) - puts JSON.pretty_generate({ "event_method" => event.method, "extracted" => result.data }) + result = stagehand.extract("Extract the page heading and description", schema: PAGE_INFO) + puts JSON.pretty_generate({ "event_method" => event.method, "extracted" => result.data }) + ensure + subscription.unsubscribe + end ensure - subscription.unsubscribe + stagehand.close end +ensure + browser.close end diff --git a/packages/sdk-ruby/examples/webmcp.rb b/packages/sdk-ruby/examples/webmcp.rb index 0d2ec6e097..b4383ea921 100644 --- a/packages/sdk-ruby/examples/webmcp.rb +++ b/packages/sdk-ruby/examples/webmcp.rb @@ -10,15 +10,13 @@ require "json" require_relative "../lib/stagehand" -require_relative "example_helpers" WEBMCP_TEST_SITE = "https://browserbase.github.io/stagehand-eval-sites/sites/webmcp-test/" browser = if ARGV.include?("--browserbase") - api_key = ExampleHelpers.env("BROWSERBASE_API_KEY") or abort "Set BROWSERBASE_API_KEY." puts "Creating a Browserbase session..." - Stagehand::Browserbase.launch(api_key: api_key) + Stagehand::Browserbase.launch(api_key: ENV.fetch("BROWSERBASE_API_KEY")) else puts "Launching local Chrome..." Stagehand::LocalBrowser.launch(headless: false) diff --git a/packages/sdk-ruby/lib/stagehand/browser_clipboard.rb b/packages/sdk-ruby/lib/stagehand/browser_clipboard.rb index c2d3731e74..615de47dd2 100644 --- a/packages/sdk-ruby/lib/stagehand/browser_clipboard.rb +++ b/packages/sdk-ruby/lib/stagehand/browser_clipboard.rb @@ -19,11 +19,13 @@ def read_text(page: nil) def write_text(text, page: nil) values = { text: text } values[:page_id] = page.page_id unless page.nil? - void("context.clipboard_write_text", Models::ContextClipboardWriteTextParams.new(**values)) + @rpc_client.send("context.clipboard_write_text", Models::ContextClipboardWriteTextParams.new(**values), "ContextVoidResult") + nil end def clear(page: nil) - void("context.clipboard_clear", target(page)) + @rpc_client.send("context.clipboard_clear", target(page), "ContextVoidResult") + nil end # shortcut: "ControlOrMeta+V" (default), "Meta+V", or "Control+V". @@ -31,15 +33,18 @@ def paste(page: nil, shortcut: nil) values = {} values[:page_id] = page.page_id unless page.nil? values[:shortcut] = shortcut unless shortcut.nil? - void("context.clipboard_paste", Models::ContextClipboardPasteParams.new(**values)) + @rpc_client.send("context.clipboard_paste", Models::ContextClipboardPasteParams.new(**values), "ContextVoidResult") + nil end def copy(page: nil) - void("context.clipboard_copy", target(page)) + @rpc_client.send("context.clipboard_copy", target(page), "ContextVoidResult") + nil end def cut(page: nil) - void("context.clipboard_cut", target(page)) + @rpc_client.send("context.clipboard_cut", target(page), "ContextVoidResult") + nil end private @@ -48,9 +53,5 @@ def target(page) page.nil? ? Models::ContextClipboardTarget.new : Models::ContextClipboardTarget.new(page_id: page.page_id) end - def void(method, params) - @rpc_client.send(method, params, "ContextVoidResult") - nil - end end end diff --git a/packages/sdk-ruby/lib/stagehand/browser_context.rb b/packages/sdk-ruby/lib/stagehand/browser_context.rb index d1b51d740e..0c45c9f324 100644 --- a/packages/sdk-ruby/lib/stagehand/browser_context.rb +++ b/packages/sdk-ruby/lib/stagehand/browser_context.rb @@ -38,7 +38,8 @@ def active_page end def set_active_page(page) - void("context.set_active_page", Models::ContextSetActivePageParams.new(page_id: page.page_id)) + @rpc_client.send("context.set_active_page", Models::ContextSetActivePageParams.new(page_id: page.page_id), "ContextVoidResult") + nil end # Closes the remote context; use Stagehand::Client#close to release local @@ -51,11 +52,13 @@ def close # source is JavaScript text, or a Pathname whose contents are read and # tagged with a sourceURL comment (matching the sibling SDKs). def add_init_script(source) - void("context.add_init_script", Models::ContextAddInitScriptParams.new(source: init_script_source(source))) + @rpc_client.send("context.add_init_script", Models::ContextAddInitScriptParams.new(source: init_script_source(source)), "ContextVoidResult") + nil end def set_extra_http_headers(headers) - void("context.set_extra_http_headers", Models::ContextSetExtraHTTPHeadersParams.new(headers: headers.to_h)) + @rpc_client.send("context.set_extra_http_headers", Models::ContextSetExtraHTTPHeadersParams.new(headers: headers.to_h), "ContextVoidResult") + nil end # Models::DomainPolicy or nil when no policy is set. @@ -72,7 +75,8 @@ def set_domain_policy(policy) when Hash then Models::DomainPolicy.new(**policy.transform_keys(&:to_sym)) else raise ArgumentError, "policy must be a Models::DomainPolicy, a Hash, or nil" end - void("context.set_domain_policy", Models::ContextSetDomainPolicyParams.new(policy: encoded)) + @rpc_client.send("context.set_domain_policy", Models::ContextSetDomainPolicyParams.new(policy: encoded), "ContextVoidResult") + nil end # Cookies visible to the context, optionally filtered to one or more URLs. @@ -93,7 +97,8 @@ def add_cookies(cookies) encoded = cookies.map do |cookie| cookie.is_a?(Models::CookieParam) ? cookie : Models::CookieParam.new(**cookie.transform_keys(&:to_sym)) end - void("context.add_cookies", Models::ContextAddCookiesParams.new(cookies: encoded)) + @rpc_client.send("context.add_cookies", Models::ContextAddCookiesParams.new(cookies: encoded), "ContextVoidResult") + nil end # Each filter is a String (exact match), a Regexp, or a @@ -106,7 +111,8 @@ def clear_cookies(name: nil, domain: nil, path: nil) filters[field] = cookie_filter(value) unless value.nil? end values = filters.empty? ? {} : { options: Models::ClearCookieOptions.new(**filters) } - void("context.clear_cookies", Models::ContextClearCookiesParams.new(**values)) + @rpc_client.send("context.clear_cookies", Models::ContextClearCookiesParams.new(**values), "ContextVoidResult") + nil end private @@ -135,9 +141,5 @@ def init_script_source(source) "#{source.read}\n//# sourceURL=#{source_url}" end - def void(method, params) - @rpc_client.send(method, params, "ContextVoidResult") - nil - end end end diff --git a/packages/sdk-ruby/lib/stagehand/client.rb b/packages/sdk-ruby/lib/stagehand/client.rb index 484a7ba061..40771fd1ba 100644 --- a/packages/sdk-ruby/lib/stagehand/client.rb +++ b/packages/sdk-ruby/lib/stagehand/client.rb @@ -107,10 +107,6 @@ def initialized? @initialized end - def context - @browser.context - end - def act(instruction, page: nil, model: nil, variables: nil, timeout: nil, cache: nil) params = { page_id: target_page_id(page), instruction: encode_instruction(instruction) } options = call_options(model: model, variables: variables, timeout: timeout, cache: cache) diff --git a/packages/sdk-ruby/lib/stagehand/page.rb b/packages/sdk-ruby/lib/stagehand/page.rb index daffd6c756..34aa549d7c 100644 --- a/packages/sdk-ruby/lib/stagehand/page.rb +++ b/packages/sdk-ruby/lib/stagehand/page.rb @@ -65,26 +65,34 @@ def locator(selector) def goto(url, wait_until: nil, timeout: nil) params = { page_id: @page_id, url: url } assign_navigation_options(params, wait_until: wait_until, timeout: timeout) - navigate("page.goto", Models::PageGotoParams.new(**params)) + result = @rpc_client.send("page.goto", Models::PageGotoParams.new(**params), "PageNavigationResult") + @page_id = result.page.page_id + result.response.nil? ? nil : Response.new(@rpc_client, result.response) end def reload(wait_until: nil, timeout: nil, ignore_cache: nil) params = { page_id: @page_id } options = { wait_until: wait_until, timeout: timeout, ignore_cache: ignore_cache }.compact params[:options] = Models::PageReloadOptions.new(**options) unless options.empty? - navigate("page.reload", Models::PageReloadParams.new(**params)) + result = @rpc_client.send("page.reload", Models::PageReloadParams.new(**params), "PageNavigationResult") + @page_id = result.page.page_id + result.response.nil? ? nil : Response.new(@rpc_client, result.response) end def go_back(wait_until: nil, timeout: nil) params = { page_id: @page_id } assign_navigation_options(params, wait_until: wait_until, timeout: timeout) - navigate("page.go_back", Models::PageGoBackParams.new(**params)) + result = @rpc_client.send("page.go_back", Models::PageGoBackParams.new(**params), "PageNavigationResult") + @page_id = result.page.page_id + result.response.nil? ? nil : Response.new(@rpc_client, result.response) end def go_forward(wait_until: nil, timeout: nil) params = { page_id: @page_id } assign_navigation_options(params, wait_until: wait_until, timeout: timeout) - navigate("page.go_forward", Models::PageGoForwardParams.new(**params)) + result = @rpc_client.send("page.go_forward", Models::PageGoForwardParams.new(**params), "PageNavigationResult") + @page_id = result.page.page_id + result.response.nil? ? nil : Response.new(@rpc_client, result.response) end def url @@ -113,15 +121,18 @@ def click(x, y, button: nil, click_count: nil) params = { page_id: @page_id, x: x, y: y } options = { button: button, click_count: click_count }.compact params[:options] = Models::PageClickOptions.new(**options) unless options.empty? - void("page.click", Models::PageClickParams.new(**params)) + @rpc_client.send("page.click", Models::PageClickParams.new(**params), "PageVoidResult") + nil end def hover(x, y) - void("page.hover", Models::PageHoverParams.new(page_id: @page_id, x: x, y: y)) + @rpc_client.send("page.hover", Models::PageHoverParams.new(page_id: @page_id, x: x, y: y), "PageVoidResult") + nil end def scroll(x, y, delta_x, delta_y) - void("page.scroll", Models::PageScrollParams.new(page_id: @page_id, x: x, y: y, delta_x: delta_x, delta_y: delta_y)) + @rpc_client.send("page.scroll", Models::PageScrollParams.new(page_id: @page_id, x: x, y: y, delta_x: delta_x, delta_y: delta_y), "PageVoidResult") + nil end # route: optional intermediate points, each {x:, y:} Hash or @@ -135,20 +146,23 @@ def drag_and_drop(from_x, from_y, to_x, to_y, button: nil, steps: nil, delay: ni end end params[:options] = Models::PageDragAndDropOptions.new(**options) unless options.empty? - void("page.drag_and_drop", Models::PageDragAndDropParams.new(**params)) + @rpc_client.send("page.drag_and_drop", Models::PageDragAndDropParams.new(**params), "PageVoidResult") + nil end def type(text, delay: nil, with_mistakes: nil) params = { page_id: @page_id, text: text } options = { delay: delay, with_mistakes: with_mistakes }.compact params[:options] = Models::PageTypeOptions.new(**options) unless options.empty? - void("page.type", Models::PageTypeParams.new(**params)) + @rpc_client.send("page.type", Models::PageTypeParams.new(**params), "PageVoidResult") + nil end def key_press(key, delay: nil) params = { page_id: @page_id, key: key } params[:options] = Models::PageKeyPressOptions.new(delay: delay) unless delay.nil? - void("page.key_press", Models::PageKeyPressParams.new(**params)) + @rpc_client.send("page.key_press", Models::PageKeyPressParams.new(**params), "PageVoidResult") + nil end # -- configuration ---------------------------------------------------- @@ -162,11 +176,13 @@ def add_init_script(source) else source end - void("page.add_init_script", Models::PageAddInitScriptParams.new(page_id: @page_id, source: script)) + @rpc_client.send("page.add_init_script", Models::PageAddInitScriptParams.new(page_id: @page_id, source: script), "PageVoidResult") + nil end def set_extra_http_headers(headers) - void("page.set_extra_http_headers", Models::PageSetExtraHTTPHeadersParams.new(page_id: @page_id, headers: headers.to_h)) + @rpc_client.send("page.set_extra_http_headers", Models::PageSetExtraHTTPHeadersParams.new(page_id: @page_id, headers: headers.to_h), "PageVoidResult") + nil end def set_viewport_size(width, height, device_scale_factor: nil) @@ -174,7 +190,8 @@ def set_viewport_size(width, height, device_scale_factor: nil) unless device_scale_factor.nil? params[:options] = Models::PageSetViewportSizeOptions.new(device_scale_factor: device_scale_factor) end - void("page.set_viewport_size", Models::PageSetViewportSizeParams.new(**params)) + @rpc_client.send("page.set_viewport_size", Models::PageSetViewportSizeParams.new(**params), "PageVoidResult") + nil end # -- events ----------------------------------------------------------- @@ -272,11 +289,13 @@ def tools(timeout: nil) def wait_for_load_state(state, timeout: nil) params = { page_id: @page_id, state: state } params[:timeout] = timeout unless timeout.nil? - void("page.wait_for_load_state", Models::PageWaitForLoadStateParams.new(**params)) + @rpc_client.send("page.wait_for_load_state", Models::PageWaitForLoadStateParams.new(**params), "PageVoidResult") + nil end def wait_for_timeout(ms) - void("page.wait_for_timeout", Models::PageWaitForTimeoutParams.new(page_id: @page_id, ms: ms)) + @rpc_client.send("page.wait_for_timeout", Models::PageWaitForTimeoutParams.new(page_id: @page_id, ms: ms), "PageVoidResult") + nil end # Returns true when the selector reached the requested state. @@ -296,15 +315,6 @@ def assign_navigation_options(params, wait_until:, timeout:) params end - def navigate(method, params) - result = @rpc_client.send(method, params, "PageNavigationResult") - @page_id = result.page.page_id - result.response.nil? ? nil : Response.new(@rpc_client, result.response) - end - def void(method, params) - @rpc_client.send(method, params, "PageVoidResult") - nil - end end end diff --git a/packages/sdk-ruby/sig/stagehand.rbs b/packages/sdk-ruby/sig/stagehand.rbs index 63658d9a6b..3170060dcd 100644 --- a/packages/sdk-ruby/sig/stagehand.rbs +++ b/packages/sdk-ruby/sig/stagehand.rbs @@ -102,7 +102,6 @@ module Stagehand attr_reader browser: StagehandBrowser def initialized?: () -> bool - def context: () -> BrowserContext def act: (untyped instruction, ?page: Page?, ?model: untyped, ?variables: Hash[Symbol | String, untyped]?, ?timeout: Numeric?, ?cache: cache_input?) -> untyped def observe: (?String? instruction, ?page: Page?, ?model: untyped, ?variables: Hash[Symbol | String, untyped]?, ?timeout: Numeric?, ?cache: cache_input?) -> untyped def extract: (String instruction, ?schema: Hash[String, untyped]?, ?page: Page?, ?model: untyped, ?timeout: Numeric?, ?screenshot: bool?, ?cache: cache_input?) -> untyped diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a3219d8303..959990730b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,202 +1,3 @@ ---- -lockfileVersion: '9.0' - -importers: - - .: - configDependencies: {} - packageManagerDependencies: - '@pnpm/exe': - specifier: 11.10.0 - version: 11.10.0 - pnpm: - specifier: 11.10.0 - version: 11.10.0 - -packages: - - '@pnpm/exe@11.10.0': - resolution: {integrity: sha512-mrmfi2C7LpZkyq0voKKye6MzrK8/K7tYQRiSB/jqOiwnFtQxxcA3xGTY9cEsrGpNl2ClPecQofLuj+BO8AIsxw==} - hasBin: true - - '@pnpm/linux-arm64@11.10.0': - resolution: {integrity: sha512-NbvDeUfs0SJuli9OPvgVvmnlbo2DvJ861XGXKrzgLu5AuTnLDLXgbZEEUd8mJ5I0YNrqOVXSWVfWEqiAazWzPA==} - cpu: [arm64] - os: [linux] - - '@pnpm/linux-x64@11.10.0': - resolution: {integrity: sha512-kdgb8BXZ/3XQ0x2cOmgLmsij7+SUIqd1bcV6OZhdGzQiDrOY6FAPrR+Y2Bp+NjrrhjzMHVm5pZrrmdeC67ymSQ==} - cpu: [x64] - os: [linux] - - '@pnpm/linuxstatic-arm64@11.10.0': - resolution: {integrity: sha512-JE1WrSyKGvqGQgWzqrMXn//ehedpiRax3hi1oP+v6mhvcnlJD1lpfXsjSfIFl9weTWC5KVtFbxSNKbKWfP+v6g==} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@pnpm/linuxstatic-x64@11.10.0': - resolution: {integrity: sha512-1TBZVRkWb78GnsusIfVgwz20MOOW0fyehW+qLL3MxE9vT0IedNWsAhe3KWXgEvtC4M7NtMt55uQQJm+1egwBkA==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@pnpm/macos-arm64@11.10.0': - resolution: {integrity: sha512-94AVpPixBqyNT6SHYvIKFb1bfaHR5vxBzZsiFJahNSlGkgyPrgzmcqDiloZ/Jl+zxJd8L5PU1ddP0Q9PZnRqlA==} - cpu: [arm64] - os: [darwin] - - '@pnpm/win-arm64@11.10.0': - resolution: {integrity: sha512-g2Ymnq+LgVyZaWsGBQSlpIcBCOOxyLky2UX+kTwGiIXnj6k/xXqZR0ZJLuxeiGNh/CVmhftOqokpyJNzyj8kng==} - cpu: [arm64] - os: [win32] - - '@pnpm/win-x64@11.10.0': - resolution: {integrity: sha512-kCHYZudUEBjrEchgnJUnNHCdvLXpUZun2z0rMAeP5DymsaXwEVvVzGj220iR/NyhgDocJUmgtTKtwL/ejL785Q==} - cpu: [x64] - os: [win32] - - '@reflink/reflink-darwin-arm64@0.1.19': - resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@reflink/reflink-darwin-x64@0.1.19': - resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@reflink/reflink-linux-arm64-gnu@0.1.19': - resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@reflink/reflink-linux-arm64-musl@0.1.19': - resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - libc: [musl] - - '@reflink/reflink-linux-x64-gnu@0.1.19': - resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@reflink/reflink-linux-x64-musl@0.1.19': - resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - libc: [musl] - - '@reflink/reflink-win32-arm64-msvc@0.1.19': - resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@reflink/reflink-win32-x64-msvc@0.1.19': - resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@reflink/reflink@0.1.19': - resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==} - engines: {node: '>= 10'} - - detect-libc@2.1.2: - resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} - engines: {node: '>=8'} - - pnpm@11.10.0: - resolution: {integrity: sha512-C3+LmAYAMZBMAX46QesYehbUDuuCm5XE+MsDaBdh/Eq1PdIZEVubRH9NzhoFohR2RGHn03AzkqnzL5URzoyGyA==} - engines: {node: '>=22.13'} - hasBin: true - -snapshots: - - '@pnpm/exe@11.10.0': - dependencies: - '@reflink/reflink': 0.1.19 - detect-libc: 2.1.2 - optionalDependencies: - '@pnpm/linux-arm64': 11.10.0 - '@pnpm/linux-x64': 11.10.0 - '@pnpm/linuxstatic-arm64': 11.10.0 - '@pnpm/linuxstatic-x64': 11.10.0 - '@pnpm/macos-arm64': 11.10.0 - '@pnpm/win-arm64': 11.10.0 - '@pnpm/win-x64': 11.10.0 - - '@pnpm/linux-arm64@11.10.0': - optional: true - - '@pnpm/linux-x64@11.10.0': - optional: true - - '@pnpm/linuxstatic-arm64@11.10.0': - optional: true - - '@pnpm/linuxstatic-x64@11.10.0': - optional: true - - '@pnpm/macos-arm64@11.10.0': - optional: true - - '@pnpm/win-arm64@11.10.0': - optional: true - - '@pnpm/win-x64@11.10.0': - optional: true - - '@reflink/reflink-darwin-arm64@0.1.19': - optional: true - - '@reflink/reflink-darwin-x64@0.1.19': - optional: true - - '@reflink/reflink-linux-arm64-gnu@0.1.19': - optional: true - - '@reflink/reflink-linux-arm64-musl@0.1.19': - optional: true - - '@reflink/reflink-linux-x64-gnu@0.1.19': - optional: true - - '@reflink/reflink-linux-x64-musl@0.1.19': - optional: true - - '@reflink/reflink-win32-arm64-msvc@0.1.19': - optional: true - - '@reflink/reflink-win32-x64-msvc@0.1.19': - optional: true - - '@reflink/reflink@0.1.19': - optionalDependencies: - '@reflink/reflink-darwin-arm64': 0.1.19 - '@reflink/reflink-darwin-x64': 0.1.19 - '@reflink/reflink-linux-arm64-gnu': 0.1.19 - '@reflink/reflink-linux-arm64-musl': 0.1.19 - '@reflink/reflink-linux-x64-gnu': 0.1.19 - '@reflink/reflink-linux-x64-musl': 0.1.19 - '@reflink/reflink-win32-arm64-msvc': 0.1.19 - '@reflink/reflink-win32-x64-msvc': 0.1.19 - - detect-libc@2.1.2: {} - - pnpm@11.10.0: {} - ---- lockfileVersion: '9.0' settings: @@ -235,6 +36,9 @@ catalogs: '@ast-grep/lang-python': specifier: 0.0.6 version: 0.0.6 + '@ast-grep/lang-ruby': + specifier: 0.0.7 + version: 0.0.7 '@ast-grep/napi': specifier: 0.44.1 version: 0.44.1 @@ -372,6 +176,9 @@ importers: '@ast-grep/lang-python': specifier: 'catalog:' version: 0.0.6 + '@ast-grep/lang-ruby': + specifier: 'catalog:' + version: 0.0.7 '@ast-grep/napi': specifier: 'catalog:' version: 0.44.1 @@ -443,7 +250,7 @@ importers: version: 3.1.1 mint: specifier: 'catalog:' - version: 4.2.788(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(supports-color@8.1.1)(typescript@5.9.3) + version: 4.2.788(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@25.9.4)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(typescript@5.9.3) packages/evals: dependencies: @@ -1192,6 +999,14 @@ packages: tree-sitter-cli: optional: true + '@ast-grep/lang-ruby@0.0.7': + resolution: {integrity: sha512-tSoj96K/saNfz2wqLZPtKPhwGsRsveydv1FIce9KbXVmxmk+9cerg+LqPS9A8CO3OMQj6LFr1J2Jyr7A/KMpZQ==} + peerDependencies: + tree-sitter-cli: 0.25.8 + peerDependenciesMeta: + tree-sitter-cli: + optional: true + '@ast-grep/napi-darwin-arm64@0.44.1': resolution: {integrity: sha512-JPcb1PvOkwKgccGbBgtoc4e6qfx/luvAtlc6SEtNPhvSwV8qagLb9MCSDcCiuqrqICx3qHj2CXouGbs66lCFvA==} engines: {node: '>= 10'} @@ -8572,6 +8387,10 @@ snapshots: dependencies: '@ast-grep/setup-lang': 0.0.6 + '@ast-grep/lang-ruby@0.0.7': + dependencies: + '@ast-grep/setup-lang': 0.0.6 + '@ast-grep/napi-darwin-arm64@0.44.1': optional: true @@ -9582,51 +9401,51 @@ snapshots: '@inquirer/ansi@1.0.2': {} - '@inquirer/checkbox@4.3.2(@types/node@24.13.2)': + '@inquirer/checkbox@4.3.2(@types/node@25.9.4)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/type': 3.0.10(@types/node@25.9.4) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/confirm@5.1.21(@types/node@24.13.2)': + '@inquirer/confirm@5.1.21(@types/node@25.9.4)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/core@10.3.2(@types/node@24.13.2)': + '@inquirer/core@10.3.2(@types/node@25.9.4)': dependencies: '@inquirer/ansi': 1.0.2 '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/type': 3.0.10(@types/node@25.9.4) cli-width: 4.1.0 mute-stream: 2.0.0 signal-exit: 4.1.0 wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/editor@4.2.23(@types/node@24.13.2)': + '@inquirer/editor@4.2.23(@types/node@25.9.4)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/external-editor': 1.0.3(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/external-editor': 1.0.3(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/expand@4.0.23(@types/node@24.13.2)': + '@inquirer/expand@4.0.23(@types/node@25.9.4)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 '@inquirer/external-editor@1.0.3(@types/node@24.13.2)': dependencies: @@ -9635,75 +9454,82 @@ snapshots: optionalDependencies: '@types/node': 24.13.2 + '@inquirer/external-editor@1.0.3(@types/node@25.9.4)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 25.9.4 + '@inquirer/figures@1.0.15': {} - '@inquirer/input@4.3.1(@types/node@24.13.2)': + '@inquirer/input@4.3.1(@types/node@25.9.4)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/number@3.0.23(@types/node@24.13.2)': + '@inquirer/number@3.0.23(@types/node@25.9.4)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/password@4.0.23(@types/node@24.13.2)': + '@inquirer/password@4.0.23(@types/node@25.9.4)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/prompts@7.9.0(@types/node@24.13.2)': - dependencies: - '@inquirer/checkbox': 4.3.2(@types/node@24.13.2) - '@inquirer/confirm': 5.1.21(@types/node@24.13.2) - '@inquirer/editor': 4.2.23(@types/node@24.13.2) - '@inquirer/expand': 4.0.23(@types/node@24.13.2) - '@inquirer/input': 4.3.1(@types/node@24.13.2) - '@inquirer/number': 3.0.23(@types/node@24.13.2) - '@inquirer/password': 4.0.23(@types/node@24.13.2) - '@inquirer/rawlist': 4.1.11(@types/node@24.13.2) - '@inquirer/search': 3.2.2(@types/node@24.13.2) - '@inquirer/select': 4.4.2(@types/node@24.13.2) + '@inquirer/prompts@7.9.0(@types/node@25.9.4)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@25.9.4) + '@inquirer/confirm': 5.1.21(@types/node@25.9.4) + '@inquirer/editor': 4.2.23(@types/node@25.9.4) + '@inquirer/expand': 4.0.23(@types/node@25.9.4) + '@inquirer/input': 4.3.1(@types/node@25.9.4) + '@inquirer/number': 3.0.23(@types/node@25.9.4) + '@inquirer/password': 4.0.23(@types/node@25.9.4) + '@inquirer/rawlist': 4.1.11(@types/node@25.9.4) + '@inquirer/search': 3.2.2(@types/node@25.9.4) + '@inquirer/select': 4.4.2(@types/node@25.9.4) optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/rawlist@4.1.11(@types/node@24.13.2)': + '@inquirer/rawlist@4.1.11(@types/node@25.9.4)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/search@3.2.2(@types/node@24.13.2)': + '@inquirer/search@3.2.2(@types/node@25.9.4)': dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/type': 3.0.10(@types/node@25.9.4) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/select@4.4.2(@types/node@24.13.2)': + '@inquirer/select@4.4.2(@types/node@25.9.4)': dependencies: '@inquirer/ansi': 1.0.2 - '@inquirer/core': 10.3.2(@types/node@24.13.2) + '@inquirer/core': 10.3.2(@types/node@25.9.4) '@inquirer/figures': 1.0.15 - '@inquirer/type': 3.0.10(@types/node@24.13.2) + '@inquirer/type': 3.0.10(@types/node@25.9.4) yoctocolors-cjs: 2.1.3 optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 - '@inquirer/type@3.0.10(@types/node@24.13.2)': + '@inquirer/type@3.0.10(@types/node@25.9.4)': optionalDependencies: - '@types/node': 24.13.2 + '@types/node': 25.9.4 '@isaacs/cliui@8.0.2': dependencies: @@ -9923,9 +9749,9 @@ snapshots: '@types/react': 19.2.17 react: 19.2.3 - '@mintlify/cli@4.0.1391(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(supports-color@8.1.1)(typescript@5.9.3)': + '@mintlify/cli@4.0.1391(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@25.9.4)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(typescript@5.9.3)': dependencies: - '@inquirer/prompts': 7.9.0(@types/node@24.13.2) + '@inquirer/prompts': 7.9.0(@types/node@25.9.4) '@mintlify/common': 1.0.1080(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@mintlify/link-rot': 3.0.1278(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(react@19.2.3)(typescript@5.9.3) '@mintlify/models': 0.0.347 @@ -9935,10 +9761,10 @@ snapshots: adm-zip: 0.6.0 chalk: 5.2.0 color: 4.2.3 - detect-port: 1.5.1(supports-color@8.1.1) + detect-port: 1.5.1 fs-extra: 11.2.0 ink: 6.3.0(@types/react@19.2.17)(bufferutil@4.1.0)(react@19.2.3) - inquirer: 12.3.0(@types/node@24.13.2) + inquirer: 12.3.0(@types/node@25.9.4) js-yaml: 4.3.1 jsonc-parser: 3.3.1 mdast-util-mdx-jsx: 3.2.0 @@ -12071,7 +11897,7 @@ snapshots: detect-libc@2.1.2: {} - detect-port@1.5.1(supports-color@8.1.1): + detect-port@1.5.1: dependencies: address: 1.2.2 debug: 4.4.3(supports-color@8.1.1) @@ -13345,12 +13171,12 @@ snapshots: inline-style-parser@0.2.7: {} - inquirer@12.3.0(@types/node@24.13.2): + inquirer@12.3.0(@types/node@25.9.4): dependencies: - '@inquirer/core': 10.3.2(@types/node@24.13.2) - '@inquirer/prompts': 7.9.0(@types/node@24.13.2) - '@inquirer/type': 3.0.10(@types/node@24.13.2) - '@types/node': 24.13.2 + '@inquirer/core': 10.3.2(@types/node@25.9.4) + '@inquirer/prompts': 7.9.0(@types/node@25.9.4) + '@inquirer/type': 3.0.10(@types/node@25.9.4) + '@types/node': 25.9.4 ansi-escapes: 4.3.2 mute-stream: 2.0.0 run-async: 3.0.0 @@ -14366,9 +14192,9 @@ snapshots: dependencies: minipass: 7.1.3 - mint@4.2.788(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(supports-color@8.1.1)(typescript@5.9.3): + mint@4.2.788(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@25.9.4)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(typescript@5.9.3): dependencies: - '@mintlify/cli': 4.0.1391(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@24.13.2)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(supports-color@8.1.1)(typescript@5.9.3) + '@mintlify/cli': 4.0.1391(@base-ui/react@1.7.0(@types/react@19.2.17)(react-dom@18.3.1(react@19.2.3))(react@19.2.3))(@types/node@25.9.4)(@types/react@19.2.17)(bufferutil@4.1.0)(react-dom@18.3.1(react@19.2.3))(typescript@5.9.3) transitivePeerDependencies: - '@base-ui/react' - '@types/node' diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e47bbe84aa..89e5f2f3a7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -8,6 +8,7 @@ catalog: "@modelcontextprotocol/sdk": 1.29.0 "@ast-grep/lang-go": 0.0.6 "@ast-grep/lang-python": 0.0.6 + "@ast-grep/lang-ruby": 0.0.7 "@ast-grep/napi": 0.44.1 "@changesets/changelog-github": 0.7.0 "@changesets/cli": 2.31.1 @@ -62,6 +63,7 @@ overrides: allowBuilds: "@ast-grep/lang-go": true "@ast-grep/lang-python": true + "@ast-grep/lang-ruby": true "@google/genai": false "@scarf/scarf": false braintrust: false diff --git a/rules/ast-grep/cdp-parity.test.ts b/rules/ast-grep/cdp-parity.test.ts index 8db7be7ebe..cb729924c3 100644 --- a/rules/ast-grep/cdp-parity.test.ts +++ b/rules/ast-grep/cdp-parity.test.ts @@ -1,12 +1,15 @@ import { readFile } from "node:fs/promises"; import go from "@ast-grep/lang-go"; import python from "@ast-grep/lang-python"; +import ruby from "@ast-grep/lang-ruby"; import { parse, registerDynamicLanguage, type SgNode } from "@ast-grep/napi"; import { describe, expect, it } from "vitest"; -registerDynamicLanguage({ go, python }); +registerDynamicLanguage({ go, python, ruby }); -type Language = "go" | "python" | "typescript"; +type Language = "go" | "python" | "ruby" | "typescript"; + +const languages = ["typescript", "python", "go", "ruby"] as const; const clients = { typescript: { @@ -24,6 +27,11 @@ const clients = { runtimeFile: new URL("../../packages/sdk-go/runtime_compatibility.go", import.meta.url), typeName: "cdpClient", }, + ruby: { + file: new URL("../../packages/sdk-ruby/lib/stagehand/cdp_client.rb", import.meta.url), + runtimeFile: undefined, + typeName: "CDPClient", + }, } as const satisfies Record< Language, { file: URL; runtimeFile: URL | undefined; typeName: string } @@ -32,19 +40,29 @@ const clients = { const capabilities = [ { name: "outbound JSON-RPC transport", - methods: { typescript: "send", python: "send", go: "Send" }, + methods: { typescript: "send", python: "send", go: "Send", ruby: "send" }, }, { name: "correlated CDP commands", - methods: { typescript: "sendCommand", python: "send_command", go: "sendCommand" }, + methods: { + typescript: "sendCommand", + python: "send_command", + go: "sendCommand", + ruby: "send_command", + }, }, { name: "incoming CDP dispatch", - methods: { typescript: "handleMessage", python: "_handle_message", go: "handleMessage" }, + methods: { + typescript: "handleMessage", + python: "_handle_message", + go: "handleMessage", + ruby: "handle_message", + }, }, { name: "deterministic shutdown", - methods: { typescript: "close", python: "close", go: "Close" }, + methods: { typescript: "close", python: "close", go: "Close", ruby: "close" }, }, ] as const; @@ -63,6 +81,7 @@ const runtimeBindingTokens = { typescript: "STAGEHAND_SEND_TO_HOST_BINDING", python: "__stagehandSendToHost", go: "__stagehandSendToHost", + ruby: "__stagehandSendToHost", } as const satisfies Record; const pages = { @@ -81,6 +100,11 @@ const pages = { typeName: "Page", method: "On", }, + ruby: { + file: new URL("../../packages/sdk-ruby/lib/stagehand/page.rb", import.meta.url), + typeName: "Page", + method: "on", + }, } as const satisfies Record; const subscriptions = { @@ -99,11 +123,16 @@ const subscriptions = { typeName: "CDPSubscription", method: "Close", }, + ruby: { + file: new URL("../../packages/sdk-ruby/lib/stagehand/page.rb", import.meta.url), + typeName: "CDPSubscription", + method: "unsubscribe", + }, } as const satisfies Record; describe("CDP clients retain the same core behavior", () => { - it("exposes page event subscriptions in TypeScript, Python, and Go", async () => { - for (const language of ["typescript", "python", "go"] as const) { + it("exposes page event subscriptions in TypeScript, Python, Go, and Ruby", async () => { + for (const language of languages) { const page = pages[language]; const root = parse(language, await readFile(page.file, "utf8")).root(); expect( @@ -113,8 +142,8 @@ describe("CDP clients retain the same core behavior", () => { } }); - it("returns removable subscription handles in TypeScript, Python, and Go", async () => { - for (const language of ["typescript", "python", "go"] as const) { + it("returns removable subscription handles in TypeScript, Python, Go, and Ruby", async () => { + for (const language of languages) { const subscription = subscriptions[language]; const root = parse(language, await readFile(subscription.file, "utf8")).root(); expect( @@ -124,17 +153,17 @@ describe("CDP clients retain the same core behavior", () => { } }); - it("keeps the same transport lifecycle in TypeScript, Python, and Go", async () => { + it("keeps the same transport lifecycle in TypeScript, Python, Go, and Ruby", async () => { const methods = new Map>(); - for (const language of ["typescript", "python", "go"] as const) { + for (const language of languages) { const client = clients[language]; const root = parse(language, await readFile(client.file, "utf8")).root(); methods.set(language, clientMethods(root, language, client.typeName)); } for (const capability of capabilities) { - for (const language of ["typescript", "python", "go"] as const) { + for (const language of languages) { expect( methods.get(language)?.has(capability.methods[language]), `${language} CDP client must implement ${capability.name}`, @@ -145,7 +174,7 @@ describe("CDP clients retain the same core behavior", () => { it("keeps extension discovery and runtime bridging aligned", async () => { const sources = await Promise.all( - (["typescript", "python", "go"] as const).map(async (language) => ({ + languages.map(async (language) => ({ language, source: ( await Promise.all( @@ -199,6 +228,20 @@ function clientMethods(root: SgNode, language: Language, typeName: string): Set< ); } + if (language === "ruby") { + const classNode = root + .findAll({ rule: { kind: "class" } }) + .find((node) => namedChildren(node)[0]?.text() === typeName); + // findAll instead of direct children: `public def x` wraps the method in + // a visibility call node, and `class << self` nests class-level methods. + return new Set( + (classNode ? classNode.findAll({ rule: { kind: "method" } }) : []).flatMap((method) => { + const name = namedChildren(method).find((child) => child.kind() === "identifier"); + return name ? [name.text()] : []; + }), + ); + } + const classKind = language === "typescript" ? "class_declaration" : "class_definition"; const bodyKind = language === "typescript" ? "class_body" : "block"; const methodKind = language === "typescript" ? "method_definition" : "function_definition"; diff --git a/rules/ast-grep/example-parity.test.ts b/rules/ast-grep/example-parity.test.ts index 735d4af78f..48feee3353 100644 --- a/rules/ast-grep/example-parity.test.ts +++ b/rules/ast-grep/example-parity.test.ts @@ -1,14 +1,16 @@ import { readdir, readFile } from "node:fs/promises"; import go from "@ast-grep/lang-go"; import python from "@ast-grep/lang-python"; +import ruby from "@ast-grep/lang-ruby"; import { parse, registerDynamicLanguage, type SgNode } from "@ast-grep/napi"; import { describe, expect, it } from "vitest"; -registerDynamicLanguage({ go, python }); +registerDynamicLanguage({ go, python, ruby }); const exampleDirectories = { go: new URL("../../packages/sdk-go/examples/", import.meta.url), python: new URL("../../packages/sdk-python/examples/", import.meta.url), + ruby: new URL("../../packages/sdk-ruby/examples/", import.meta.url), typescript: new URL("../../packages/sdk-ts/examples/", import.meta.url), } as const; @@ -17,6 +19,7 @@ type ExampleLanguage = keyof typeof exampleDirectories; const exampleExtensions: Record = { go: ".go", python: ".py", + ruby: ".rb", typescript: ".ts", }; @@ -38,11 +41,13 @@ describe("All language examples remain in sync", () => { const inventories = { go: (await examples("go")).map(({ name }) => name), python: (await examples("python")).map(({ name }) => name), + ruby: (await examples("ruby")).map(({ name }) => name), typescript: (await examples("typescript")).map(({ name }) => name), }; expect(inventories.python).toStrictEqual(inventories.typescript); expect(inventories.go).toStrictEqual(inventories.typescript); + expect(inventories.ruby).toStrictEqual(inventories.typescript); expect(inventories.typescript.length).toBeGreaterThan(0); }); @@ -52,17 +57,23 @@ describe("All language examples remain in sync", () => { (await examples("python")).map((example) => [example.name, example]), ); const goExamples = new Map((await examples("go")).map((example) => [example.name, example])); + const rubyExamples = new Map( + (await examples("ruby")).map((example) => [example.name, example]), + ); for (const typescript of typescriptExamples) { const pythonExample = pythonExamples.get(typescript.name); const goExample = goExamples.get(typescript.name); + const rubyExample = rubyExamples.get(typescript.name); expect(pythonExample, `${typescript.name} must have a Python example`).toBeDefined(); expect(goExample, `${typescript.name} must have a Go example`).toBeDefined(); - if (!pythonExample || !goExample) continue; + expect(rubyExample, `${typescript.name} must have a Ruby example`).toBeDefined(); + if (!pythonExample || !goExample || !rubyExample) continue; const typescriptRoot = parse("typescript", await readFile(typescript.url, "utf8")).root(); const pythonRoot = parse("python", await readFile(pythonExample.url, "utf8")).root(); const goRoot = parse("go", await readFile(goExample.url, "utf8")).root(); + const rubyRoot = parse("ruby", await readFile(rubyExample.url, "utf8")).root(); expect( publicSdkOperations(pythonRoot, "python"), @@ -72,6 +83,10 @@ describe("All language examples remain in sync", () => { publicSdkOperations(goRoot, "go"), `${typescript.name} must call the same public SDK operations in Go and TypeScript`, ).toStrictEqual(publicSdkOperations(typescriptRoot, "typescript")); + expect( + publicSdkOperations(rubyRoot, "ruby"), + `${typescript.name} must call the same public SDK operations in Ruby and TypeScript`, + ).toStrictEqual(publicSdkOperations(typescriptRoot, "typescript")); } }); @@ -93,7 +108,7 @@ func main() { }); it("uses the public Stagehand lifecycle in every example", async () => { - for (const language of ["typescript", "python", "go"] as const) { + for (const language of ["typescript", "python", "go", "ruby"] as const) { for (const example of await examples(language)) { const root = parse(language, await readFile(example.url, "utf8")).root(); const stagehand = stagehandVariable(root, language); @@ -109,7 +124,9 @@ func main() { pattern: language === "typescript" ? 'import { $$$IMPORTS } from "../src/index.js"' - : "from stagehand import $$$IMPORTS", + : language === "ruby" + ? 'require_relative "../lib/stagehand"' + : "from stagehand import $$$IMPORTS", }, }); @@ -117,7 +134,7 @@ func main() { publicImport, `${language} ${example.file} must import the public SDK`, ).toBeDefined(); - if (language !== "go") { + if (language !== "go" && language !== "ruby") { expect( publicImport?.getMultipleMatches("IMPORTS").some((node) => node.text() === "Stagehand"), `${language} ${example.file} must import public Stagehand`, @@ -140,6 +157,15 @@ func main() { goCalls(root).some(({ object, method }) => object === stagehand && method === "Close"), `${language} ${example.file} must close Stagehand`, ).toBe(true); + } else if (language === "ruby") { + expect( + root.find({ rule: { pattern: `${stagehand} = Stagehand.create($$$ARGS)` } }), + `${language} ${example.file} must create Stagehand`, + ).not.toBeNull(); + expect( + root.find({ rule: { pattern: `${stagehand}.close` } }), + `${language} ${example.file} must close Stagehand`, + ).not.toBeNull(); } else { expect( root.find({ rule: { pattern: `${stagehand} = await Stagehand.create($$$ARGS)` } }), @@ -188,7 +214,9 @@ function stagehandVariable(root: SgNode, language: ExampleLanguage): string | un pattern: language === "typescript" ? "const $STAGEHAND = await Stagehand.create($$$ARGS)" - : "$STAGEHAND = await Stagehand.create($$$ARGS)", + : language === "ruby" + ? "$STAGEHAND = Stagehand.create($$$ARGS)" + : "$STAGEHAND = await Stagehand.create($$$ARGS)", }, }); diff --git a/rules/ast-grep/rpc-parity.test.ts b/rules/ast-grep/rpc-parity.test.ts index c1fd5acaf7..45bafc88f1 100644 --- a/rules/ast-grep/rpc-parity.test.ts +++ b/rules/ast-grep/rpc-parity.test.ts @@ -1,12 +1,15 @@ import { readFile } from "node:fs/promises"; import go from "@ast-grep/lang-go"; import python from "@ast-grep/lang-python"; +import ruby from "@ast-grep/lang-ruby"; import { parse, registerDynamicLanguage, type SgNode } from "@ast-grep/napi"; import { describe, expect, it } from "vitest"; -registerDynamicLanguage({ go, python }); +registerDynamicLanguage({ go, python, ruby }); -type Language = "go" | "python" | "typescript"; +type Language = "go" | "python" | "ruby" | "typescript"; + +const languages = ["typescript", "python", "go", "ruby"] as const; const clients = { typescript: { @@ -21,16 +24,20 @@ const clients = { file: new URL("../../packages/sdk-go/rpc_client.go", import.meta.url), typeName: "rpcClient", }, + ruby: { + file: new URL("../../packages/sdk-ruby/lib/stagehand/rpc_client.rb", import.meta.url), + typeName: "RPCClient", + }, } as const satisfies Record; const capabilities = [ { name: "outbound requests", - methods: { typescript: "send", python: "send", go: "call" }, + methods: { typescript: "send", python: "send", go: "call", ruby: "send" }, }, { name: "inbound request handlers", - methods: { typescript: "onRequest", python: "on_request", go: "onRequest" }, + methods: { typescript: "onRequest", python: "on_request", go: "onRequest", ruby: "on_request" }, }, { name: "notification listeners", @@ -38,30 +45,31 @@ const capabilities = [ typescript: "onNotification", python: "on_notification", go: "onNotification", + ruby: "on_notification", }, }, { name: "incoming message processing", - methods: { typescript: "receive", python: "_read", go: "receive" }, + methods: { typescript: "receive", python: "_read", go: "receive", ruby: "receive" }, }, { name: "deterministic shutdown", - methods: { typescript: "close", python: "close", go: "close" }, + methods: { typescript: "close", python: "close", go: "close", ruby: "close" }, }, ] as const; describe("JSON-RPC clients retain the same core behavior", () => { - it("keeps the same lifecycle capabilities in TypeScript, Python, and Go", async () => { + it("keeps the same lifecycle capabilities in TypeScript, Python, Go, and Ruby", async () => { const methods = new Map>(); - for (const language of ["typescript", "python", "go"] as const) { + for (const language of languages) { const client = clients[language]; const root = parse(language, await readFile(client.file, "utf8")).root(); methods.set(language, clientMethods(root, language, client.typeName)); } for (const capability of capabilities) { - for (const language of ["typescript", "python", "go"] as const) { + for (const language of languages) { expect( methods.get(language)?.has(capability.methods[language]), `${language} RPC client must implement ${capability.name}`, @@ -71,11 +79,12 @@ describe("JSON-RPC clients retain the same core behavior", () => { }); it("uses the protocol error codes and notification buffer bound in every client", async () => { - const [protocol, typescript, pythonSource, goSource] = await Promise.all([ + const [protocol, typescript, pythonSource, goSource, rubySource] = await Promise.all([ readFile(new URL("../../packages/protocol/json-rpc/schemas.ts", import.meta.url), "utf8"), readFile(clients.typescript.file, "utf8"), readFile(clients.python.file, "utf8"), readFile(clients.go.file, "utf8"), + readFile(clients.ruby.file, "utf8"), ]); const errorCodes = [ @@ -92,10 +101,14 @@ describe("JSON-RPC clients retain the same core behavior", () => { ); expect(pythonSource, `Python must use JSON-RPC error ${code}`).toContain(code); expect(goSource, `Go must use JSON-RPC error ${code}`).toContain(code); + // Ruby writes numeric literals with digit separators (-32_700). + const rubyCode = new RegExp(`${code.slice(0, 3)}_?${code.slice(3)}`, "u"); + expect(rubyCode.test(rubySource), `Ruby must use JSON-RPC error ${code}`).toBe(true); } expect(typescript).toMatch(/MAX_PENDING_NOTIFICATIONS\s*=\s*100/u); expect(pythonSource).toMatch(/_MAX_PENDING_NOTIFICATIONS\s*=\s*100/u); expect(goSource).toMatch(/maxPendingNotifications\s*=\s*100/u); + expect(rubySource).toMatch(/MAX_PENDING_NOTIFICATIONS\s*=\s*100/u); }); }); @@ -115,6 +128,20 @@ function clientMethods(root: SgNode, language: Language, typeName: string): Set< ); } + if (language === "ruby") { + const classNode = root + .findAll({ rule: { kind: "class" } }) + .find((node) => namedChildren(node)[0]?.text() === typeName); + // findAll instead of direct children: `public def x` wraps the method in + // a visibility call node, and `class << self` nests class-level methods. + return new Set( + (classNode ? classNode.findAll({ rule: { kind: "method" } }) : []).flatMap((method) => { + const name = namedChildren(method).find((child) => child.kind() === "identifier"); + return name ? [name.text()] : []; + }), + ); + } + const classKind = language === "typescript" ? "class_declaration" : "class_definition"; const bodyKind = language === "typescript" ? "class_body" : "block"; const methodKind = language === "typescript" ? "method_definition" : "function_definition"; diff --git a/rules/ast-grep/sdk-parity.test.ts b/rules/ast-grep/sdk-parity.test.ts index 17aacc2f9e..053712f609 100644 --- a/rules/ast-grep/sdk-parity.test.ts +++ b/rules/ast-grep/sdk-parity.test.ts @@ -1,10 +1,11 @@ import { readdir, readFile } from "node:fs/promises"; import go from "@ast-grep/lang-go"; import python from "@ast-grep/lang-python"; +import ruby from "@ast-grep/lang-ruby"; import { parse, registerDynamicLanguage, type SgNode } from "@ast-grep/napi"; import { describe, expect, it } from "vitest"; -registerDynamicLanguage({ go, python }); +registerDynamicLanguage({ go, python, ruby }); const sdkObjects = [ ["Stagehand", "stagehand.ts", "stagehand.py", "stagehand.go", "Stagehand"], @@ -604,6 +605,368 @@ describe("All language SDK operations remain in sync", () => { }); }); +// --------------------------------------------------------------------------- +// Ruby lane. Ruby call syntax (receiver/method/arguments fields) and idioms +// (`?` predicates with `is_` aliases, attr_reader accessors, `private` +// section markers) differ enough from Python that it gets its own +// extractors. Ruby has no in-source type annotations (the types live in +// packages/sdk-ruby/sig/stagehand.rbs), so the typed-parameter checks above +// intentionally have no Ruby lane. +// --------------------------------------------------------------------------- + +const rubySource = new URL("../../packages/sdk-ruby/lib/stagehand/", import.meta.url); + +const rubyObjects: Readonly> = { + Stagehand: { file: "client.rb", typeName: "Client" }, + BrowserContext: { file: "browser_context.rb", typeName: "BrowserContext" }, + BrowserClipboard: { file: "browser_clipboard.rb", typeName: "BrowserClipboard" }, + Page: { file: "page.rb", typeName: "Page" }, + Locator: { file: "locator.rb", typeName: "Locator" }, +}; + +// Ruby methods that correspond to TypeScript getters (memoized accessors and +// `?` predicates); they are compared as accessors, not callables. +const rubyAccessorNames: Readonly>> = { + Stagehand: new Set(["initialized"]), + BrowserContext: new Set(["clipboard"]), + BrowserClipboard: new Set(), + Page: new Set(), + Locator: new Set(), +}; + +describe("Ruby SDK operations remain in sync", () => { + it("sends protocol version and client identity in stagehand.init", async () => { + const source = await readFile(new URL("client.rb", rubySource), "utf8"); + expect(source, "ruby stagehand.init must send the protocol version").toMatch( + /protocol_version:\s*STAGEHAND_PROTOCOL_VERSION/u, + ); + expect(source, "ruby stagehand.init must send the client identity").toMatch( + /client_info:\s*Models::ImplementationInfo/u, + ); + }); + + it("references every registered protocol operation", async () => { + const registry = await stagehandMethodNames(); + expect( + await rubyProtocolOperations(), + "Ruby must reference every StagehandMethods operation", + ).toStrictEqual([...registry.values()].sort()); + }); + + it("matches the extension router outbound and the TypeScript inbound surface", async () => { + const registry = await stagehandMethodNames(); + const [extensionInbound, typescriptInbound] = await Promise.all([ + extensionRouterOperations(), + protocolOperations("typescript", typescriptSource, registry, "inbound"), + ]); + expect( + await rubyProtocolOperations("outbound"), + "Ruby outbound operations must match the extension router", + ).toStrictEqual(extensionInbound); + expect( + await rubyProtocolOperations("inbound"), + "Ruby inbound request handlers must match TypeScript inbound request handlers", + ).toStrictEqual(typescriptInbound); + }); + + it("handles every registered notification", async () => { + const registry = await stagehandNotificationNames(); + const notifications = new Set(); + for (const root of await rubyRoots()) { + for (const call of rubyCalls(root, "on_notification")) { + const notification = rubyCallArguments(call)[0]; + if (notification?.kind() === "string") notifications.add(stringLiteral(notification)); + } + } + expect( + [...notifications].sort(), + "Ruby must handle every StagehandNotifications entry", + ).toStrictEqual([...registry.values()].sort()); + }); + + it("exposes the same RPC-backed operations as TypeScript", async () => { + const registry = await stagehandMethodNames(); + for (const [className, typescriptFile] of sdkObjects.map( + ([name, file]) => [name, file] as const, + )) { + const typescript = await publicOperations( + "typescript", + new URL(typescriptFile, typescriptSource), + className, + registry, + ); + expect( + await rubyPublicOperations(className), + `${className} must expose the same RPC-backed operations in Ruby`, + ).toStrictEqual(typescript); + } + }); + + it("exposes the same public callable surface as TypeScript", async () => { + for (const [className, typescriptFile] of sdkObjects.map( + ([name, file]) => [name, file] as const, + )) { + const typescript = await publicCallableMethods( + "typescript", + new URL(typescriptFile, typescriptSource), + className, + ); + expect( + await rubyPublicCallableMethods(className), + `${className} Ruby public methods must remain in sync`, + ).toStrictEqual(typescript); + } + }); + + it("keeps Stagehand accessors aligned with TypeScript", async () => { + const [className, typescriptFile] = sdkObjects[0]; + const typescript = await publicAccessors( + "typescript", + new URL(typescriptFile, typescriptSource), + className, + ); + const accessors = await rubyPublicAccessors(className); + expect(accessors, "Stagehand Ruby accessors must remain in sync").toStrictEqual(typescript); + expect(accessors, "Ruby Stagehand must not expose context").not.toContain("context"); + }); + + it("uses the protocol parameter and result models at Ruby RPC boundaries", async () => { + const methods = await protocolMethods(); + let staticallyVisibleParams = 0; + + for (const { file, root } of await rubyRootsWithFiles()) { + for (const call of rubyCalls(root, "send")) { + const [methodNode, params, result] = rubyCallArguments(call); + if (methodNode?.kind() !== "string") continue; + const wireMethod = stringLiteral(methodNode); + const protocolMethod = methods[wireMethod]; + expect( + protocolMethod, + `${file} calls undeclared protocol method ${wireMethod}`, + ).toBeDefined(); + if (!protocolMethod || !params || !result) continue; + + const paramsModel = rubyModelName(params); + if (paramsModel) { + staticallyVisibleParams += 1; + expect( + paramsModel, + `${file} must send ${wireMethod} with ${referencedModel(protocolMethod.properties.params.$ref)}`, + ).toBe(referencedModel(protocolMethod.properties.params.$ref)); + } + // Ruby result descriptors are wire-definition names; nil means the + // raw JSON result is used as-is (scalar results). + if (result.kind() === "string") { + expect( + stringLiteral(result), + `${file} must decode ${wireMethod} with its protocol result model`, + ).toBe(referencedModel(protocolMethod.properties.result.$ref)); + } + } + } + + expect(staticallyVisibleParams).toBeGreaterThan(0); + }); +}); + +type RubyClassMethod = { + name: string; + mappedName: string; + node: SgNode; + isPublic: boolean; +}; + +async function rubyRoots(): Promise { + return (await rubyRootsWithFiles()).map(({ root }) => root); +} + +async function rubyRootsWithFiles(): Promise> { + const files = (await readdir(rubySource, { recursive: true })) + .filter((file) => file.endsWith(".rb")) + .sort(); + return Promise.all( + files.map(async (file) => ({ + file, + root: parse("ruby", await readFile(new URL(file, rubySource), "utf8")).root(), + })), + ); +} + +// Calls on an RPC client receiver (@rpc_client, rpc_client, +// connected_rpc_client) with the given method name. +function rubyCalls(node: SgNode, methodName: string): SgNode[] { + return node.findAll({ rule: { kind: "call" } }).filter((call) => { + if (call.field("method")?.text() !== methodName) return false; + const receiver = call.field("receiver")?.text() ?? ""; + return /(?:^|[.@_])rpc_client$/u.test(receiver); + }); +} + +function rubyCallArguments(call: SgNode): SgNode[] { + const argumentsNode = call.field("arguments"); + return argumentsNode ? argumentsNode.children().filter((child) => child.isNamed()) : []; +} + +async function rubyProtocolOperations(boundary?: RequestBoundary): Promise { + const operations = new Set(); + for (const root of await rubyRoots()) { + if (boundary !== "inbound") { + for (const call of rubyCalls(root, "send")) { + const method = rubyCallArguments(call)[0]; + if (method?.kind() === "string") operations.add(stringLiteral(method)); + } + } + if (boundary !== "outbound") { + for (const call of rubyCalls(root, "on_request")) { + const method = rubyCallArguments(call)[0]; + if (method?.kind() === "string") operations.add(stringLiteral(method)); + } + } + } + return [...operations].sort(); +} + +function rubyClassNode(root: SgNode, typeName: string): SgNode | undefined { + return root + .findAll({ rule: { kind: "class" } }) + .find((node) => namedChildren(node)[0]?.text() === typeName); +} + +// Walks the class body tracking `private`/`public` section markers and +// aliases. `?`/`!` suffixes map to the alias name when one exists (Ruby's +// `visible?` + `alias is_visible visible?` present the `is_visible` surface), +// otherwise they are stripped. +function rubyClassMethods(classNode: SgNode): { + methods: RubyClassMethod[]; + attrReaders: string[]; +} { + const body = namedChildren(classNode).find((child) => child.kind() === "body_statement"); + if (!body) return { methods: [], attrReaders: [] }; + + const entries: Array<{ name: string; node: SgNode; isPublic: boolean }> = []; + const aliases = new Map(); + const attrReaders: string[] = []; + let visibility: "public" | "private" | "protected" = "public"; + + for (const child of namedChildren(body)) { + if (child.kind() === "identifier") { + const marker = child.text(); + if (marker === "public" || marker === "private" || marker === "protected") { + visibility = marker; + } + continue; + } + if (child.kind() === "method") { + const name = namedChildren(child).find((nested) => nested.kind() === "identifier"); + if (name) entries.push({ name: name.text(), node: child, isPublic: visibility === "public" }); + continue; + } + if (child.kind() === "alias") { + const [newName, oldName] = namedChildren(child); + if (newName && oldName) aliases.set(oldName.text(), newName.text()); + continue; + } + if (child.kind() !== "call") continue; + const callMethod = child.field("method")?.text(); + if (callMethod === "attr_reader" || callMethod === "attr_accessor") { + if (visibility === "public") { + for (const symbol of rubyCallArguments(child)) { + if (symbol.kind() === "simple_symbol") attrReaders.push(symbol.text().slice(1)); + } + } + continue; + } + // `public def x` / `private def x` inline visibility. + if (callMethod === "public" || callMethod === "private") { + const method = child.findAll({ rule: { kind: "method" } })[0]; + const name = method && namedChildren(method).find((nested) => nested.kind() === "identifier"); + if (method && name) { + entries.push({ name: name.text(), node: method, isPublic: callMethod === "public" }); + } + } + } + + return { + methods: entries.map(({ name, node, isPublic }) => ({ + name, + mappedName: aliases.get(name) ?? name.replace(/[?!]$/u, ""), + node, + isPublic, + })), + attrReaders, + }; +} + +async function rubyClassMethodsFor( + className: string, +): Promise<{ methods: RubyClassMethod[]; attrReaders: string[] }> { + const { file, typeName } = rubyObjects[className] ?? {}; + if (!file || !typeName) throw new Error(`Unknown Ruby SDK object: ${className}`); + const root = parse("ruby", await readFile(new URL(file, rubySource), "utf8")).root(); + const classNode = rubyClassNode(root, typeName); + expect(classNode, `${typeName} must exist in ${file}`).toBeDefined(); + return classNode ? rubyClassMethods(classNode) : { methods: [], attrReaders: [] }; +} + +function rubyPublicSurface(methods: RubyClassMethod[], className: string): RubyClassMethod[] { + return methods.filter( + (method) => + method.isPublic && + method.name !== "initialize" && // Ruby constructors are implicitly private + !method.name.startsWith("_") && + !rubyAccessorNames[className]?.has(method.mappedName) && + participatesInSurfaceParity(className, "python", method.mappedName), + ); +} + +async function rubyPublicCallableMethods(className: string): Promise { + const { methods } = await rubyClassMethodsFor(className); + return [ + ...new Set(rubyPublicSurface(methods, className).map((method) => method.mappedName)), + ].sort(); +} + +async function rubyPublicOperations( + className: string, +): Promise> { + const { methods } = await rubyClassMethodsFor(className); + return rubyPublicSurface(methods, className) + .flatMap((method) => + rubyCalls(method.node, "send").flatMap((call) => { + const wireMethod = rubyCallArguments(call)[0]; + return wireMethod?.kind() === "string" + ? [{ publicMethod: method.mappedName, wireMethod: stringLiteral(wireMethod) }] + : []; + }), + ) + .sort((left, right) => + `${left.publicMethod}:${left.wireMethod}`.localeCompare( + `${right.publicMethod}:${right.wireMethod}`, + ), + ); +} + +async function rubyPublicAccessors(className: string): Promise { + const { methods, attrReaders } = await rubyClassMethodsFor(className); + const predicateAccessors = methods + .filter( + (method) => + method.isPublic && + !method.name.startsWith("_") && + rubyAccessorNames[className]?.has(method.mappedName) === true, + ) + .map((method) => method.mappedName); + return [...new Set([...attrReaders, ...predicateAccessors])].sort(); +} + +function rubyModelName(expression: SgNode): string | undefined { + if (expression.kind() !== "call" || expression.field("method")?.text() !== "new") { + return undefined; + } + const receiver = expression.field("receiver")?.text(); + return receiver?.split("::").at(-1); +} + async function publicOperations( language: SdkLanguage, file: URL,