rails-ai-context 5.32.2 → 5.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1491 -0
- data/CONTEXT.md +27 -3
- data/README.md +16 -12
- data/Rakefile +6 -0
- data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
- data/config/routes.rb +2 -3
- data/docs/ARCHITECTURE.md +7 -5
- data/docs/CLI.md +79 -6
- data/docs/COMPATIBILITY.md +75 -1
- data/docs/CONFIGURATION.md +30 -11
- data/docs/CUSTOM_TOOLS.md +2 -1
- data/docs/FAQ.md +16 -6
- data/docs/GUIDE.md +117 -71
- data/docs/INTROSPECTORS.md +10 -8
- data/docs/QUICKSTART.md +3 -3
- data/docs/RECIPES.md +1 -1
- data/docs/SECURITY.md +109 -25
- data/docs/SETUP.md +30 -6
- data/docs/STANDALONE.md +10 -3
- data/docs/TOOLS.md +152 -34
- data/docs/TROUBLESHOOTING.md +90 -12
- data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
- data/docs/adr/0005-workspace-mcp-entries.md +39 -0
- data/exe/rails-ai-context +733 -104
- data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
- data/lib/rails_ai_context/app_kind.rb +18 -5
- data/lib/rails_ai_context/ast_cache.rb +7 -0
- data/lib/rails_ai_context/base_mixins.rb +8 -8
- data/lib/rails_ai_context/boot_manager.rb +22 -3
- data/lib/rails_ai_context/change_watch.rb +14 -11
- data/lib/rails_ai_context/cli/app_root.rb +401 -0
- data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
- data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
- data/lib/rails_ai_context/code_reloader.rb +115 -1
- data/lib/rails_ai_context/concern_membership.rb +3 -1
- data/lib/rails_ai_context/concern_paths.rb +8 -3
- data/lib/rails_ai_context/configuration.rb +41 -9
- data/lib/rails_ai_context/context_file_report.rb +19 -2
- data/lib/rails_ai_context/database_file.rb +26 -0
- data/lib/rails_ai_context/doctor.rb +1122 -184
- data/lib/rails_ai_context/engine.rb +18 -1
- data/lib/rails_ai_context/erb_source.rb +19 -0
- data/lib/rails_ai_context/executable.rb +21 -0
- data/lib/rails_ai_context/facts_formatter.rb +5 -2
- data/lib/rails_ai_context/file_walk.rb +61 -10
- data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
- data/lib/rails_ai_context/fingerprinter.rb +40 -12
- data/lib/rails_ai_context/gem_lock.rb +186 -27
- data/lib/rails_ai_context/git_ignore.rb +22 -0
- data/lib/rails_ai_context/install/ai_tool.rb +31 -5
- data/lib/rails_ai_context/install/cleanup.rb +21 -0
- data/lib/rails_ai_context/install/program.rb +209 -35
- data/lib/rails_ai_context/install/selection_record.rb +258 -97
- data/lib/rails_ai_context/install/surface.rb +6 -1
- data/lib/rails_ai_context/install/validation_hook.rb +282 -0
- data/lib/rails_ai_context/install/workspace.rb +166 -0
- data/lib/rails_ai_context/install_mode.rb +71 -13
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
- data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
- data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
- data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
- data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
- data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
- data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
- data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
- data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
- data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
- data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
- data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
- data/lib/rails_ai_context/live_reload.rb +20 -20
- data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
- data/lib/rails_ai_context/mcp_edge.rb +182 -32
- data/lib/rails_ai_context/middleware.rb +5 -1
- data/lib/rails_ai_context/migration_status.rb +88 -25
- data/lib/rails_ai_context/output_guard.rb +48 -2
- data/lib/rails_ai_context/package_json.rb +1 -1
- data/lib/rails_ai_context/path_resolver.rb +46 -13
- data/lib/rails_ai_context/payload.rb +92 -8
- data/lib/rails_ai_context/pending_migrations.rb +5 -1
- data/lib/rails_ai_context/portable_path.rb +3 -2
- data/lib/rails_ai_context/presets.rb +15 -11
- data/lib/rails_ai_context/redaction.rb +262 -51
- data/lib/rails_ai_context/resources.rb +55 -20
- data/lib/rails_ai_context/route_coverage.rb +8 -0
- data/lib/rails_ai_context/safe_file.rb +14 -1
- data/lib/rails_ai_context/safe_path.rb +101 -4
- data/lib/rails_ai_context/schema_adapter.rb +8 -0
- data/lib/rails_ai_context/schema_version.rb +9 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
- data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
- data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
- data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
- data/lib/rails_ai_context/server.rb +304 -71
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
- data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
- data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
- data/lib/rails_ai_context/tools/base_tool.rb +367 -46
- data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
- data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
- data/lib/rails_ai_context/tools/diagnose.rb +218 -11
- data/lib/rails_ai_context/tools/generate_test.rb +339 -39
- data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
- data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
- data/lib/rails_ai_context/tools/get_concern.rb +91 -27
- data/lib/rails_ai_context/tools/get_config.rb +13 -4
- data/lib/rails_ai_context/tools/get_context.rb +71 -14
- data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
- data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
- data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
- data/lib/rails_ai_context/tools/get_env.rb +18 -9
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
- data/lib/rails_ai_context/tools/get_gems.rb +1 -1
- data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
- data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
- data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
- data/lib/rails_ai_context/tools/get_routes.rb +29 -8
- data/lib/rails_ai_context/tools/get_schema.rb +52 -19
- data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
- data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
- data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
- data/lib/rails_ai_context/tools/get_view.rb +14 -14
- data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
- data/lib/rails_ai_context/tools/onboard.rb +52 -8
- data/lib/rails_ai_context/tools/performance_check.rb +5 -3
- data/lib/rails_ai_context/tools/query.rb +914 -86
- data/lib/rails_ai_context/tools/read_logs.rb +69 -15
- data/lib/rails_ai_context/tools/review_changes.rb +215 -53
- data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
- data/lib/rails_ai_context/tools/safe_call.rb +35 -14
- data/lib/rails_ai_context/tools/search_code.rb +243 -67
- data/lib/rails_ai_context/tools/search_docs.rb +4 -4
- data/lib/rails_ai_context/tools/security_scan.rb +143 -33
- data/lib/rails_ai_context/tools/session_context.rb +39 -5
- data/lib/rails_ai_context/tools/validate.rb +259 -40
- data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +67 -40
- data/lib/rails_ai_context/view_file.rb +41 -3
- data/lib/rails_ai_context/watcher.rb +51 -13
- data/lib/rails_ai_context.rb +21 -0
- data/server.json +1 -1
- metadata +14 -1
data/docs/INTROSPECTORS.md
CHANGED
|
@@ -181,10 +181,10 @@ It runs a single-pass Dispatcher that walks the AST once and feeds events to all
|
|
|
181
181
|
|:---------|:---------------|
|
|
182
182
|
| AssociationsListener | `belongs_to`, `has_many`, `has_one`, `has_and_belongs_to_many`, the polymorphic `belongs_to` of `delegated_type`, the `belongs_to` of `acts_as_tenant` (tenant defaults to `:account`), under the options of an enclosing `with_options` block |
|
|
183
183
|
| ValidationsListener | `validates`, `validates_*_of`, custom `validate :method`, under the options of an enclosing `with_options` block |
|
|
184
|
-
| ScopesListener | `scope :name, -> { ... }`, `lambda { ... }` and the block form |
|
|
184
|
+
| ScopesListener | `scope :name, -> { ... }`, `lambda { ... }` and the block form; a scope declared in an `each` over a literal list, inline or a constant the file assigns (`KINDS.each { \|k\| scope k, -> { ... } }`), as one scope per element, tagged `[INFERRED]` |
|
|
185
185
|
| EnumsListener | Rails 7+ and legacy enum syntax, prefix/suffix options |
|
|
186
|
-
| CallbacksListener | All AR callback types including `around_*`, `after_touch`, `after_initialize` and `after_find`; `after_commit` with `on:` resolution; a callback object as the source wrote it (`Normalizer`, `Normalizer.new`), a block as `[inline_block]
|
|
187
|
-
| MacrosListener | (each attribute macro and `has_secure_password` with its keyword options as written, under `written`) `encrypts`, `normalizes`, `delegate`, `has_secure_password`, `serialize`, `store`, `has_one_attached`, `has_many_attached`, `has_rich_text`, `generates_token_for`, `attribute`, `alias_attribute`, `store_accessor`, `self.ignored_columns`, `has_secure_token`, `accepts_nested_attributes_for`, `attr_readonly`, `query_constraints`, `connects_to` as written (a child carries its base's, with the base's name under `declared_in`), the class settings `self.inheritance_column`/`store_full_sti_class`/`strict_loading_by_default`/`implicit_order_column`/`locking_column =` as written, an `aasm` block's states, initial state, events and transitions, and model gems' class macros (`GEM_MACROS`: `has_paper_trail`, `friendly_id`, `mount_uploader`, `monetize`, `pg_search_scope`, `acts_as_list` and others) as written, without their block |
|
|
186
|
+
| CallbacksListener | All AR callback types including `around_*`, `after_touch`, `after_initialize` and `after_find`; `after_commit` with `on:` resolution; a callback object as the source wrote it (`Normalizer`, `Normalizer.new`), a block as `[inline_block]` with its declaration as written; `if:`/`unless:` kept as the source wrote them, and the options of an enclosing `with_options` block; the commit callbacks turbo-rails' `broadcasts`, `broadcasts_to`, `broadcasts_refreshes` and `broadcasts_refreshes_to` declare, each with the method it runs; `before_`/`after_`/`around_<event>` on a chain `define_model_callbacks` makes, kept when the same file defines it (honouring `only:`), and for a chain a library defines elsewhere (Paperclip's `before_<attachment>_validate`, `after_post_process`) only when the model walk asks, so the booted tier keeps the ones whose chain the model's `__callbacks` holds |
|
|
187
|
+
| MacrosListener | (each attribute macro and `has_secure_password` with its keyword options as written, under `written`) `encrypts`, `normalizes`, `delegate`, `has_secure_password`, `serialize`, `store`, `has_one_attached`, `has_many_attached`, `has_rich_text`, `generates_token_for`, `attribute`, `alias_attribute`, `store_accessor`, `self.ignored_columns`, `has_secure_token`, `accepts_nested_attributes_for`, `attr_readonly`, `query_constraints`, the turbo-rails broadcast macros (`broadcasts`, `broadcasts_to`, `broadcasts_refreshes`, `broadcasts_refreshes_to`), `connects_to` as written (a child carries its base's, with the base's name under `declared_in`), the class settings `self.inheritance_column`/`store_full_sti_class`/`strict_loading_by_default`/`implicit_order_column`/`locking_column =` as written, an `aasm` block's states, initial state, events and transitions, and model gems' class macros (`GEM_MACROS`: `has_paper_trail`, `friendly_id`, `mount_uploader`, `monetize`, `pg_search_scope`, `acts_as_list` and others) as written, without their block (Paperclip's `has_attached_file` by its name and attachment only) |
|
|
188
188
|
| MethodsListener | `def`/`def self.`, visibility tracking, parameter extraction, `class << self`, the methods `delegate` and Forwardable's `def_delegators`/`def_delegator` define (public unless `private: true`, and with no `end_location`, since a delegation has no body), and the methods `alias`, `alias_method`, `attr_*`, `define_method` with a literal name, `class_attribute` and `cattr_*`/`mattr_*` define (an alias keeps its original's visibility and parameters; Active Support's accessors are public on the class and, unless an option turns them off, on instances); each `def` carries `offset`/`end_offset`, which `SourceIntrospector.outside_defs` pairs against a call's `offset` to tell a call on the same line as a def from one inside it; a `Struct.new`/`Class.new`/`Module.new`/`Data.define` block assigned to a constant is that constant's body, and a `def self.x` in `included do` is marked `class_methods_block` like a `def x` in `class_methods do`; `include_initialize: true` adds the constructor a caller reports on its own |
|
|
189
189
|
| MixinsListener | `include`, `prepend`, `extend`, `singleton_class.include` and `singleton_class.prepend` (`singleton_class.extend` is not read), flagging the ones that reach the ancestor chain, as reflection reports them. GitLab's `prepend_mod_with("Note")`, `include_mod_with`, `extend_mod_with` and their `_mod` forms record `EE::Note` and `JH::Note` flagged `edition`; `ConcernMembership.owned_by` keeps one only where the app has its file, and credits `Note.prepend X` to Note |
|
|
190
190
|
|
|
@@ -194,6 +194,7 @@ Passed to `SourceIntrospector.walk(path, key => Listener)` when a specific file
|
|
|
194
194
|
|
|
195
195
|
| Listener | What it detects |
|
|
196
196
|
|:---------|:---------------|
|
|
197
|
+
| ForeignEvalsListener | A class body's blocks evaluated on another receiver (`translation_class.class_eval do`, `other.instance_eval do`), outside any `def`, with every outermost call inside by the line it starts on, an `if`/`unless` condition left out. The model walk passes it beside the default listeners (`ModelIntrospector::MODEL_LISTENERS`) and moves what the blocks declare to "Declared on another class" |
|
|
197
198
|
| GenericMacroListener | Any receiver-less macro you name: `GenericMacroListener.new(:devise, :rate_limit)`; `any_receiver: true` also counts `base.macro`, as a mixin's `self.included(base)` hook writes it. Returns args, values (with a source-slice fallback), options, option values and option nodes, `proc_lines` (the line each block, `->`, `lambda {}`, `proc {}` or `Proc.new {}` argument opens on, as `Proc#source_location` gives it), plus the nesting: `parent_offset` is the offset of the target macro call whose block this one sits in, paired against each call's own `offset` rather than its line |
|
|
198
199
|
| ConditionalMacroListener | GenericMacroListener plus `condition`, the `if`/`unless`/`case` branches the call sits under (`BranchConditions`), as `if Rails.env.test?`. Controller filters read it through FilterMacroListener, so `before_action :x if Rails.env.test?` is listed with its condition |
|
|
199
200
|
| FilterMacroListener | ConditionalMacroListener plus `callbacks`, what each positional argument adds, named at its node: `[:name, x]` for a symbol, string or class, `[:object, Klass]` for an instance (`Class` for an anonymous class), `[:lambda]` for `-> {}` or `lambda {}`, `[:block]` for a proc, `[:unread, source]` for anything else. Used by `ControllerFilters`, so the static tier names each callback as the booted tier does |
|
|
@@ -217,7 +218,7 @@ Passed to `SourceIntrospector.walk(path, key => Listener)` when a specific file
|
|
|
217
218
|
| SchemaDslListener | `schema.rb`: `create_table`, `t.string`, `t.column`, `t.index`, `add_foreign_key`, `create_enum`, `create_schema`, `create_view`, `create_virtual_table`, the `ActiveRecord::Schema[x.y]` version stamp (a nil one for the unstamped `Schema.define` of Rails before 7.0), and the comment the dumper writes for a table it could not describe. Names drop `public.` unless `raw_names:` is set, which SchemaReader uses to name them against the search path |
|
|
218
219
|
| MigrationDslListener | Migration DSL: `create_table`, `add_column`, `add_index`, `add_reference`, and friends |
|
|
219
220
|
| MigrationReplayListener | What a replay needs beside the DSL: `def down`, `down`/`revert` blocks, and `t.timestamps` |
|
|
220
|
-
| RoutesDslListener | `config/routes.rb`, resolving namespace/scope/resources nesting into flat routes (a `controller:` with a leading slash is absolute, as in Rails); routing concerns (`concern` definitions replayed at each `concerns:` site), `with_options` defaults merged under each inner call, `match ... via:` (or the `via:` of an enclosing `scope`) as one route answering each verb it names (`GET|POST`, and `ANY` for `via: :all`, as the booted table has it), the controller a `scope(controller:)` or a route's own `controller:` names (with Rails' `a/b` shorthand when no action is given), and the `as:`, `param:`, `module:`, `path:` and `only:`/`except:` options. A block drawn through an app class (`ApiRouteSet::V1.draw(self) do`) takes the path and `as:` prefixes that class's `self.prefix` and `mapper_prefix` return as literals, or a literal prefix argument; a class whose prefix is not a literal, and that class's own `resources`, are counted as unexpanded. Routes drawn into an engine's table (`Spree::Core::Engine.routes.draw`) sit under the engine's namespace and carry `engine:`, which the route introspector files under that engine's mount, apart from the app's count. A file pulled in by `draw` is walked inside the scope its `draw` sits in, once per scope that draws it, so `draw :api` under `namespace :api` and `scope module: :v1` routes to `api/v1/...` at `/api
|
|
221
|
+
| RoutesDslListener | `config/routes.rb`, resolving namespace/scope/resources nesting into flat routes (a `controller:` with a leading slash is absolute, as in Rails); routing concerns (`concern` definitions replayed at each `concerns:` site), `with_options` defaults merged under each inner call, `match ... via:` (or the `via:` of an enclosing `scope`) as one route answering each verb it names (`GET|POST`, and `ANY` for `via: :all`, as the booted table has it), the controller a `scope(controller:)` or a route's own `controller:` names (with Rails' `a/b` shorthand when no action is given), and the `as:`, `param:`, `module:`, `path:` and `only:`/`except:` options. A block drawn through an app class (`ApiRouteSet::V1.draw(self) do`) takes the path and `as:` prefixes that class's `self.prefix` and `mapper_prefix` return as literals, or a literal prefix argument; a class whose prefix is not a literal, and that class's own `resources`, are counted as unexpanded. Routes drawn into an engine's table (`Spree::Core::Engine.routes.draw`) sit under the engine's namespace and carry `engine:`, which the route introspector files under that engine's mount, apart from the app's count. A file pulled in by `draw` is walked inside the scope its `draw` sits in, once per scope that draws it, so `draw :api` under `namespace :api` and `scope module: :v1` routes to `api/v1/...` at `/api/...`. A resource's `nested do` block draws under the parent's nested path, as a direct child would. A block from any other method draws in place only for `constraints`, `defaults`, Devise's `devise_scope`/`as`/`authenticate`/`authenticated`/`unauthenticated` and route_translator's `localized`; any other block that holds routes (an app's `api_v1 do`, versionist's `api_version`) may scope them in a way the walk cannot see, so its routes are left out and the block is counted once as unexpanded |
|
|
221
222
|
| MountListener | `mount Sidekiq::Web, at: "/sidekiq"`, the hash form, and a Rack app attached with `match "/metrics", to: MetricsApp` - `mount` is that call with a name derived. Paths carry the enclosing `namespace`/`scope` prefix; a scope whose own name is an expression yields no path rather than an unprefixed one, and `scope path: nil` adds no segment. A mounted app built by a call on a constant (`Flipper::UI.app(Flipper)`) is named by that call, arguments off. A mount under `if`/`unless`/`case` carries that `condition` |
|
|
222
223
|
| GemfileDslListener | `gem "name", "version"`, `group :development do ... end` and `eval_gemfile "path"` (a literal or `File.expand_path("x", __dir__)`) with the groups around it; `ruby "3.3.6"` with its `engine:` and `engine_version:`; an `:unknown_gems` entry for `gemspec` and for a `gem` or `eval_gemfile` whose argument is not a literal |
|
|
223
224
|
| RakeTaskDslListener | `namespace` (with the span its block covers), `desc`, `task`, `multitask` in `.rake` files |
|
|
@@ -317,7 +318,7 @@ Thread-safe parse cache using `Concurrent::Map`:
|
|
|
317
318
|
- A file whose stat (mtime, size, inode) matches the last read answers without reading or hashing it, but only once that file was already two seconds older than the read, so a same-size rewrite within one mtime tick is still read
|
|
318
319
|
- Bounded at 500 parses; a recorded stat is dropped with its parse
|
|
319
320
|
- Shared by all AST-based introspectors
|
|
320
|
-
- Cleared on `reset_all_caches!` (
|
|
321
|
+
- Cleared on `reset_all_caches!` (when a server sees the files change: live reload, or the check at each call)
|
|
321
322
|
|
|
322
323
|
### RunCache
|
|
323
324
|
|
|
@@ -329,13 +330,14 @@ Answers kept for one introspection run and dropped when it ends: the file list f
|
|
|
329
330
|
|
|
330
331
|
Introspection results are cached at three levels:
|
|
331
332
|
|
|
332
|
-
1. **Introspection cache** - Full context hash, invalidated by TTL (`config.cache_ttl`, default: 60s) and fingerprint
|
|
333
|
+
1. **Introspection cache** - Full context hash, invalidated by TTL (`config.cache_ttl`, default: 60s) and, in a server, as soon as the fingerprint changes
|
|
333
334
|
2. **AST cache** - Per-file parse results, invalidated by file content change (SHA256), with a stat shortcut for a file older than the read
|
|
334
335
|
3. **Run cache** - File lists, stats and directory answers for one introspection run, dropped when it ends
|
|
335
336
|
|
|
336
|
-
The **Fingerprinter**
|
|
337
|
+
The **Fingerprinter** reads one set of files: all watched directories (`app/`, `config/`, `db/`, `lib/`, `rakelib/`, `test/`, `spec/`), the Gemfile and `Gemfile.lock` (or `gems.rb` and `gems.locked`), `package.json`, `tsconfig.json`, `config.ru` and the Rakefile. When one of them changes, a server drops the introspection cache even if the TTL hasn't expired, and in the booted tier reloads the app's code first, so what reflection reads (an association, an enum) is current too, wherever the app can reload. Under `RAILS_ENV=test`, or in production with eager loading, `config.enable_reloading` is off and Rails keeps the code it booted with: what reflection reads stays as of boot (validations and callbacks are read off the source, so they still follow the edit), and once a file under the app's autoload paths changes, every answer ends with a note naming it and asking for a restart.
|
|
337
338
|
|
|
338
|
-
**
|
|
339
|
+
- **Every server** - stdio, HTTP, or the endpoints mounted inside the app - checks before each tool call and resource read, so an answer sees every edit made before the call began. `Fingerprinter::Snapshot` stats each watched directory and file and lists a directory again only when its stat moved: about a millisecond on a small app, about 30 ms at 10,000 files. Only a check that began after a call arrived stands in for that call's own, so calls one after another each check and calls made at once wait for one check together. Inside a request the app serves, Rails' own reloader has already reloaded the code.
|
|
340
|
+
- **Live Reload** (the `listen` gem: the app's bundle's, or for the standalone binary one installed beside it) watches these directories and notifies connected MCP clients via `notify_resources_list_changed`. It loads none of the app's code and drops no cache: a change reaches it after the 1.5-second debounce, and by then the call that followed the edit has checked the files itself.
|
|
339
341
|
|
|
340
342
|
---
|
|
341
343
|
|
data/docs/QUICKSTART.md
CHANGED
|
@@ -32,7 +32,7 @@ that is `--mcp-only`.
|
|
|
32
32
|
Three more prompts appear only when they apply:
|
|
33
33
|
|
|
34
34
|
- **Remove their generated files?** - on a re-run that drops a tool you picked before. Default: keep them. A yes removes only what the gem generated, never a rule file or a line you wrote.
|
|
35
|
-
- **Install a pre-commit hook that
|
|
35
|
+
- **Install a pre-commit hook that checks staged Ruby, ERB and JavaScript files for syntax errors?** - in a git repo that has no pre-commit hook yet. Default: no.
|
|
36
36
|
- **Delete them?** - in a terminal, when rule files left over from before v5.0.0 are found. Default: no.
|
|
37
37
|
|
|
38
38
|
`--defaults` skips every prompt and takes each default: all five tools, keep the files, MCP config + context files, no hook, keep legacy files. Every step is listed in [What the install generator does](GUIDE.md#what-the-install-generator-does).
|
|
@@ -77,7 +77,7 @@ The install generator created:
|
|
|
77
77
|
## Verify it works
|
|
78
78
|
|
|
79
79
|
```bash
|
|
80
|
-
# Check AI readiness
|
|
80
|
+
# Check AI readiness
|
|
81
81
|
rails ai:doctor # In-Gemfile
|
|
82
82
|
rails-ai-context doctor # Standalone
|
|
83
83
|
|
|
@@ -119,7 +119,7 @@ rails 'ai:tool[conventions]'
|
|
|
119
119
|
| `rails 'ai:tool[NAME]'` | `rails-ai-context tool NAME` | Run any tool |
|
|
120
120
|
| `rails ai:tool` | `rails-ai-context tool --list` | List all tools |
|
|
121
121
|
| `rails ai:doctor` | `rails-ai-context doctor` | Diagnostics |
|
|
122
|
-
| `rails ai:watch` | `rails-ai-context watch` | Auto-regenerate on change |
|
|
122
|
+
| `rails ai:watch` | `rails-ai-context watch` | Auto-regenerate on change (needs `listen`: in the Gemfile, or `gem install listen` standalone) |
|
|
123
123
|
| `rails ai:context` | `rails-ai-context context` | Regenerate context files |
|
|
124
124
|
|
|
125
125
|
---
|
data/docs/RECIPES.md
CHANGED
|
@@ -367,7 +367,7 @@ The gem wraps its generated content in section markers:
|
|
|
367
367
|
<!-- END rails-ai-context -->
|
|
368
368
|
```
|
|
369
369
|
|
|
370
|
-
Add your custom rules **outside** these markers - they're preserved on regeneration.
|
|
370
|
+
Add your custom rules **outside** these markers - they're preserved on regeneration. A hand-written `app/models/AGENTS.md` or `app/controllers/AGENTS.md` is kept the same way: your text stays, and the gem's listing goes between the markers above it.
|
|
371
371
|
|
|
372
372
|
**Step 3: Remove manual maintenance**
|
|
373
373
|
|
data/docs/SECURITY.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
> [!CAUTION]
|
|
14
|
-
> This gem is designed for **development environments**. The query tool
|
|
14
|
+
> This gem is designed for **development environments**. The query tool, and MCP over HTTP from inside the app, are disabled in production by default. Sensitive files are blocked. All 45 tools are read-only.
|
|
15
15
|
|
|
16
16
|
## Design principles
|
|
17
17
|
|
|
@@ -50,14 +50,29 @@ flowchart LR
|
|
|
50
50
|
Before any query reaches the database:
|
|
51
51
|
|
|
52
52
|
- Strips comments: block (`/* */`), line (`--`), and on MySQL `#`. A comment marker inside a quoted string or identifier is data and stays. The query that runs is this stripped text, never the raw input, so nothing the checks did not read reaches the database
|
|
53
|
-
- **Blocks write keywords**: INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE, CREATE, GRANT, REVOKE, SET, COPY, MERGE, REPLACE
|
|
53
|
+
- **Blocks write keywords**: INSERT, UPDATE, DELETE, DROP, ALTER, TRUNCATE, CREATE, GRANT, REVOKE, SET, COPY, MERGE, REPLACE. The keyword check (and the `SELECT … INTO` table-creation check) reads **quote-masked text**, so a keyword inside a string literal is data: `… WHERE notes LIKE '%update%'` and `… = 'put into box'` are reads, not writes. `REPLACE` is matched only as the statement; the string function `replace(col, a, b)` every database has (a `REPLACE` immediately followed by `(`) is allowed. `SHOW CREATE TABLE` / `SHOW CREATE VIEW` are allowed despite the `CREATE` (see below)
|
|
54
54
|
- **Blocks lock clauses**: FOR UPDATE, FOR SHARE, FOR NO KEY UPDATE
|
|
55
55
|
- **Blocks dangerous SHOW**: GRANTS, PROCESSLIST, BINLOG, SLAVE, MASTER, REPLICAS
|
|
56
|
-
- **Blocks SELECT INTO**: prevents table creation via SELECT
|
|
56
|
+
- **Blocks SELECT INTO**: prevents table creation via SELECT. `INTO OUTFILE` / `INTO DUMPFILE` disk writes, and the file/network functions above, stay matched on the **raw (unmasked) text** - no read-only transaction stops a server-side file write, so these are never read as data
|
|
57
57
|
- **Blocks file and network functions**: `pg_read_file`, `pg_read_binary_file`, `pg_ls_dir`, `pg_stat_file`, `lo_import`, `lo_export`, `dblink*`, `LOAD DATA`, `LOAD_FILE`, `load_extension` and their relatives, checked before and after comment stripping
|
|
58
|
-
- **Blocks
|
|
59
|
-
- **Blocks
|
|
60
|
-
- **
|
|
58
|
+
- **Blocks session-effecting / administrative functions**: a read-only transaction does not stop these, and their effect lingers on the pooled connection. PostgreSQL: `pg_terminate_backend`, `pg_cancel_backend`, `pg_reload_conf`, `pg_stat_reset*`, `pg_switch_wal`, `pg_create_restore_point`, replication-slot and -origin functions, `pg_logical_emit_message`, `pg_advisory_lock*`, `set_config`, `pg_notify`, `txid_current` and their relatives; MySQL: `GET_LOCK`, `RELEASE_LOCK`, `RELEASE_ALL_LOCKS`, `IS_FREE_LOCK`, `IS_USED_LOCK`. This name list is the first layer; on PostgreSQL the plan's VOLATILE-function check (below) closes the rest of the family
|
|
59
|
+
- **Blocks column-alias lists**: a CTE or FROM-item list such as `t(a, b, c)` renames the columns a wildcard returns, which would carry sensitive columns out under harmless names. A type modifier (`numeric(10,2)`, `varchar(255)`), a record-function list (`AS t(a int, b text)`) and a single-column alias are left alone. A list whose body is a `VALUES` clause (`(VALUES (1,'a')) AS v(id, name)`, `WITH v(id, name) AS (VALUES …)`) renames no table column - `VALUES` has none - so it is allowed; the body is identified by balancing parentheses on quote-masked text, so one inside a literal does not move its bounds, and a sensitive name written inside the `VALUES` rows is still caught by the textual check. A FROM-item table-alias list is always a rename and is refused
|
|
60
|
+
- **Blocks multi-statements**: multiple semicolons, read on quote-masked text so a `;` inside a string literal or a `DO $$ … ; … $$` block body is not mistaken for a second statement (a `DO` block is then refused plainly as a disallowed statement, not for the wrong reason)
|
|
61
|
+
- **Blocks injection patterns**: OR 1=1, OR true, OR ''=''
|
|
62
|
+
- **Refuses any UNION**: a UNION takes its column names from its first SELECT, so another table's rows - a wildcard's columns included - would come out under those names, past the redaction that reads names. Every spelling is refused (`UNION`, `UNION ALL`, `UNION DISTINCT`, a parenthesised `UNION (SELECT ...)`), recursive CTEs with it; `INTERSECT` and `EXCEPT` return only their first SELECT's rows and stay. On PostgreSQL a "union" inside a string literal is data
|
|
63
|
+
- **Blocks sensitive column references**: see Layer 4 - a query whose text names a sensitive column, directly or through an alias or expression, is refused before execution
|
|
64
|
+
- **Allows only**: SELECT, WITH, SHOW, EXPLAIN, DESCRIBE, DESC. `SHOW CREATE TABLE` and `SHOW CREATE VIEW` are allowed so schema can be inspected; every other `SHOW CREATE` (`USER`, whose output includes credential hashes, `DATABASE`, `FUNCTION`, …) keeps its `CREATE` and is refused. A returned definition is passed through secret redaction before it is shown, so a credential embedded in it - a FEDERATED table's `CONNECTION='mysql://user:pass@host/db'` - comes back as `mysql://[FILTERED]@host/db`
|
|
65
|
+
|
|
66
|
+
### Layer 1b - PostgreSQL semantic analysis (planning only)
|
|
67
|
+
|
|
68
|
+
The textual layer cannot see a whole-row serialisation (`SELECT row_to_json(u.*) FROM users u`) or an admin function introduced through a view, because no sensitive column name appears in the query. On PostgreSQL, before the query runs, the tool plans it with `EXPLAIN (VERBOSE, FORMAT JSON)` inside the same read-only transaction - planning only, nothing executes, not even a VOLATILE function (the planner does not fold one). The plan is refused when it:
|
|
69
|
+
|
|
70
|
+
- holds a **whole-row reference** (`alias.*`) to a relation that has a sensitive column - this closes `row_to_json(u)`, `to_json(u)`, `u::text`, `array_agg(u)`, `json_agg(u)`, `hstore(u)`, `concat(u.*)`, `string_agg(u::text, …)`, a bare `SELECT u`, and the same through a view, subquery or CTE;
|
|
71
|
+
- **uses a sensitive column** anywhere but as a plain pass-through output - a `ROW(u.*)` the planner expanded into named columns, or a `WHERE` / `ORDER BY` oracle;
|
|
72
|
+
- **launders a sensitive column** through an intermediate the plan's top node does not name - a `MATERIALIZED` CTE, a subquery scan, or a set operation (`INTERSECT` / `EXCEPT`) - so its value would reach a result column that the output-provenance redaction (Layer 4) cannot pin. These are refused rather than redacted;
|
|
73
|
+
- names any **VOLATILE function** (`pg_proc.provolatile = 'v'`) outside a short read-only allowlist. `pg_proc` marks a function VOLATILE when its answer may change within one statement, which is as true of a reader of live server state as of a writer, so VOLATILE alone does not mean "changes state". The allowlist holds only built-in C functions that write nothing: value generators (`random`, `clock_timestamp`, `gen_random_uuid`, `timeofday`, `uuid_generate_v4`, and `pg_sleep*`, which the statement timeout bounds); the size readers (`pg_total_relation_size`, `pg_relation_size`, `pg_table_size`, `pg_indexes_size`, `pg_database_size`, `pg_tablespace_size`); the lock-table snapshot readers (`pg_lock_status` - what the `pg_locks` view reads - `pg_blocking_pids`, `pg_safe_snapshot_blocking_pids`); and the recovery / WAL-position readers (`pg_is_in_recovery`, `pg_is_wal_replay_paused`, `pg_get_wal_replay_pause_state`, `pg_current_wal_lsn` and the other `pg_current_wal_*` / `pg_last_wal_*` / `pg_last_xact_replay_timestamp` readers). `pg_stat_activity` reads through `pg_stat_get_activity`, which is STABLE, so it was never refused. Anything else VOLATILE is not run, and the refusal says plainly that the function is VOLATILE and not on the read-only list - not that it changes state. This is how the admin-function family is closed robustly, rather than by an ever-growing name list.
|
|
74
|
+
|
|
75
|
+
A whole-row read of a relation with no sensitive column (`row_to_json(posts.*)`) is allowed. Before these checks scan a plan expression, its string constants are masked (PostgreSQL's deparser always doubles quotes and never emits an `E''` string, so a constant is matched exactly), so a sensitive word inside a `'%key%'::text` filter or a literal argument is not mistaken for a column or a function.
|
|
61
76
|
|
|
62
77
|
### Layer 2 - Database-level read-only
|
|
63
78
|
|
|
@@ -67,6 +82,7 @@ After validation, the query runs inside a transaction:
|
|
|
67
82
|
|:---------|:----------|
|
|
68
83
|
| PostgreSQL | `SET TRANSACTION READ ONLY` + `SET LOCAL statement_timeout` |
|
|
69
84
|
| MySQL | `SET TRANSACTION READ ONLY` + `MAX_EXECUTION_TIME` hint |
|
|
85
|
+
| MariaDB | `SET TRANSACTION READ ONLY` + `SET STATEMENT max_statement_time = <seconds> FOR <query>` (MariaDB ignores MySQL's hint, so a long query would otherwise run unbounded) |
|
|
70
86
|
| SQLite | A read-only connection in a child process, killed at timeout |
|
|
71
87
|
|
|
72
88
|
On PostgreSQL and MySQL the query executes inside a transaction, then rolls back (even if it could write, it can't). Any other adapter has no database-level guard: the query runs with Layer 1 validation and the row limit only.
|
|
@@ -88,16 +104,30 @@ lock in WAL mode does not block it.
|
|
|
88
104
|
|
|
89
105
|
- Default: 100 rows
|
|
90
106
|
- Configurable: `config.query_row_limit` (hard cap: 1000)
|
|
91
|
-
- Applied as a `LIMIT` clause appended to the query
|
|
92
|
-
- The rows that come back are cut to the cap
|
|
107
|
+
- Applied as a `LIMIT` clause appended to the query, which fetches **one row past the cap** (`cap + 1`): a `LIMIT` or `FETCH FIRST` that ends the query is rewritten to `min(n, cap + 1)`, and one inside a subquery is left as written
|
|
108
|
+
- The rows that come back are cut to the cap, so the answer holds it whatever the database made of the text
|
|
109
|
+
- The note that rows were held back is added **only when more than the cap came back** - the extra row proves the cap actually cut the answer - then the extra row is dropped. A result that exactly fills the cap (every row fit) gets no note, which fixes the old "N rows shown; the query returned at least this many" appearing when nothing was held back. The note reads `100 rows shown; the query returned more (the row limit of 100; pass limit: up to 1000 to see more)` and rides every format; in CSV it sits after a blank line, outside the comma block, so a parser still reads clean rows
|
|
93
110
|
|
|
94
111
|
### Layer 4 - Sensitive column rejection
|
|
95
112
|
|
|
96
|
-
A query that names a sensitive column is **rejected before execution**, not redacted after it:
|
|
113
|
+
A query that names a sensitive column is **rejected before execution**, not redacted after it. One rule decides what is sensitive, used by the pre-execution refusal, the PostgreSQL plan check and result redaction alike. A column is sensitive when it is:
|
|
114
|
+
|
|
115
|
+
- in `config.query_redacted_columns`, or the fixed built-in list;
|
|
116
|
+
- an `encrypts` column of one of your models;
|
|
117
|
+
- a name the heuristic catches: it ends in `password`, `secret`, `token`, `key`, `digest` or `hash`, or contains `password`, `secret` or `token`
|
|
118
|
+
|
|
119
|
+
minus anything in `config.query_allowed_columns`.
|
|
120
|
+
|
|
121
|
+
**Default redacted patterns:** `password_digest`, `encrypted_password`, `password_hash`, `reset_password_token`, `confirmation_token`, `unlock_token`, `otp_secret`, `session_data`, `secret_key`, `api_key`, `api_secret`, `access_token`, `refresh_token`, `jti`. Those are the defaults of `config.query_redacted_columns`. The fixed built-in list adds `password_reset_token`, `remember_token`, `secret` and `private_key`.
|
|
97
122
|
|
|
98
|
-
|
|
123
|
+
The pre-execution refusal matches on two things, case-insensitive and word-bounded:
|
|
99
124
|
|
|
100
|
-
|
|
125
|
+
- **the fixed names above**, on every adapter whether or not they are real columns, so the check holds even with no connection;
|
|
126
|
+
- **every real column of your schema that the rule flags** - read from the live connection, falling back to the cached schema. This catches an app-specific column the heuristic flags (`api_token`, `auth_token`) through any alias or expression, not only an exact configured name. Rails' own bookkeeping tables (`ar_internal_metadata`, `schema_migrations`) are left out: their columns are never secrets, and `ar_internal_metadata.key` ends in "key", so counting it refused every query whose text held the word "key".
|
|
127
|
+
|
|
128
|
+
So `SELECT password_digest AS pd FROM users`, `SELECT upper(api_token) FROM users` and `SELECT (SELECT api_token FROM users LIMIT 1) AS leaked` are all refused: post-execution redaction reads the output column names, which the caller controls through aliases and expressions, so it cannot be relied on. A table or alias that merely contains a sensitive word (a `tokens` table) does not trip the check - only a real column name does. `SELECT *` stays allowed, and its sensitive columns are redacted in the result.
|
|
129
|
+
|
|
130
|
+
A sensitive word inside a **string literal** - a `LIKE '%secret%'` pattern, a JSON key `->>'token'` - is data, not a column reference. On PostgreSQL the check runs on literal-masked text, and the plan layer (Layer 1b) then has to vouch: a query that passed only because a name sat inside a literal runs only when its plan was obtained and cleared every check, so a masker fooled by a quote trick (`standard_conforming_strings` off, an `E''` string) can never be the only gate - if planning fails, the raw-text refusal stands. On MySQL, MariaDB and SQLite the match stays on raw text with no masking, because a quote's meaning there depends on server modes (`NO_BACKSLASH_ESCAPES`, `ANSI_QUOTES`) or a dotted single-quoted name is an identifier (`t.'col'`).
|
|
101
131
|
|
|
102
132
|
If one of your own columns merely looks sensitive (an `oauth_applications.secret`, say), exempt it by name:
|
|
103
133
|
|
|
@@ -105,13 +135,11 @@ If one of your own columns merely looks sensitive (an `oauth_applications.secret
|
|
|
105
135
|
config.query_allowed_columns = %w[secret]
|
|
106
136
|
```
|
|
107
137
|
|
|
108
|
-
Results are redacted as well: a returned column comes back as `[FILTERED]` when
|
|
109
|
-
its name is in `config.query_redacted_columns`, contains `password`, `secret` or
|
|
110
|
-
`token`, or ends in `key`, `digest` or `hash`. `SHOW`, `DESCRIBE` and `EXPLAIN`
|
|
111
|
-
output is not redacted.
|
|
138
|
+
Results are redacted as well: a returned column comes back as `[FILTERED]` when the sensitivity rule flags its **output name**, and on PostgreSQL when its origin is a sensitive base column - either by **provenance** (`PG::Result#ftable` / `#ftablecol`, mapped to the base column) or by the **plan's top-node `Output`**, whose i-th expression is result column i. The plan path catches a view (or inlined CTE/subquery/join) that renamed a sensitive column to a harmless name: `CREATE VIEW user_tokens AS SELECT id, api_token AS t FROM users; SELECT t FROM user_tokens` returns `t` as `[FILTERED]`. `SHOW`, `DESCRIBE` and `EXPLAIN` output skips column-name redaction; the one exception is `SHOW CREATE TABLE` / `SHOW CREATE VIEW`, whose definition text is run through the shared secret redaction so a credential embedded in it is filtered.
|
|
112
139
|
|
|
113
140
|
The exemption covers the results too: an allowed name comes back unredacted. A
|
|
114
|
-
column declared with `encrypts` stays `[FILTERED]` either way
|
|
141
|
+
column declared with `encrypts` stays `[FILTERED]` either way - an encrypted-at-rest
|
|
142
|
+
attribute is never meant to be read back raw.
|
|
115
143
|
|
|
116
144
|
### Environment guard
|
|
117
145
|
|
|
@@ -177,7 +205,43 @@ root = File.realpath(Rails.root.to_s)
|
|
|
177
205
|
raise unless real_path == root || real_path.start_with?(root + File::SEPARATOR)
|
|
178
206
|
```
|
|
179
207
|
|
|
180
|
-
|
|
208
|
+
A path may also resolve into a directory the app links in from its own git repository, as
|
|
209
|
+
described under "Links in and out of the app" below. The VFS
|
|
210
|
+
(`rails-ai-context://views/{path}`) applies the same protection for view template reads.
|
|
211
|
+
|
|
212
|
+
### Links in and out of the app
|
|
213
|
+
|
|
214
|
+
A symlink inside the app is followed when it leads into the app root, or into the engine its
|
|
215
|
+
`test/dummy` runs in, and a directory link is also followed when it leads anywhere inside the
|
|
216
|
+
git work tree that holds the app. A monorepo's shared package linked into `packs/` or
|
|
217
|
+
`app/javascript/controllers` is read as the app's own, under the path the app gives it
|
|
218
|
+
(`packs/billing/app/models/invoice.rb`). A file reached through such a directory is read only
|
|
219
|
+
when its real path stays inside the app or inside that directory. Outside a git repository
|
|
220
|
+
nothing marks where the project ends, so only links that stay inside the app root are
|
|
221
|
+
followed. A link to anywhere else, a home directory included, is not followed: the file is
|
|
222
|
+
neither listed nor read. A link that resolves nowhere is not listed either.
|
|
223
|
+
|
|
224
|
+
The walks over app code follow a linked-in directory where Rails does: the trees Zeitwerk
|
|
225
|
+
autoloads (models, controllers, jobs, mailers, channels, helpers, components, concerns,
|
|
226
|
+
policies and the rest of `app/`, packs and in-repo engines included), the view resolver's
|
|
227
|
+
templates and layouts, and the JavaScript roots a bundler reads. A file Rails finds with a
|
|
228
|
+
glob of its own is found the way that glob finds it, entering no linked directory below where
|
|
229
|
+
it starts: initializers, locale and environment files, migrations, seeds, rake tasks, test
|
|
230
|
+
files and mailer previews. Among those, a linked file is read only when it stays inside the
|
|
231
|
+
app.
|
|
232
|
+
|
|
233
|
+
A tool handed a path that leads out of the app refuses it, and so does a tool that resolves a
|
|
234
|
+
name to one file - a log, a partial, a concern - when that file leads out; see "How a refusal
|
|
235
|
+
is reported" below. A tool that answers from what the walks found, such as
|
|
236
|
+
`rails_get_stimulus`, never lists a file a link carries out, and asked for that controller by
|
|
237
|
+
name answers that it is not there. A path into a linked-in directory is accepted wherever a
|
|
238
|
+
path is (`packs/billing/app/models/invoice.rb`), and the sensitive patterns are matched
|
|
239
|
+
against the file's name inside that directory as well as the app's. `rails_search_code`
|
|
240
|
+
searches the linked-in directories too.
|
|
241
|
+
|
|
242
|
+
Booted, a class Rails loaded through a link out of the app is still described from what
|
|
243
|
+
reflection answers, such as a job's queue name; only its file goes unread.
|
|
244
|
+
`rails_security_scan` hands the app to Brakeman, which reads it by its own rules.
|
|
181
245
|
|
|
182
246
|
### Frontend roots outside the app
|
|
183
247
|
|
|
@@ -215,8 +279,18 @@ and contains the app root.
|
|
|
215
279
|
### How a refusal is reported
|
|
216
280
|
|
|
217
281
|
A path refused on policy - outside the app, a traversal, a sensitive file - comes back as an
|
|
218
|
-
error result: `isError: true` over MCP, exit 1 from the CLI
|
|
219
|
-
|
|
282
|
+
error result: `isError: true` over MCP, exit 1 from the CLI, and the text starts with
|
|
283
|
+
`Path not allowed: <path>`, followed by `(sensitive file)` when the name is the reason. A
|
|
284
|
+
path that is simply not there is an ordinary answer and exits 0, so a script can tell the two
|
|
285
|
+
apart. `rails_validate`, which answers for several files at once, says `Path not allowed` on
|
|
286
|
+
the refused file's own line; a file it cannot validate, refused or missing, fails the
|
|
287
|
+
validation, so that answer is an error result either way.
|
|
288
|
+
|
|
289
|
+
The same holds for a name a tool turns into a file: a log (`rails_read_logs file:`), a partial
|
|
290
|
+
(`rails_get_partial_interface partial:`) or a concern (`rails_get_concern name:`) whose file
|
|
291
|
+
links out of the app is refused, not reported missing. A list of what a tool can read, such
|
|
292
|
+
as the available logs or partials, leaves out every entry it would refuse. `rails_diagnose` still diagnoses the error it is given when the `file:` it is
|
|
293
|
+
pointed at is refused, says so, and returns the whole answer as an error result.
|
|
220
294
|
|
|
221
295
|
---
|
|
222
296
|
|
|
@@ -233,6 +307,11 @@ Open3.capture3("rg", "--no-heading", "--", pattern, directory)
|
|
|
233
307
|
|
|
234
308
|
File type parameters accept only alphanumeric characters.
|
|
235
309
|
|
|
310
|
+
An argument git would read as an option never reaches it: `rails_review_changes`
|
|
311
|
+
resolves its `ref` to a commit first (`git rev-parse --verify <ref>^{commit}`)
|
|
312
|
+
and hands git that commit's SHA. A ref that starts with a dash is refused, so
|
|
313
|
+
`--output=<path>` cannot make `git diff` write a file.
|
|
314
|
+
|
|
236
315
|
---
|
|
237
316
|
|
|
238
317
|
## Regex injection prevention
|
|
@@ -268,8 +347,9 @@ Everything that leaves your app through this gem - log lines, query rows, enviro
|
|
|
268
347
|
What gets redacted:
|
|
269
348
|
|
|
270
349
|
- Passwords, tokens, secrets and API keys, wherever they are named
|
|
350
|
+
- The same in the app's own source a tool or resource shows: a method body, a template, a test file, a scan's code line, a change's diff (where a file on `sensitive_patterns` shows none of its lines)
|
|
271
351
|
- Credentials embedded in URIs (`redis://user:pass@host`)
|
|
272
|
-
- Email addresses in log lines
|
|
352
|
+
- Email addresses in log lines, in any script (`jürgen@example.com`, `anna@münchen.de`)
|
|
273
353
|
- Values assigned to secret-named settings in initializers
|
|
274
354
|
|
|
275
355
|
### Markers
|
|
@@ -316,13 +396,17 @@ The `rails_migration_advisor` tool validates input:
|
|
|
316
396
|
|
|
317
397
|
## MCP HTTP transport
|
|
318
398
|
|
|
319
|
-
|
|
399
|
+
No HTTP entry point authenticates a client: every tool answers whoever reaches the endpoint.
|
|
400
|
+
|
|
401
|
+
**Inside the app** - the mounted engine and `auto_mount` - the endpoint is on your app's own web server, so it is as reachable as the app is. In every environment but development and test - production, and a staging app the network reaches the same way - both refuse every request with a 403 and a JSON-RPC error that says how to opt in, and log the refusal once. `config.allow_http_in_production = true` lets them answer there. Set it only with the endpoint behind your app's authentication, such as a routes constraint around the mount. `auto_mount` answers before routing, so nothing in the app can guard it, and doctor's "MCP HTTP endpoint" check fails that pair. With the option on and the engine, it warns.
|
|
402
|
+
|
|
403
|
+
Both answer the hosts the app's `config.hosts` allows, judged by Rails' own HostAuthorization with its `host_authorization` exclusions, as every other route of the app is: `myapp.localhost` and any IP address in development, the app's own names in production, every host when `config.hosts` is empty. A host it does not allow, such as a DNS rebinding attacker's domain, gets 403 "Forbidden: Invalid Host header", and a browser's request from another origin gets 403 "Forbidden: Invalid Origin header". The MCP SDK's own check, which answers only `127.0.0.1`, `::1` and `localhost`, is off for these two.
|
|
404
|
+
|
|
405
|
+
**The standalone server** - `rails-ai-context serve --transport http` and `rails ai:serve_http` - is its own process and binds `127.0.0.1` by default. `http_bind` can open it to the network, still with no authentication. The MCP SDK answers only requests whose `Host` header is `127.0.0.1`, `::1` or `localhost`, which stops a DNS rebinding attack from a browser but not a client that sends `Host: localhost` itself; a client that addresses the machine by IP or name gets 403 "Invalid Host header". So a non-loopback bind suits a container whose port is published to the host, where the client still connects to `localhost`. The server says so on stderr when it starts. On an MCP SDK too old to check the `Host` header, every tool answers whoever reaches the port.
|
|
320
406
|
|
|
321
|
-
|
|
322
|
-
- **`auto_mount` is `false` by default** - must be explicitly enabled
|
|
323
|
-
- **Doctor checks** fail if `auto_mount` is true in production, and report it as enabled elsewhere
|
|
407
|
+
`auto_mount` is `false` by default, and the engine exists only where you mount it. Mount it guarded, `if defined?(RailsAiContext::Engine)`, so the routes file still loads where the gem is not installed.
|
|
324
408
|
|
|
325
|
-
The
|
|
409
|
+
The engine builds one transport per process, under a mutex, outside the controller class, and the app's code reloader never replaces that class, so a session lasts across edits in development.
|
|
326
410
|
|
|
327
411
|
---
|
|
328
412
|
|
data/docs/SETUP.md
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
- [GitHub Copilot](#github-copilot)
|
|
19
19
|
- [OpenCode](#opencode)
|
|
20
20
|
- [Codex CLI](#codex-cli)
|
|
21
|
+
- [A folder of apps](#a-folder-of-apps)
|
|
21
22
|
- [Verify MCP is connected](#verify-mcp-is-connected)
|
|
22
23
|
- [HTTP Transport](#http-transport-alternative)
|
|
23
24
|
- [Regenerating context files](#regenerating-context-files)
|
|
@@ -55,10 +56,10 @@ rails generate rails_ai_context:install # Select "Claude Code"
|
|
|
55
56
|
This creates:
|
|
56
57
|
- `.mcp.json` - MCP auto-discovery config (auto-detected on project open)
|
|
57
58
|
- `CLAUDE.md` - Root context file
|
|
58
|
-
- `.claude/rules/rails-schema.md` - Schema rules (loaded when editing
|
|
59
|
+
- `.claude/rules/rails-schema.md` - Schema rules (loaded when editing a schema dump or a migration of any of the app's databases)
|
|
59
60
|
- `.claude/rules/rails-models.md` - Model rules (loaded when editing `app/models/`)
|
|
60
61
|
- `.claude/rules/rails-context.md` - General context rules (always loaded)
|
|
61
|
-
- `.claude/rules/rails-mcp-tools.md` - Tool reference (always loaded)
|
|
62
|
+
- `.claude/rules/rails-mcp-tools.md` - Tool reference: detail levels and the table of every tool (always loaded; the protocol, workflows and rules are in CLAUDE.md, so they are not loaded twice, and here only when `generate_root_files` is off)
|
|
62
63
|
- `.claude/rules/rails-components.md` - Component rules (loaded when editing `app/components/` or `app/views/components/`, written only when the app has view components)
|
|
63
64
|
|
|
64
65
|
Keeping your own `CLAUDE.md`? Add `--mcp-only` and only `.mcp.json` is
|
|
@@ -82,6 +83,8 @@ If you need to configure manually, create `.mcp.json`:
|
|
|
82
83
|
|
|
83
84
|
This is what the generator writes for an in-Gemfile install. A standalone install has no `bundle exec`: the command is `rails-ai-context` and the args are `["serve"]`. The same goes for the other tools below.
|
|
84
85
|
|
|
86
|
+
On Windows the command is `cmd` and `"/c"` goes ahead of the args (`["/c", "bundle", "exec", "rails-ai-context", "serve"]`): a gem's executables there are batch files, which a client that starts its server without a shell does not run. The generator writes that form on Windows, and `doctor` names an entry written for the other platform; see [GUIDE.md](GUIDE.md#mcp-server-setup) for teams on both.
|
|
87
|
+
|
|
85
88
|
### Split rules with `paths:` frontmatter
|
|
86
89
|
|
|
87
90
|
Claude Code loads `.claude/rules/` files conditionally based on YAML frontmatter:
|
|
@@ -155,7 +158,7 @@ This creates:
|
|
|
155
158
|
- `.github/instructions/rails-models.instructions.md` - Model rules
|
|
156
159
|
- `.github/instructions/rails-controllers.instructions.md` - Controller rules
|
|
157
160
|
- `.github/instructions/rails-context.instructions.md` - Context rules
|
|
158
|
-
- `.github/instructions/rails-mcp-tools.instructions.md` - Tool reference
|
|
161
|
+
- `.github/instructions/rails-mcp-tools.instructions.md` - Tool reference: detail levels and the table of every tool (the protocol and workflows are in copilot-instructions.md, and here only when `generate_root_files` is off)
|
|
159
162
|
|
|
160
163
|
### Manual MCP config
|
|
161
164
|
|
|
@@ -256,7 +259,20 @@ Codex CLI `env_clear()`s the process before spawning MCP servers. Without the en
|
|
|
256
259
|
rails ai:doctor # Includes check_codex_env_staleness
|
|
257
260
|
```
|
|
258
261
|
|
|
259
|
-
If you change Ruby versions, re-run the install generator to update the env snapshot.
|
|
262
|
+
doctor fails a snapshot whose `PATH` no longer reaches the server's command (the Ruby it names was upgraded or removed; rbenv and asdf save a `PATH` and nothing else), and warns about a saved `GEM_HOME` that is gone, or a directory on a saved `GEM_PATH` that is gone and that your shell's `GEM_PATH` no longer names (RubyGems passes over a missing one, so one the shell still names is no change of Ruby). If you change Ruby versions, re-run the install generator (`rails-ai-context init` for a standalone install, in the folder of apps for a workspace's config) to update the env snapshot.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## A folder of apps
|
|
267
|
+
|
|
268
|
+
When your AI tool opens a folder that holds several apps (`work/shop`, `work/admin`), run `rails-ai-context init` in that folder. Each AI tool reads its MCP config from the folder it opened, so the folder's configs get one server per app, and each app keeps its own `.rails-ai-context.yml` and context files:
|
|
269
|
+
|
|
270
|
+
| AI tool | Entry for `work/shop` |
|
|
271
|
+
|---------|-----------------------|
|
|
272
|
+
| Claude Code, Codex CLI, OpenCode | `rails-ai-context serve --app-path shop`, with `RAILS_AI_CONTEXT_SERVER_NAME=shop-rails-ai-context` |
|
|
273
|
+
| Cursor, GitHub Copilot (VS Code) | the same, with `--app-path ${workspaceFolder}/shop` |
|
|
274
|
+
|
|
275
|
+
Claude Code, Codex and OpenCode start a server in the folder they were launched in, so start them in `work/`. Cursor and VS Code expand `${workspaceFolder}` to the folder their config sits in. An in-Gemfile app's entry runs `bundle exec` with `BUNDLE_GEMFILE` naming the Gemfile that app's bundle reads (its own, or a monorepo's shared one), spelled the same way. `RAILS_AI_CONTEXT_SERVER_NAME` makes each server announce its app first: VS Code names every tool after that name and keeps 13 characters of it. The full shape is in the [CLI reference](CLI.md#init-standalone-only), and [ADR-0005](adr/0005-workspace-mcp-entries.md) records why.
|
|
260
276
|
|
|
261
277
|
---
|
|
262
278
|
|
|
@@ -266,10 +282,16 @@ Instead of stdio, you can mount the MCP server inside your Rails app:
|
|
|
266
282
|
|
|
267
283
|
```ruby
|
|
268
284
|
# config/routes.rb
|
|
269
|
-
mount RailsAiContext::Engine, at: "/mcp"
|
|
285
|
+
mount RailsAiContext::Engine, at: "/mcp" if defined?(RailsAiContext::Engine)
|
|
270
286
|
```
|
|
271
287
|
|
|
272
|
-
|
|
288
|
+
The `if` keeps the routes file loading wherever the gem is not: in production, where Bundler skips the `:development` group, and after the gem is removed. An unguarded mount raises `uninitialized constant RailsAiContext::Engine` there, and the app does not boot.
|
|
289
|
+
|
|
290
|
+
The mounted engine answers the server-push channel (a long-lived SSE `GET /mcp`) with 405, which the MCP spec allows a server that sends no server-initiated messages: live reload, the one thing that sends them, runs only in the standalone `rails-ai-context serve --transport http` process. Clients carry on over POST, a connected client holds a server thread only while one of its requests runs, and `rails server` stops without waiting for clients to disconnect.
|
|
291
|
+
|
|
292
|
+
The engine runs each call on the thread Rails serves the request on and sends the answer whole, so calls keep being answered while the app reloads code after an edit. What a tool sends during a call reaches the client with the answer: a custom tool's progress or log notifications arrive together at the end, and a request a tool makes of the client mid-call (sampling, elicitation, roots) fails at once with a message saying why. stdio and the standalone HTTP server (`rails-ai-context serve --transport http`) deliver both as they are sent. For the same reason the engine answers `subscriptions/listen`, whose notification stream it cannot keep open, as a method it does not have.
|
|
293
|
+
|
|
294
|
+
With a migration pending in development, Rails answers every request to the app with its pending-migration page, and the mounted engine is one of those routes: it sits behind the app's whole middleware stack, `ActiveRecord::Migration::CheckPending` included, and cannot skip it without also skipping the cookies and session an authentication constraint around the mount needs. `auto_mount` answers ahead of that check, so its tools, which report the pending migration, keep working; so do stdio and the standalone server.
|
|
273
295
|
|
|
274
296
|
Then point your AI tool's MCP config to the HTTP endpoint instead of a command:
|
|
275
297
|
|
|
@@ -285,6 +307,8 @@ Then point your AI tool's MCP config to the HTTP endpoint instead of a command:
|
|
|
285
307
|
|
|
286
308
|
Benefits: inherits Rails routing, authentication, and middleware stack. No separate process needed.
|
|
287
309
|
|
|
310
|
+
Running the install again keeps an entry like this one, under the gem's own name, in place of the command entry it would write, and says so.
|
|
311
|
+
|
|
288
312
|
---
|
|
289
313
|
|
|
290
314
|
## Verify MCP is connected
|
data/docs/STANDALONE.md
CHANGED
|
@@ -27,13 +27,15 @@ Interactive setup asks:
|
|
|
27
27
|
1. Which AI tools? (Claude, Cursor, Copilot, OpenCode, Codex, or all; an empty answer selects all five)
|
|
28
28
|
2. What should it write? (MCP config + context files, context files only for CLI mode, or MCP config only)
|
|
29
29
|
|
|
30
|
-
On a re-run that drops a tool
|
|
30
|
+
On a re-run it shows the tools selected last time. A re-run that drops a tool also asks whether to remove that tool's files, and one that switches to CLI mode whether to take the gem's entries out of the MCP configs, since CLI mode starts no MCP server. Every question comes before the app boots and anything is written; an MCP-only setup does not boot the app at all. Unlike the install generator, `init` has no pre-commit hook prompt and no `--defaults` flag. The steps are listed in [What the install generator does](GUIDE.md#what-the-install-generator-does).
|
|
31
31
|
|
|
32
32
|
Creates:
|
|
33
33
|
- `.rails-ai-context.yml` - YAML configuration
|
|
34
34
|
- MCP config files for selected AI tools
|
|
35
35
|
- Context files for selected AI tools
|
|
36
36
|
|
|
37
|
+
Run it from a subdirectory and it sets up the app above. Run it in a folder that holds several apps - the folder you open your editor at - and it sets them all up at once: each app gets its own `.rails-ai-context.yml` and context files, and the folder's MCP configs get one server per app. See [`init`](CLI.md#init-standalone-only) in the CLI reference.
|
|
38
|
+
|
|
37
39
|
It does not create `config/initializers/rails_ai_context.rb`.
|
|
38
40
|
|
|
39
41
|
## Usage
|
|
@@ -49,11 +51,14 @@ rails-ai-context watch # Auto-regenerate on changes
|
|
|
49
51
|
rails-ai-context version # Show version
|
|
50
52
|
```
|
|
51
53
|
|
|
54
|
+
`watch` needs the `listen` gem, and `serve`'s live reload uses it when it is there. Both use the app's when its bundle locks one, and otherwise one installed beside this gem: `gem install listen`, no Gemfile change. Without it, `serve` still answers from the current files: each tool call checks them first.
|
|
55
|
+
|
|
52
56
|
## How standalone mode works
|
|
53
57
|
|
|
54
|
-
1. **Loads only its boot shim** before the app - the binary requires the two files it needs to boot Rails, then `config/environment.rb`, and requires the gem itself only after the boot returns
|
|
58
|
+
1. **Loads only its boot shim** before the app - the binary requires the two files it needs to boot Rails, then `config/environment.rb`, and requires the gem itself only after the boot returns. Nothing it loads first is a gem an app pins: it reads the Gemfile and `config/boot.rb` with Ruby's own Ripper, not the prism gem, and loads json only once the app's bundle is set up
|
|
55
59
|
2. **Restores `$LOAD_PATH`** entries that `Bundler.setup` strips (since the gem isn't in the Gemfile). The gem's own dependencies come off the load path for the duration of the boot, so every gem the app locks resolves to the app's pin, and go back behind it afterwards
|
|
56
60
|
3. **YAML config** - uses `.rails-ai-context.yml` instead of a Ruby initializer
|
|
61
|
+
4. **Steps aside for the app's own copy** - in an app whose bundle carries its own rails-ai-context (another installed version, or a `path:` copy), a command that boots the app runs through that copy, as `bundle exec rails-ai-context` would, and says so on stderr. That is the copy the app's MCP configs start, and two copies in one process would each load half the gem. The bundle is the one `BUNDLE_GEMFILE` names when you set it (a dual boot's `Gemfile.next`), and a command typed in a subdirectory runs from the app root it found. `init` asks its questions itself and has that copy write only the context files; an MCP-only setup, chosen with `--mcp-only` or at the prompt, runs nothing of the app's. So does `--no-boot`: bundle exec would evaluate the app's Gemfile and start whatever copy the app names, which on a repo just cloned is code nobody has read. A bundle not installed yet (`bundle check` fails) is left alone too: its boot fails, and the static tier answers. A copy installed only under a vendored bundle path or from git is not seen from outside the bundle, so there the installed copy runs
|
|
57
62
|
|
|
58
63
|
This means you get the same 45 tools, same MCP server, same context generation - without touching the project's Gemfile.
|
|
59
64
|
|
|
@@ -94,6 +99,8 @@ skip_tools:
|
|
|
94
99
|
- rails_security_scan
|
|
95
100
|
```
|
|
96
101
|
|
|
102
|
+
`init`, `rails ai:context` and the install generator own three keys of this file - `ai_tools`, `tool_mode` and `context_files` - and rewrite one only when its value changes, where it stands. Your comments and every other key stay as you wrote them.
|
|
103
|
+
|
|
97
104
|
### YAML limitations
|
|
98
105
|
|
|
99
106
|
One config option is Ruby-only and can't be set via YAML:
|
|
@@ -165,7 +172,7 @@ ruby -v # Right Ruby version?
|
|
|
165
172
|
|
|
166
173
|
The file is read from the app root, once, at boot. Check:
|
|
167
174
|
|
|
168
|
-
1. You are
|
|
175
|
+
1. You are inside the app, or passed `--app-path`. A command run from a subdirectory reads the config of the app root it walks up to, and names it on stderr.
|
|
169
176
|
2. The key is one the gem knows. An unknown key warns on stderr and is ignored; `custom_tools` is initializer-only.
|
|
170
177
|
3. The file is readable and parses. A directory at that path, an unreadable file or broken YAML warns on stderr and keeps the defaults.
|
|
171
178
|
|