Compatibility
What's actually supported, what's actually proven, and where each claim comes from.
Every claim below cites the spec file or release that proves it. No entry in this document is aspirational.
Supported versions
- Ruby: 3.1 - 4.0 (gemspec:
required_ruby_version >= 3.1.0, no upper cap) - Rails (railties): 7.0 - 8.1 (gemspec:
railties >= 7.0, < 9.0) - mcp gem:
>= 0.13, < 2.0 - thor:
>= 1.2, < 3.0(1.0 and 1.1 fail to load on Ruby 3.1+: they referenceDidYouMean::SPELL_CHECKERS, which did_you_mean removed) - prism:
>= 1.4, < 3.0(CI legs pin 1.4.0 and 2.1.0 exactly on Ruby 3.2 and run the suite against each) - concurrent-ruby:
>= 1.2, < 3.0 - zeitwerk:
~> 2.5(2.5 is what railties 7.0 allows; a CI leg pins 2.5.0 on Rails 7.0 and Ruby 3.1) - json: unconstrained by this gem, but
json >= 3.0and Rails 7.0 to 8.0 do not work together, whatever gem is in the middle.json 3.0removed thequirks_modekeyword andActiveSupport::JSON::Encodingpasses it on everyto_jsonthrough 8.0, so the pair raisesArgumentError: unknown keyword: quirks_modeinside Rails itself. Rails 8.1 dropped the keyword and resolves json 3 cleanly. The test bundle pinsjson < 3below 8.1 for that reason; an app on those lines has the same choice to make, and it is between its own Rails and json rather than anything here. In such an app the stdio server still works, because the gem writes its stdio replies withJSON.generate. Over HTTP the MCP SDK serializes each reply withto_json, so every request answers with a JSON-RPC error namingunknown keyword: quirks_modeuntil the pair is fixed.
The gemspec's railties bound is wider than the CI matrix: point releases inside
7.0-8.1 and any future 8.x minor satisfy Bundler's constraint without a gem
release, but only the combinations in the table below are actually exercised by
CI.
CI matrix
Source: .github/workflows/unit-matrix.yml, which both ci.yml and
release.yml call. 17 of 25 possible Ruby x Rails combinations run
(fail-fast: false, so one failing cell doesn't hide the rest):
| Ruby \ Rails | 7.0 | 7.1 | 7.2 | 8.0 | 8.1 |
|---|---|---|---|---|---|
| 3.1 | yes | yes | yes | - | - |
| 3.2 | - | yes | yes | yes | yes |
| 3.3 | - | yes | yes | yes | yes |
| 3.4 | - | yes | yes | yes | yes |
| 4.0 | - | - | - | yes | yes |
- Rails 7.0 runs only on Ruby 3.1 (7.0 predates upstream Ruby 3.2+ support).
- Rails 8.0/8.1 run on Ruby 3.2+ (both declare
required_ruby_version >= 3.2.0upstream; they never needed 3.3). - Ruby 4.0 runs only against Rails 8.0/8.1 (7.x has no upstream Ruby 4 support).
- RuboCop runs in a dedicated
lintjob pinned to Ruby 3.3. The release adds a second run inside the matrix (Ruby 3.3 / Rails 8.0), sincerelease.ymlhas nolintjob of its own.
Operating systems
Linux, macOS and Windows. CI runs the unit suite on Linux for every cell above, and on macOS (Ruby 3.4 / Rails 8.1 and Ruby 3.1 / Rails 7.0) and Windows (Ruby 3.4 / Rails 8.1). The E2E harness, which builds real apps and drives the CLI, the rake tasks and both transports, runs on Linux for six Ruby / Rails pairs and on macOS and Windows for Ruby 3.4 / Rails 8.1. A release waits for all of them.
On Windows:
- The MCP configs start the server through
cmd /d /c, since a gem's executables there are batch files that a client starting its server without a shell does not run. See GUIDE.md for teams on both Windows and macOS or Linux. rails_queryon SQLite runs without its time limit: the limit runs the query in a forked child, and Windows has nofork. The answer says so.- The pre-commit hook runs under the sh that Git for Windows runs hooks with.
- A checkout whose lines end in CRLF, Git for Windows' default, is read and
written in them:
rails_search_codeends a line before its CRLF, so$anchors as it does on LF, and the context files' managed block and the initializer'sconfig.ai_toolsline keep the file's own line endings. - ripgrep installs with
winget install BurntSushi.ripgrep.MSVC; without itrails_search_codetakes the slower Ruby path.
Rails 9
Not supported, and the two install paths fail differently:
- In-Gemfile: the gemspec's
railties < 9.0constraint makesbundle install/bundle execfail dependency resolution outright when the host app's Gemfile locks Rails 9 - the gem never loads. - Standalone: the CLI pre-loads the gem into its own
GEM_HOMEbefore the host app boots, so no Bundler constraint applies.exe/rails-ai-contextprints a stderr warning after a successful boot whenRails::VERSION::MAJOR >= 9, naming the installed Rails version and pointing at checking for a newer gem release. Introspection itself is untested on Rails 9 either way.
Operating tiers
Two tiers, both reachable over the CLI and MCP (stdio and HTTP):
RUNTIME - the app booted. Introspectors read live Rails reflection
(ActiveRecord connections, Rails.application.routes, loaded classes) plus the
Prism AST layer for source-level facts (scopes, callbacks, strong params).
STATIC - the app didn't boot, or --no-boot was passed. 32 of the 40
introspectors answer here; each declares which of three kinds it is
(ADR-0002), so what a tier can say is a declaration rather than a guess.
files-only (22) run unchanged against the static app handle, because they
only ever read files: gems, views, view_templates, turbo, stimulus,
active_storage, action_text, auth, tests, rake_tasks, assets,
devops, action_mailbox, migrations, seeds, env_config,
multi_database, components, performance, frontend_frameworks,
credentials and env.
alternate-source (10) have a static_call that reads a different source
from the booted path:
| Introspector | Static source |
|---|---|
schema |
db/schema.rb / db/structure.sql / migration replay over every db/migrate the repo owns, following the files a migration requires under db/ |
models |
app/models/**/*.rb (plus packs, in-repo engines/plugins/modules, extra paths) parsed, not constantized |
routes |
config/routes.rb parsed with a dedicated Prism listener |
controllers |
app/controllers/**/*.rb (plus packs, in-repo engines/plugins/modules, extra paths) parsed, not constantized |
jobs |
app/jobs, app/workers, app/sidekiq, app/mailers and app/channels parsed for classes and their public methods, plus any class elsewhere under app/ whose parent chain reaches ActionMailer::Base |
i18n |
config.i18n.available_locales read from config/, or every top-level key across config/locales when the app never assigns it; the default locale read from config/. The backend and the fallbacks stay in the answer and are declared unanswered, being facts about the running process |
api |
every detection but the mode is a file read and runs unchanged; config.api_only comes from the assignment in config/application.rb |
engines |
the mounts in config/routes.rb and the files it draws, plus the in-repo engines found in the tree; the loaded Rails::Engine classes are declared unavailable |
active_support |
concerns, message verifier and encryptor use, notification subscriptions and a TaggedLogging initializer read from source; deprecators, load hooks and the cache store are declared unavailable |
middleware |
app/middleware, lib/middleware, and the stack changes in config/initializers, config/environments and config/application.rb; the booted stack and its count are declared unavailable |
runtime-only (8) report { unavailable: reason } here, and only these:
conventions, database_stats, config, initializers, autoload,
connection_pool, security and observability. The fallback message comes
from Introspector#run_introspector (lib/rails_ai_context/introspector.rb)
and is the same whatever put the gem in the static tier.
doctor never enters the static tier - its job is diagnosing why boot failed,
so it always requires a bootable app.
Boot failure degrades serve/tool to the static tier automatically (proven
across five boot-failure modes - raises, prints to stdout, writes via the
STDOUT constant, calls exit/abort, and hangs past the timeout - in
spec/e2e/boot_resilience_spec.rb); --no-boot forces it without attempting a
boot at all (spec/e2e/static_tier_spec.rb).
The exit mode is the standalone binary only. The rake tasks boot inside the
app's own process, where exit is that process deciding to stop, so
rails ai:serve on an app that aborts stops with it.
Confidence vocabulary
Source: lib/rails_ai_context/confidence.rb.
| Tag | Meaning |
|---|---|
[VERIFIED] |
Runtime-confirmed value, or a static literal the AST resolves with certainty |
[STATIC] |
Derived from source files without a booted app - trustworthy structure, not runtime-confirmed |
[INFERRED] |
Heuristic: a dynamic expression, metaprogramming, or a runtime-only construct the AST can't fully resolve |
[UNAVAILABLE: reason] |
The data source is absent entirely |
[VERIFIED]/[INFERRED] are per-value tags applied inside the schema, model,
route, controller, and mailbox-routing introspectors as they walk the AST
(Confidence.for_node). [UNAVAILABLE] is a whole-response tag, marking a
section with no static path, or (in either tier) a data source that genuinely
doesn't exist for this app. It also marks a single row: a model or controller
whose file could not be read is named with the reason, in the generated files
and the tool listings alike, rather than rendered as an entry that declares
nothing. The count above the row still includes it, because the app has it.
[STATIC] marks the whole response of any answer that came from the static
tier, and it also caps the records inside it: no record can claim more than the
tier carrying it, so a static model entry's associations, validations, scopes
and methods read [STATIC] rather than the [VERIFIED] the AST walk would
give them on its own. A record the parser could not resolve keeps its lower
[INFERRED]. The same cap decides the header of a hydrated Schema Hints block.
A generated context file says the same thing. A run that did not boot writes a
[STATIC] line under the header of every file that states app counts
(CLAUDE.md, AGENTS.md, the Copilot instructions, the split AGENTS.md pair and
every per-tool rules file except the MCP tool reference, which lists the gem's
own tools in either tier), and .ai-context.json carries the tier as a tier key
reading static or booted. A booted run writes no notice into the markdown
files, so a file that states app counts and carries none came from a booted
run; .ai-context.json names its tier either way.
A model's file in .ai-context.json is app-relative, except for a model a
gem owns: that value begins gem: and the rest of it starts with the gem's own
directory name, as in gem:doorkeeper-5.9.5/app/models/access_grant.rb. It is
relative to the directory the gems are unpacked under, not to the app root, so
a consumer must not join it to the app root. The gem's own readers ask
PortablePath.resolve, which answers both shapes.
Shape matrix
Rows are app shapes; columns are schema, models, routes, and controllers
(the four whose static behavior varies by shape), plus views.
n/a means the shape doesn't change that introspector's behavior - see
the stock full-stack row for its baseline proof. Reference numbers point at
the proof list below the table.
| Shape | Schema | Models | Routes | Controllers | Views |
|---|---|---|---|---|---|
Stock full-stack (schema.rb) |
runtime + static [1] | runtime + static [1] | runtime + static [1] | runtime + static [1] | runtime [2] + static [8] |
| API-only | runtime + static [1] | runtime + static [1] | runtime + static [1] | runtime + static [1] | runtime, "not applicable" [3] |
structure.sql - PostgreSQL dialect |
static [4] | n/a | n/a | n/a | n/a |
structure.sql - MySQL/Trilogy dialect |
static [4] | n/a | n/a | n/a | n/a |
structure.sql - SQLite dialect |
static [4] | n/a | n/a | n/a | n/a |
Multi-DB solid_* schema dumps |
static, secondary_databases [5] |
n/a | n/a | n/a | n/a |
Packwerk packs/ |
n/a | static [5] | n/a | n/a | n/a |
In-repo engines/ |
n/a | n/a | n/a | static [5] | n/a |
In-repo plugins/, modules/, gems/plugins/ |
n/a | static [5] | n/a | static [5] | n/a |
| Mongoid | [UNAVAILABLE], honest signal [6] |
static (fields + embeds) [6] | n/a | n/a | n/a |
| Broken-boot (any full-stack app) | static [7] | static, per-file isolation [7] | static [7] | static [7] | static [8] |
Packs + engines under --no-boot |
n/a | static [5] | n/a | static [5] | n/a |
| Empty/greenfield app (no scaffold) | graceful, no crash [9] | graceful, no crash [9] | graceful, no crash [9] | graceful, no crash [9] | graceful, no crash [9] |
| Massive app (500 models/tables) | no crash at scale [10] | no crash at scale [10] | no crash at scale [10] | n/a | n/a |
Proof sources:
spec/e2e/in_gemfile_install_spec.rb,standalone_install_spec.rb,zero_config_install_spec.rb(schema, routes, model_details, and controllers exercised across the install-path suite - in-Gemfile, standalone, zero-config); real Rails 8.0 apps in the v5.14.0 release QA (blog,sandbox); a large open-source app (Rails 8.1, Ruby 3.4) in the v5.25.0 release QA, booted and static tiers, standalone and in-Gemfile installs; the same app again in the v5.26.0 release QA, with packs, in-repo engines, Postgres and concurrent tool calls covered by hand where no lab shape plants them; a private Rails 8.0.5.1 API-only app (Ruby 3.4.10, 133 models, 102 controllers, 1630 ActiveInteraction services, 516 Sidekiq workers) in the v5.27.0 QA round, booted and static tiers, with every report rebuilt on a minimal Rails 8.0.5.1 fixture before it was filed. In the v5.30.0 release QA: 12 open-source apps in the static and failed-boot tiers, and the same private app in every tier and both install paths. In the v5.30.1 release QA: the same private app on PostgreSQL 17 with a partitioned table added, booted and static from schema.rb and structure.sql, a Rails 7.2 app whose schema.rb carries no partition marker, and four of the open-source apps answering as before. In the v5.30.2 release QA: 14 real apps answered by v5.30.1 and by the release branch side by side, and the lab regressions on SQLite in all three tiers, PostgreSQL and MySQL. In the v5.31.0 release QA: a private Rails 8.0 API app booted and static, and eight of the open-source apps answered by v5.30.2 and by the release branch side by side, every changed answer checked against the app's source; the lab regressions on SQLite in all three tiers, PostgreSQL and MySQL; and the unit suite on Ruby 3.1 with Rails 7.0. In the v5.32.0 release QA: the same private app booted and static, and 13 open-source apps answered by v5.31.0 and by the release branch side by side, every changed answer checked against the app's source; 23 small apps built one per project shape (API-only, no Active Record, Mongoid, Sequel, multi-database, engine root and dummy, packs, monorepo, multi-tenant, GraphQL, view-heavy, Rails 6.1 and 7.0, Sorbet, Sinatra and a plain gem); the lab regressions on SQLite in all three tiers, PostgreSQL and MySQL; and the unit suite on Ruby 3.1 with Rails 7.0 through Ruby 4.0 with Rails 8.1. In the v5.32.1 release QA: a PostgreSQL 17 lab on Rails 8.1 and 8.0 with a two-schema search path, answered booted and static from schema.rb and structure.sql side by side; the small apps and the same private app answering as v5.32.0 did; and the unit suite on Ruby 3.1 with Rails 7.0 through Ruby 4.0 with Rails 8.1. In the v5.32.2 release QA:rails_queryon the same private app's PostgreSQL database, with comment markers inside string literals, E strings and dollar quotes, a#>>that starts a line, and every spelling of a row limit; the installer's cleanup on a small Rails 8.1 app holding hand-written rule files; and the unit suite on Ruby 3.1 with Rails 7.0 through Ruby 4.0 with Rails 8.1. In the v5.33.0 release QA: the release gem in a fresh app on Ruby 3.1 with Rails 7.0 and 7.1, Ruby 3.2 with Rails 8.0, and Rails 8.1 on Ruby 3.3, 3.4 and 4.0, each through the standalone and in-Gemfile installs, the rake tasks and all 45 of that release's tools over MCP stdio; a Rails 8.1 app reached through a symlinked path, as macOS spells its temporary folders; a Rails 8.1 and a Rails 7.0 app mounting the engine, six clients calling while a model changed every 0.3 seconds; the pre-commit hook installed and run by real commits in an app's own repository, a monorepo's folder and a submodule; and the unit suite and the E2E harness on Linux, macOS and Windows. In the v5.34.0 release QA: the 21 apps of the v5.33.0 acceptance run (12 open-source apps, a private app and the small blogs), 11 booted and 10 static, their answers compared with v5.33.0's, and their strong params and model tables compared again after the last fix; a fresh Rails 8.1 and a fresh Rails 7.0 app through the install,ai:contexttwice,zeitwerk:checkwith the engine mounted, MCP over the mounted HTTP endpoint and stdio, and the standalone CLI; an app that loads a gem beforebundler/setup; and the unit suite and the E2E harness on Linux, macOS and Windows. In the v5.34.1 release QA: the gem built from the release commit and its file list read, and the unit suite and the E2E harness on Linux, macOS and Windows; the code is v5.34.0's. In the v5.35.0 release QA: every copy-paste prompt indocs/PROMPTS.mdpasted into Claude Code (claude -p) and Codex CLI (codex exec) and the result checked on disk, 40 scenarios in fresh Rails 8.1 apps, an app that cannot boot, a mountable engine, a Docker container and a copy of a private production app; each AI tool's MCP config started the way its client starts it; and the unit suite and the E2E harness on Linux, macOS and Windows. In the v5.35.1 release QA:rails_querythrough its tool call on real PostgreSQL 16, MySQL 8.0 and 8.4 and MariaDB 10.6 and 11.4 servers, through mysql2 and Trilogy, on Rails 7.0, 8.0 and 8.1, now a CI job of its own; files of statements sent through the tool on each server and on SQLite before and after each fix, with every changed answer traced to its fix; the tool's own checks timed on Ruby 3.4 for statements up to a megabyte and on Ruby 3.1 for statements of 50 kilobytes; and the unit suite and the E2E harness on Linux, macOS and Windows. In the v5.35.2 closing review: the statement files of the v5.35.1 QA sent through the tool again on PostgreSQL 16, MySQL 8.4, MariaDB 11.4 and SQLite, on the v5.35.1 code and on the fixed code, with every changed answer traced to its fix; and for each fix an example that fails on the v5.35.1 code, on a real server where the fix is a server's. In the v5.36.0 release QA: for each fix an example that fails on the v5.35.2 code, on a real server where the fix is a server's;rails_querythrough its tool call in CI on PostgreSQL 16, MySQL 8.0 and 8.4 and MariaDB 10.6 and 11.4 on Rails 7.0 (mysql2), 8.0 and 8.1 (mysql2 and Trilogy), and on PostgreSQL 13, 14, 15, 17 and 18 on Rails 8.0; and the unit suite and the E2E harness on Linux, macOS and Windows. In the v5.36.1 release QA: every issue repro of 5.36.0 run again on the published code on PostgreSQL 13 to 18, MySQL 8.0 and 8.4 and MariaDB 10.6 and 11.4, the booted and static mailer lists compared on one app, and the unit suite and the E2E harness on Linux, macOS and Windows. In the v5.36.2 release QA: each fix's example failing on the v5.36.1 code; the issues' repros run through the CLI on a minimal app on the v5.36.1 code and on the fix; the static answers of 13 corpus apps compared between the two; MariaDB 10.6 and 11.4 through mysql2 built against MariaDB's client library on Linux;rails_runtime_infoagainst a real Sidekiq and Redis; and the unit suite and the E2E harness on Linux, macOS and Windows. In the v5.36.3 release QA: each fix's example failing on the v5.36.2 code; the issues' repros run through the CLI on a minimal app, and on copies of two large apps (both in the static tier, one also booted) against counts taken from their files; the introspector, tool and integration specs on Ruby 3.1 with Rails 7.0 and 7.2, Ruby 3.3 with Rails 7.1, Ruby 3.4 with Rails 8.0 and 8.1, and Ruby 4.0 with Rails 8.1; and the unit suite and the E2E harness on Linux, macOS and Windows. In the v5.36.4 release QA: the update check run as the installed binary on Ruby 3.1, 3.3, 3.4 and 4.0 against rubygems.org, through a terminal and over MCP stdio on a fixture app, a large private app booted and another in the static tier, with the app's pinned json and net-http still loaded; the suite on prism 1.4.0 and on prism main under Ruby 3.2 and 3.4; and the unit suite and the E2E harness on Linux, macOS and Windows. In the v5.36.5 release QA: each fix's example failing on the code before it; the advicerails_migration_advisorwrites run as real migrations under strong_migrations 1.8 to 2.8 and Active Record 7.0 to 8.1 against PostgreSQL 13, 16 and 17, MySQL 8.0 and 8.4, MariaDB 10.6 and 11.4 and SQLite, for bigint, integer, unsigned, serial and uuid keys; its answers on three corpus apps in the static tier; the unit suite on Ruby 3.1 to 4.0 with Rails 7.0 to 8.1 on macOS, with seeds 1, 27377 and 90210 and defined order; and the unit suite and the E2E harness on Linux, macOS and Windows. In the v5.36.6 release QA: each fix's example failing on the code before it; the models section (associations, validations, enums, scopes and concerns of every model) and the answers ofrails_migration_advisor,rails_dependency_graph,rails_analyze_featureandrails_performance_checkon eleven open-source and private apps, one of them on Mongoid, in the static tier, compared with v5.36.5 and every changed answer checked against the app's source; and the unit suite and the E2E harness on Linux, macOS and Windows. In the v5.36.7 release QA: each fix's example failing on the code before it; therails_querygate and its reads run against live PostgreSQL 17, MySQL 9.6 and MariaDB 13 for the dollar-quote and--comment readings, with each server confirmed to read the text the way the gate does; the security, gate, redaction and database specs on Rails 7.0 (Ruby 3.1), 7.1, 7.2 and 8.1 against live PostgreSQL; and the unit suite and the E2E harness on Linux, macOS and Windows. In the v5.36.8 release QA: each fix's example failing on the code before it;rails_read_logsagainst Active Record 8.0's own logs from live PostgreSQL 16, MySQL 8.4 and MariaDB 13 for inline values, binds and the errors that repeat a row, and against a JSON logger's lines; the static-tier settings reader,rails_search_code,rails_get_configandrails_review_changeson five open-source apps and two private ones with--no-boot, compared with v5.36.7 and every changed answer checked; the touched specs on Rails 7.0 (Ruby 3.1) and on prism 1.4; and the unit suite and the E2E harness on Linux, macOS and Windows. In the v5.36.9 release QA: each fix's example failing on the code before it;doctor's plugin checks against what Claude Code 2.1.308 and 2.1.309 do with the same settings (marketplaces, enabled plugins, server command and URL rules, managed files andmanaged-mcp.json), and the plugin's launcher under Ruby 2.6 and 3.1;rails_read_logsandrails_queryon plural secret names and code lists, compared with v5.36.8 in a booted SQLite app; the QA ladder on the SQLite labs in every tier, the E2E harness and one large open-source app, booted; the unit suite on Ruby 3.4 and on Rails 7.0 (Ruby 3.1); and the unit suite and the E2E harness on Linux, macOS and Windows. In the v5.37.0 release QA: each fix's example failing on the code before it; every answer that changed from v5.36.9 on copies of three large open-source apps, three small sample apps and one private app, static and booted, read against the app's source and this changelog; the schema replay against what PostgreSQL 16, MySQL 8.4, MariaDB 11.4 and SQLite build and Rails dumps from the same migrations; static routes against a live RouteSet on Rails 7.0 and 8.1; views against booted Rails, and constant lookups against Ruby's own; the QA ladder's regression checks in the booted, static and failed-boot tiers and its answer checks; the security advisories' repros; the E2E harness; the unit suite at seeds 1, 27377 and 90210 and in defined order, and on Ruby 3.1 to 4.0 with Rails 7.0 to 8.1; and the unit suite and the E2E harness on Linux, macOS and Windows. In the v5.38.0 release QA: each fix's example failing on the code before it; the migration replay's key order against SQLite on Rails 7.0 to 8.1 and against PostgreSQL 16, MySQL 8.4 and MariaDB 10.6 and 11.4, with the advisor's rollbacks run on each; generated tests run in RSpec and minitest apps with and without factories and fixtures, booted and static, over thirty model shapes; the source redaction over a corpus of more than a thousand credential forms and its cost on Ruby 3.1 and 3.4; the plugin's router on folders of apps, a non-app folder and the gem's own checkout; every answer on one private app and copies of three large open-source apps, booted and static; the unit suite at seeds 1, 27377 and 90210 and in defined order, on Ruby 3.1 to 4.0 with Rails 7.0 to 8.1; and the unit suite and the E2E harness on Linux, macOS and Windows.- Non-crash coverage for every built-in tool including
get_viewinspec/e2e/in_gemfile_install_spec.rb's full-tool sweep; output correctness (ivar cross-check, render-form detection, partial interfaces) verified against the realblogapp in the v5.14.0 release QA. - Unit specs for the "not applicable" API-only messaging:
get_view_spec.rb,get_component_catalog_spec.rb,get_turbo_map_spec.rb,get_partial_interface_spec.rb,get_stimulus_spec.rb,get_api_spec.rb; real Rails 8--apiapp (api_app) in the v5.14.0 release QA. spec/lib/rails_ai_context/introspectors/schema_introspector_spec.rb("with a valid structure.sql fixture", "with a MySQL (mysqldump) structure.sql", "with a SQLite structure.sql"). Unit-proven only - no committed e2e fixture writes astructure.sqlfile. The MySQL/Trilogy adapter itself (live queries, not the dump format) was also run for real against MySQL 9.6 in the v5.14.0 release QA (storeapp,schema_format = :sql).spec/e2e/shapes_spec.rb: "packs and engines code discovery (static tier)" and "multi-database schema dumps".spec/e2e/shapes_spec.rb: "Mongoid app (bare directory, --app-path, no boot possible)".spec/e2e/boot_resilience_spec.rb(five boot-failure modes: raises, prints to stdout, writes via theSTDOUTconstant, callsexit/abort, hangs past the timeout) plusspec/e2e/static_tier_spec.rb("broken-boot app over the CLI", "broken-boot app over MCP stdio", "syntax error in one model file").viewsis files-only (lib/rails_ai_context/introspectors/view_introspector.rb), so itscallruns unchanged with nothing booted, proven againstspec/fixtures/static_appinspec/lib/rails_ai_context/introspectors/static_tier_declarations_spec.rb.Introspector#run_introspectorreports{ unavailable: reason }only for the eight runtime-only sections, regardless of shape.spec/e2e/empty_app_spec.rb- all 35 built-in tools swept against an app with no scaffold, no models, no controllers beyondApplicationController, no routes beyond root.spec/e2e/massive_app_spec.rb-schema,model_details, androutes(pluscontext,onboard,analyze_feature,get_turbo_mapand theenvsection ofget_config) sampled against 500 generated models/tables under a 180-second cap.
rails_query, rails_runtime_info and the row estimates run on three
database families: PostgreSQL (with PostGIS), MySQL (mysql2 and Trilogy, with
MariaDB) and SQLite. rails_query refuses any other adapter (SQL Server,
Oracle, CockroachDB) by name before sending a statement, and the table sizes,
row estimates and index usage say they are not available there. CI's
database_servers job runs the families against PostgreSQL 16, MySQL 8.0 and
8.4 and MariaDB 10.6 and 11.4 on Rails 8.0, through mysql2 and Trilogy. It also
runs PostgreSQL 16 with MySQL 8.0 and MariaDB 10.6 on Rails 7.0, which has no
Trilogy adapter, and with MySQL 8.4 and MariaDB 11.4 on Rails 8.1; see
CONTRIBUTING.md. The
postgresql_versions job runs the PostgreSQL examples on PostgreSQL 13, 14,
15, 17 and 18 as well, since the plan checks read the plan the server prints and
its shape changes between major versions.
Postgres itself - live query safety (read-only transactions, the
BLOCKED_FUNCTIONS guard against pg_read_file/dblink/COPY ... PROGRAM,
DDL rejection) alongside schema/routes - is proven end-to-end against a real
Postgres instance in spec/e2e/postgres_install_spec.rb, opt-in via
TEST_POSTGRES=1 and skipped otherwise.
Known limits
- The static tier inflects with the rules the initializers write down.
Rails derives a table name, a route name and a class name through the app's
inflections. Without a boot, a run reads the
ActiveSupport::Inflector.inflections(:en)calls underconfig/initializers(acronym,plural,singular,irregular,uncountable,human) and applies those whose arguments are literals, for that run only. A rule built from a computed value, one for another locale, or one a gem registers is not applied. The booted tier uses the app's own inflector. - What a model inherits, and from where. The model answer walks the model
file, the files of the concerns it includes, and the files of every class it
inherits from up to
ActiveRecord::Base, in both tiers. An abstract base is in that chain: a per-connectionAnalytics::Record, and the app's ownApplicationRecord, pass their concerns, scopes, callbacks and macros down the way Rails does. Only the table stops at an abstract base, because a child of one has its own. A concern's macro is tagged with the concern that declared it. Both tiers leave out a superclass whose file is not under one of the app's model directories, a gem-owned base for example. The booted tier could read that file and deliberately does not, because an answer the static tier can never match is two answers to one question. The static tier ends the walk at such a base; the booted tier steps over it and still reads an app base above it. Reflection still carries such a base's associations, validations and enums onto the child. The presence validation Rails adds for a requiredbelongs_tois listed in both tiers as implicit; without a boot it is worked out fromoptional:/required:andbelongs_to_required_by_default. A concern whose file cannot be found, a gem's module for example, is named underConcernsas not read. - Callbacks are what the file declares. In both tiers the callback list is
what the model file, the app concerns it includes and the classes it inherits
from declare. A callback a gem registers on include without an
includeline in the model file (Devise, counter caches, attachment purges) is not listed. Within one type the order is the order Rails runs them: base classes first, a concern's callbacks where itsincludeline stands, then the model's own, and abefore_oraround_callback withprepend: truefirst.after_commitandafter_rollbackrun last declared first unlessrun_after_transaction_callbacks_in_order_definedis on (load_defaults 7.1or later; Rails 7.0 has no such setting). The booted tier reads the setting; the static tier readsload_defaultsand any assignment inconfig/application.rbor an initializer, the last line that sets it winning, and when those do not say, keeps declaration order and the answer says so. The static tier does not readconfig/environments/*.rbfor this setting or forbelongs_to_required_by_default, so a value set per environment is missed. The order across types is Rails' event order. - Concern-style Mongoid documents in runtime results. Mongoid documents
are invisible to ActiveRecord reflection, so
ModelIntrospector#callfalls back to the same source-parsing pass used in the static tier even when the app is booted (lib/rails_ai_context/introspectors/model_introspector.rb). A concern's fields, embedded relations,store_inand indexes are read. Abelongs_to/has_many, validation, scope or callback declared in a concern rather than in the document class body is not resolved, in either tier. - Static-tier API-only detection.
RailsAiContext::StaticAppexposes noconfigmethod, so in the static tierTools::BaseTool.api_only_app?reads theconfig.api_only = trueassignment inconfig/application.rb(AppKind.api_only?). A value set anywhere else, or one that is not a literaltrue, is missed, and the "not applicable" messaging for view/frontend tools then only fires once the app has booted. - Live multi-DB connections are not iterated, only dumps.
MultiDatabaseIntrospectorreadsconfig/database.ymland the model source in the static tier, so databases, replicas, sharding andconnects_todeclarations come from the files rather than from the live connection handlers. A database only a runtimeconnects_tonames is missed. - Rails 7.0 merges same-name enum types. On Rails 7.0, two PostgreSQL enum types with one name in different schemas come back from the connection as one type holding both label lists; the static tier keeps the first type's labels.
- A schema.rb from before Rails 8.1 does not say which schema holds a
table. It names each table bare, whichever search path schema holds it,
so without a boot a schema-qualified name such as
app.usersfinds a listed table only when the search path has one schema. A Rails 7.0 schema.rb names no schema at all, so the static tier takes every schema the search path names as existing, except the one"$user"stands for. - A dump made with a
schema_search_pathholds only those schemas. By default Rails passes each search path schema to pg_dump, so structure.sql holds no other schema's tables and no extension, and a Rails 8.1 schema.rb holds no other schema's tables. Without a boot those tables are not found and the extension list is plpgsql alone.config.active_record.dump_schemas = :alldumps everything. - Inherited controller actions and filters are resolved by parent name, so
some walks end early. Every controller also takes the actions of each app
ancestor the listing holds, up to the app's base controller, walked through the
parent_classeach entry carries, and the filter chain is walked the same way. A superclass spelled relatively inside amodulebody (module Settings; class ProfileController < BaseController) is resolved against the enclosing namespace first, the way Ruby resolves it. A gem-owned parent such asOAuth::AuthorizationsController < Doorkeeper::AuthorizationsController, which the payload cannot hold, still ends the inherited-actions walk. The filter walk skips that class's own filters: for a Devise or Doorkeeper parent it resumes at the base class the gem's initializer names, or the gem's default, and for any other gem-owned parent it ends. A booted run answers those. ApplicationControlleris not in the listing, but its filters are in the chain. Every app has one and it would sit in every listing row, so the controller listing leaves it out. The chain walk reads its file anyway, by the one name Rails fixes, so a filter it declares is attributed to it in both tiers with itsonly:/except:/if:intact. A parent the listing does not hold and the app has no file for still ends the walk: reconstructing a path from a class name breaks on an app inflection, and a gem-owned parent has no file under the app root at all (a Devise or Doorkeeper one resumes at its configured base, as above). A booted run answers that one from reflection.- Some route macros surface as a dynamic tally, not resolved entries.
RouteIntrospector#static_callcounts routes behinddevise_for, amatchwhosevia:it cannot read, adrawit cannot read, a block drawn through an app class whose prefix is not a literal (and that class's ownresources), a route whoseto:is a lambda or aredirect(...), a mount of a lambda or a variable, a resource whose options are a variable or a splat, a call it does not know (load,use_doorkeeper,ActiveAdmin.routes(self)) and a call to a route-file method that takes arguments into adynamic_routescount rather than fabricating per-route controller/action pairs it can't actually determine from source. Routing concerns, awith_optionsblock that takes no block parameter and a route-file method called without arguments are expanded, so they are not in that count;directandresolvedraw no route and are not counted either. Awith_optionsthat yields a mapper is in it, and so is aconcerns:naming a concern defined in another drawn file.constraints do...endand any other block that draws routes do not hide their children: they are read and resolved, and a literal hash constraint ordefaults:is listed beside the route asbin/rails routesprints it, with any extra option Rails reads as a default. A route underif/unlessor acasebranch is listed with that condition, since source cannot tell whether it holds. Routes an initializer underconfig/initializersadds withRails.application.routes.prependor.appendare read, before and after the draw as Rails orders them. Routes Rails' own engines and gems draw into the app's table are read only with the app booted. - A folder of apps is served from the folder.
initrun in a folder of apps writes entries that name each app relative to that folder, because Claude Code, Codex, OpenCode, Copilot CLI and Cursor start a server in the folder they were launched in (the Cursor editor, in the project's folder), and only VS Code expands${workspaceFolder}. A tool launched inside one of the apps still reads the folder's config above it, and its servers then start where the relative path misses. Every server in the folder also runs under the Ruby the version manager picks in the folder, so an app that declares another may answer from the static tier, and itsbundle execentry may not start;initwarns when it sees one.spec/e2e/workspace_install_spec.rbstarts each entry the way its tool does, on a standalone and an in-Gemfile app. ADR-0005 records the per-tool facts. - A file saved twice within one second at the same size can reload as the
first save. Bootsnap's compile cache, which
config/boot.rbsets up in a new Rails app, knows a Ruby file by its size and the second of its mtime. A file written again within that second at the same size gets the code compiled from the earlier write back from it: on the reload the write sets off, and in every process that loads the file after, the app's own server and tests included, until the file is written in another second. Bootsnap has no way to drop one entry, and the server does not touch the app's files to move their stat, so a server that reloads code names such a file at the end of every answer (under_stale_codein a JSON body), from the call that sees the save until the file is saved again. What reflection reads, such as associations and enums, may be the earlier save's meanwhile. Saving the file again a second later puts the edit through, andDISABLE_BOOTSNAP_COMPILE_CACHE=1in the server's environment turns the compile cache off for that process.