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.
Files changed (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1491 -0
  3. data/CONTEXT.md +27 -3
  4. data/README.md +16 -12
  5. data/Rakefile +6 -0
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
  7. data/config/routes.rb +2 -3
  8. data/docs/ARCHITECTURE.md +7 -5
  9. data/docs/CLI.md +79 -6
  10. data/docs/COMPATIBILITY.md +75 -1
  11. data/docs/CONFIGURATION.md +30 -11
  12. data/docs/CUSTOM_TOOLS.md +2 -1
  13. data/docs/FAQ.md +16 -6
  14. data/docs/GUIDE.md +117 -71
  15. data/docs/INTROSPECTORS.md +10 -8
  16. data/docs/QUICKSTART.md +3 -3
  17. data/docs/RECIPES.md +1 -1
  18. data/docs/SECURITY.md +109 -25
  19. data/docs/SETUP.md +30 -6
  20. data/docs/STANDALONE.md +10 -3
  21. data/docs/TOOLS.md +152 -34
  22. data/docs/TROUBLESHOOTING.md +90 -12
  23. data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
  24. data/docs/adr/0005-workspace-mcp-entries.md +39 -0
  25. data/exe/rails-ai-context +733 -104
  26. data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
  27. data/lib/rails_ai_context/app_kind.rb +18 -5
  28. data/lib/rails_ai_context/ast_cache.rb +7 -0
  29. data/lib/rails_ai_context/base_mixins.rb +8 -8
  30. data/lib/rails_ai_context/boot_manager.rb +22 -3
  31. data/lib/rails_ai_context/change_watch.rb +14 -11
  32. data/lib/rails_ai_context/cli/app_root.rb +401 -0
  33. data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
  34. data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
  35. data/lib/rails_ai_context/code_reloader.rb +115 -1
  36. data/lib/rails_ai_context/concern_membership.rb +3 -1
  37. data/lib/rails_ai_context/concern_paths.rb +8 -3
  38. data/lib/rails_ai_context/configuration.rb +41 -9
  39. data/lib/rails_ai_context/context_file_report.rb +19 -2
  40. data/lib/rails_ai_context/database_file.rb +26 -0
  41. data/lib/rails_ai_context/doctor.rb +1122 -184
  42. data/lib/rails_ai_context/engine.rb +18 -1
  43. data/lib/rails_ai_context/erb_source.rb +19 -0
  44. data/lib/rails_ai_context/executable.rb +21 -0
  45. data/lib/rails_ai_context/facts_formatter.rb +5 -2
  46. data/lib/rails_ai_context/file_walk.rb +61 -10
  47. data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
  48. data/lib/rails_ai_context/fingerprinter.rb +40 -12
  49. data/lib/rails_ai_context/gem_lock.rb +186 -27
  50. data/lib/rails_ai_context/git_ignore.rb +22 -0
  51. data/lib/rails_ai_context/install/ai_tool.rb +31 -5
  52. data/lib/rails_ai_context/install/cleanup.rb +21 -0
  53. data/lib/rails_ai_context/install/program.rb +209 -35
  54. data/lib/rails_ai_context/install/selection_record.rb +258 -97
  55. data/lib/rails_ai_context/install/surface.rb +6 -1
  56. data/lib/rails_ai_context/install/validation_hook.rb +282 -0
  57. data/lib/rails_ai_context/install/workspace.rb +166 -0
  58. data/lib/rails_ai_context/install_mode.rb +71 -13
  59. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  60. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
  61. data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
  63. data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
  65. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
  66. data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
  67. data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
  68. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
  69. data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
  70. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
  71. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  72. data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
  73. data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
  74. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
  75. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
  76. data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
  77. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  78. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
  79. data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
  80. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
  81. data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
  82. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
  83. data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
  84. data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
  85. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
  86. data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
  87. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
  88. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
  90. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
  91. data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
  92. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
  93. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
  94. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
  95. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
  96. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
  97. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
  98. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  99. data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
  100. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
  101. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
  102. data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
  103. data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
  104. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
  105. data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
  106. data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
  107. data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
  108. data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
  109. data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
  110. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
  111. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
  112. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
  113. data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
  114. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
  115. data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
  116. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
  117. data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
  118. data/lib/rails_ai_context/live_reload.rb +20 -20
  119. data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
  120. data/lib/rails_ai_context/mcp_edge.rb +182 -32
  121. data/lib/rails_ai_context/middleware.rb +5 -1
  122. data/lib/rails_ai_context/migration_status.rb +88 -25
  123. data/lib/rails_ai_context/output_guard.rb +48 -2
  124. data/lib/rails_ai_context/package_json.rb +1 -1
  125. data/lib/rails_ai_context/path_resolver.rb +46 -13
  126. data/lib/rails_ai_context/payload.rb +92 -8
  127. data/lib/rails_ai_context/pending_migrations.rb +5 -1
  128. data/lib/rails_ai_context/portable_path.rb +3 -2
  129. data/lib/rails_ai_context/presets.rb +15 -11
  130. data/lib/rails_ai_context/redaction.rb +262 -51
  131. data/lib/rails_ai_context/resources.rb +55 -20
  132. data/lib/rails_ai_context/route_coverage.rb +8 -0
  133. data/lib/rails_ai_context/safe_file.rb +14 -1
  134. data/lib/rails_ai_context/safe_path.rb +101 -4
  135. data/lib/rails_ai_context/schema_adapter.rb +8 -0
  136. data/lib/rails_ai_context/schema_version.rb +9 -0
  137. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
  138. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
  139. data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
  140. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
  141. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
  142. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
  143. data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
  144. data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
  145. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
  146. data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
  147. data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
  148. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
  149. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
  150. data/lib/rails_ai_context/server.rb +304 -71
  151. data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
  152. data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
  153. data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
  154. data/lib/rails_ai_context/tools/base_tool.rb +367 -46
  155. data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
  156. data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
  157. data/lib/rails_ai_context/tools/diagnose.rb +218 -11
  158. data/lib/rails_ai_context/tools/generate_test.rb +339 -39
  159. data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
  160. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
  161. data/lib/rails_ai_context/tools/get_concern.rb +91 -27
  162. data/lib/rails_ai_context/tools/get_config.rb +13 -4
  163. data/lib/rails_ai_context/tools/get_context.rb +71 -14
  164. data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
  165. data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
  166. data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
  167. data/lib/rails_ai_context/tools/get_env.rb +18 -9
  168. data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
  169. data/lib/rails_ai_context/tools/get_gems.rb +1 -1
  170. data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
  171. data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
  172. data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
  173. data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
  174. data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
  175. data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
  176. data/lib/rails_ai_context/tools/get_routes.rb +29 -8
  177. data/lib/rails_ai_context/tools/get_schema.rb +52 -19
  178. data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
  179. data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
  180. data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
  181. data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
  182. data/lib/rails_ai_context/tools/get_view.rb +14 -14
  183. data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
  184. data/lib/rails_ai_context/tools/onboard.rb +52 -8
  185. data/lib/rails_ai_context/tools/performance_check.rb +5 -3
  186. data/lib/rails_ai_context/tools/query.rb +914 -86
  187. data/lib/rails_ai_context/tools/read_logs.rb +69 -15
  188. data/lib/rails_ai_context/tools/review_changes.rb +215 -53
  189. data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
  190. data/lib/rails_ai_context/tools/safe_call.rb +35 -14
  191. data/lib/rails_ai_context/tools/search_code.rb +243 -67
  192. data/lib/rails_ai_context/tools/search_docs.rb +4 -4
  193. data/lib/rails_ai_context/tools/security_scan.rb +143 -33
  194. data/lib/rails_ai_context/tools/session_context.rb +39 -5
  195. data/lib/rails_ai_context/tools/validate.rb +259 -40
  196. data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
  197. data/lib/rails_ai_context/version.rb +1 -1
  198. data/lib/rails_ai_context/vfs.rb +67 -40
  199. data/lib/rails_ai_context/view_file.rb +41 -3
  200. data/lib/rails_ai_context/watcher.rb +51 -13
  201. data/lib/rails_ai_context.rb +21 -0
  202. data/server.json +1 -1
  203. metadata +14 -1
data/CONTEXT.md CHANGED
@@ -30,16 +30,26 @@ Two senses, one per module, and neither is bare "path" in a name.
30
30
 
31
31
  ## Safe path
32
32
 
33
- A caller-supplied path resolved once, through `SafePath`, before anything reads it. Its refusal order is the contract, not an implementation detail: traversal, sensitive name, realpath, containment, sensitive realpath, file, size. A caller that reorders those checks, or repeats one of them itself, gets a different answer on a symlink or a dotfile than every other tool does, which is the divergence the module exists to end. `BaseTool.safe_glob` is the globbed-path form: realpath, containment and the sensitive-realpath check on each path a pattern yields, with the size cap left to the read that follows.
33
+ A caller-supplied path resolved once, through `SafePath`, before anything reads it. Its refusal order is the contract, not an implementation detail: traversal, sensitive name, realpath, containment (the directory asked about, or a **linked-in** directory on the way to it), sensitive realpath, file, size. A caller that reorders those checks, or repeats one of them itself, gets a different answer on a symlink or a dotfile than every other tool does, which is the divergence the module exists to end. `BaseTool.safe_glob` is the globbed-path form: realpath, containment and the sensitive-realpath check on each path a pattern yields, with the size cap left to the read that follows.
34
34
 
35
35
  Not the same as a path that merely looks harmless, and not a caller's own containment check - "the tool guards this parameter" means it hands the parameter to `SafePath` and renders whatever refusal comes back.
36
36
 
37
37
  ## Carried path
38
38
 
39
- A path the payload already holds because the gem's own walk found it - a controller's or a model's `file:`. A controller's is re-read with `SafeFile.read(File.join(root, relative))`, which checks that it is a regular file under the size cap and nothing else; a model's goes through `PortablePath.resolve` first, since it can be a `gem:` path. The reason is the walk: `SourceScan` deliberately keeps the spelling the app uses when the realpath falls outside the root, so a pack or an in-repo engine symlinked out of the root is spelled inside it, and `SafePath`'s realpath containment would refuse the very file the payload just named - the source comes back nil and a section silently empties.
39
+ A path the payload already holds because the gem's own walk found it - a controller's or a model's `file:`. A controller's is re-read with `SafeFile.read(File.join(root, relative))`, which checks that it is a regular file under the size cap and nothing else; a model's goes through `PortablePath.resolve` first, since it can be a `gem:` path. The reason is the walk: it already decided the file is the app's (see **Linked out**), keeping the spelling the app uses when the realpath falls outside the root, and a model's file can be a gem's, which `SafePath` refuses as outside the root - the source would come back nil and a section silently empty.
40
40
 
41
41
  Not an exception to the **safe path** rule, the other side of it: a caller-supplied path is untrusted and goes through `SafePath`; a carried path was produced by this gem and only needs the cap.
42
42
 
43
+ ## Linked out
44
+
45
+ A file the app spells inside its root, or inside the engine its test/dummy runs in, that a symlink takes somewhere neither of those holds nor a **linked-in** directory does: the file is a link, or sits under one. `PathResolver.linked_out?` answers it. A walk that reads what it finds drops such a file before listing it (`FileWalk`, `SourceScan`, `ViewFile.glob` and the introspectors' own globs), and so does a read of the file Ruby says a loaded class came from, so a **carried path** is never one. A link that resolves nowhere is not linked out, since nothing can be read through it, and a walk drops it as no file.
46
+
47
+ Not the **safe path** check, which refuses a path a caller supplied: this one decides which of the files the gem found itself are the app's. Booted, a class Rails loaded through such a link is still described by reflection; only its file goes unread.
48
+
49
+ ## Linked-in directory
50
+
51
+ A directory link inside the app that leads somewhere in the git work tree holding the app (`SafePath.link_bound`): a monorepo package linked into `packs/` or `app/javascript/controllers`. Its files are the app's, named the way the app spells them (`packs/billing/app/models/invoice.rb`), because Zeitwerk, the view resolver and the bundlers follow it; a file under it that links further out, past the app and the directory, is **linked out**. Outside any repository the bound is the app root, so nothing outside it is linked in. `SafePath.linked_in` finds the directory on a path's spelling, and `PathResolver.enter_link?` decides whether a walk enters one: never one back up over the directory being walked. The walks that stand for Rails' own globs - initializers, locales, migrations, seeds, rake tasks, test files - enter no linked directory, as those globs do not.
52
+
43
53
  ## Source scan
44
54
 
45
55
  The one walk over a kind of app source, across every directory `PathResolver` resolves for it: conventional layout, packs, in-repo engines. `SourceScan.paths` stats only, `each` reads the source on top of it, and `classes` names each file by its declared constant. An introspector that globs `app/<kind>` itself is the mistake this entry exists to name: it misses every pack and engine, and it names files by their path rather than by what they declare.
@@ -56,7 +66,7 @@ The other half of the same problem is the reverse trip. Once a name is the decla
56
66
 
57
67
  ## Table name
58
68
 
59
- The table a model reads. Rails builds it as `table_name_prefix` + the demodulized class name, pluralized unless `pluralize_table_names` is off, + `table_name_suffix`, unless the class assigns one itself or is an STI child, which reads its parent's. The static tier answers the same order from source: an explicit `self.table_name =`, else the table of the model it inherits from, else the prefix and suffix the enclosing modules declare around the file's own basename. That basename is pluralized unless the class, then its superclass chain, then the app sets `pluralize_table_names = false`. The app-wide affixes and pluralize setting come from `ActiveRecordSettings`, which reads config/application.rb, the environment file, the initializers and `on_load(:active_record)` blocks. Never the underscored constant - that turns `Admin::ActionLog` into `admin/action_logs` and `OAuthClientConfig` into `o_auth_client_configs`, neither of which is a table.
69
+ The table a model reads. Rails builds it as `table_name_prefix` + the demodulized class name, pluralized unless `pluralize_table_names` is off, + `table_name_suffix`, unless the class assigns one itself or is an STI child, which reads its parent's. The static tier answers the same order from source: an explicit `self.table_name =`, else the table of the model it inherits from, else the prefix and suffix the enclosing modules declare around the file's own basename. That basename is pluralized unless the class, then its superclass chain, then the app sets `pluralize_table_names = false`. A class on a parent the app does not define (`Tag < ActsAsTaggableOn::Tag`) reads the parent's table, which the static tier takes from the schema: the table it holds among the parent's names and the class's own. The app-wide affixes and pluralize setting come from `ActiveRecordSettings`, which reads config/application.rb, the environment file, the initializers and `on_load(:active_record)` blocks. Never the underscored constant as an answer - that turns `Admin::ActionLog` into `admin/action_logs` and `OAuthClientConfig` into `o_auth_client_configs`, neither of which is a table; a gem parent's underscored name is only a candidate the schema must hold.
60
70
 
61
71
  `TableName` reads the five declarations of one class body (`table_name`, `table_name_prefix`, `table_name_suffix`, `pluralize_table_names` and `primary_key`) and holds the two derivations; walking the namespace and the superclass chain belongs to the caller, because only it has the other files. A reader that starts from a model name instead of a file asks `TableName.for_model_name`, which takes the table the model tier already recorded before falling back to the convention. A module prefix declared outside the model directories is still invisible, an engine's in `lib/**/engine.rb` aside, so the answer stays [STATIC].
62
72
 
@@ -154,3 +164,17 @@ Two senses on different axes. Qualify it; the bare word does not say which.
154
164
  **Standalone install** - how the gem got installed: `gem install rails-ai-context`, driven through its own binary, never in the host app's Gemfile, so the `rails ai:*` rake tasks do not exist. `InstallMode.standalone?` is the decider, and the `standalone:` flag on `McpConfigGenerator` means this - it picks whether the written MCP command is the bare binary or a bundled invocation. `docs/STANDALONE.md` documents this sense only.
155
165
 
156
166
  **Standalone server** - how MCP is served: the entry point that is its own process, `rails ai:serve_http` starting `Server` with `transport: :http`, serving MCP and nothing else. The middleware and the engine controller answer the same requests from inside the app's web server; the standalone server does not need one running. It says nothing about the install - an in-Gemfile app starts it from a rake task. ADR-0003 and `McpEdge` use this sense.
167
+
168
+ ## App root
169
+
170
+ The directory a command reads as the app, decided once by `CLI::AppRoot` before anything reads the working directory, and then made the process's working directory. `--app-path` names it outright and is never walked. Otherwise it is the nearest directory at or above the working directory that passes the strict test, as `bin/rails` and Bundler find theirs: `config/application.rb`, `config/environment.rb`, or a `bin/rails` that boots an app or an engine. A working directory that passes only the loose test (source alone, any `app/**/*.rb`: a packwerk pack, an engine kept as code) is the root itself when no app holds it. The walk up never stops at a pack, `$HOME`, `node_modules`, `vendor/bundle` or a gem install.
171
+
172
+ Not the same as `Rails.root`, which only a booted app has, and not the **workspace** below, which is no app at all.
173
+
174
+ ## Workspace
175
+
176
+ A folder an editor or agent is opened at that is no app and has no app above it, but holds apps one or two levels down (`config/application.rb` only, so an engine or a pack is not one). `init` sets one up as a whole: the AI tools are chosen once, each app gets its own `.rails-ai-context.yml` and context files, since its server reads them from its own root, and the workspace's MCP configs get one server per app, since a client reads them from the folder it opened and from nowhere below. Every other command needs a single app, so it uses the only one or refuses with one `--app-path` command per app.
177
+
178
+ **Server name** - the key an entry has in a config. An app's own config calls its server `rails-ai-context`; a workspace's calls each one `rails-ai-context-<app folder>` (`Install::Workspace.server_names`), with the whole path where two apps share a folder name, and a hash where the name would be too long for the tools. `McpConfigGenerator::OWN_SERVER_NAME` matches every form, and `own_entry?` claims the ones that run the gem's server, so removal never leaves one behind and never takes an entry somebody else named that way. Not the **announced name**, which the running server gives the tool (`config.server_name`, or, in place of its default, `RAILS_AI_CONTEXT_SERVER_NAME` for one process): a workspace server announces its app first, because VS Code names tools after that and keeps 13 characters of it.
179
+
180
+ **Workspace entry** - the server entry itself: `--app-path` relative to the workspace, because the file may be committed; spelled from the tool's own name for its config's folder (`${workspaceFolder}`) where it has one, bare where the tool starts servers in the folder it was launched in. For an in-Gemfile app it adds `BUNDLE_GEMFILE` naming the Gemfile the app's bundle reads (its own, else the shared one its `config/boot.rb` names, else the one `bundle exec` run inside the app would find), because `bundle exec` started in the workspace would never find it. ADR-0005 records the per-tool facts this rests on.
data/README.md CHANGED
@@ -4,9 +4,9 @@
4
4
 
5
5
  *Give your AI coding assistant ground truth about your Rails app*
6
6
 
7
- [![Gem Version](https://img.shields.io/gem/v/rails-ai-context?style=flat-square&color=brightgreen)](https://rubygems.org/gems/rails-ai-context)
8
- [![Downloads](https://img.shields.io/gem/dt/rails-ai-context?style=flat-square&color=blue)](https://rubygems.org/gems/rails-ai-context)
9
- [![CI](https://img.shields.io/github/actions/workflow/status/crisnahine/rails-ai-context/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/crisnahine/rails-ai-context/actions)
7
+ [![Gem Version](https://img.shields.io/gem/v/rails-ai-context?style=flat-square&color=brightgreen&cacheSeconds=21600)](https://rubygems.org/gems/rails-ai-context)
8
+ [![Downloads](https://img.shields.io/gem/dt/rails-ai-context?style=flat-square&color=blue&cacheSeconds=21600)](https://rubygems.org/gems/rails-ai-context)
9
+ [![CI](https://github.com/crisnahine/rails-ai-context/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/crisnahine/rails-ai-context/actions/workflows/ci.yml)
10
10
  [![MCP Registry](https://img.shields.io/badge/MCP_Registry-listed-green?style=flat-square)](https://registry.modelcontextprotocol.io)
11
11
  [![Ruby](https://img.shields.io/badge/Ruby-3.1_to_4.0-CC342D?style=flat-square&logo=ruby&logoColor=white)](https://github.com/crisnahine/rails-ai-context)
12
12
  [![Rails](https://img.shields.io/badge/Rails-7.0_to_8.1-CC0000?style=flat-square&logo=rubyonrails&logoColor=white)](https://github.com/crisnahine/rails-ai-context)
@@ -30,7 +30,7 @@
30
30
  **rails-ai-context** is a Ruby gem that turns your Rails app into the source of truth for AI coding assistants. Instead of guessing your schema, associations, routes and conventions from training data, the assistant asks your app: 45 read-only tools served over [MCP](https://modelcontextprotocol.io) or run from the CLI, plus generated context files for Claude Code, Cursor, GitHub Copilot, OpenCode and Codex CLI.
31
31
 
32
32
  > [!TIP]
33
- > Nothing to add to your Gemfile if you don't want to. `gem install rails-ai-context`, then `rails-ai-context init` inside any Rails app. It also works on an app that won't boot: pass `--no-boot` and the tools answer from the source files. The two that need a live app, `query` and `runtime_info`, say so.
33
+ > Nothing to add to your Gemfile if you don't want to. `gem install rails-ai-context`, then `rails-ai-context init` inside any Rails app, or in a folder of apps to set them all up with one MCP server each. It also works on an app that won't boot: pass `--no-boot` and the tools answer from the source files. The two that need a live app, `query` and `runtime_info`, say so.
34
34
 
35
35
  ## Why
36
36
 
@@ -67,7 +67,7 @@ You catch it, fix it, re-prompt, and something next to it breaks. The tokens are
67
67
  - **Three ways in**: MCP over stdio, MCP mounted inside your Rails app over HTTP, or plain CLI in any terminal.
68
68
  - **Generated context files** for Claude Code, Cursor, GitHub Copilot, OpenCode and Codex CLI, with the MCP config each tool auto-detects on project open.
69
69
  - **Live resources**: `rails://` and `rails-ai-context://` URIs that introspect fresh on every read.
70
- - **Anti-hallucination rules** shipped in every generated context file, on by default.
70
+ - **Anti-hallucination rules** shipped in each AI tool's main context file, in compact and full mode, on by default.
71
71
  - **Static tier**: when the app can't boot, tools answer from `config/routes.rb`, `db/schema.rb`, migrations and source files, and say so.
72
72
  - **Works with real app shapes**: packwerk packs, in-repo engines, multi-database schema dumps, Mongoid, API-only apps.
73
73
  - **Custom tools**: register your own `MCP::Tool` classes next to the built-in ones and test them with the bundled `TestHelper`.
@@ -78,6 +78,7 @@ You catch it, fix it, re-prompt, and something next to it breaks. The tokens are
78
78
 
79
79
  - Ruby 3.1 or newer
80
80
  - Rails 7.0 or newer
81
+ - Linux, macOS or Windows (see [Operating systems](docs/COMPATIBILITY.md#operating-systems))
81
82
  - Optional: `brakeman` for `security_scan`, `listen` for `watch`, `ripgrep` for faster `search_code`
82
83
 
83
84
  ### Install in the Gemfile
@@ -87,7 +88,7 @@ bundle add rails-ai-context --group development
87
88
  rails generate rails_ai_context:install
88
89
  ```
89
90
 
90
- The generator asks which AI tools you use and what to write, then creates the context files, the MCP config for each tool, `config/initializers/rails_ai_context.rb` and `.rails-ai-context.yml`. In a git repo with no pre-commit hook it also offers to install one. An empty answer to the tools question selects all five, and `--defaults` takes the default for each question. Re-running it is safe; it keeps what you have and adds what is missing. Every step is in [What the install generator does](docs/GUIDE.md#what-the-install-generator-does).
91
+ The generator asks which AI tools you use and what to write, then creates the context files, the MCP config for each tool, `config/initializers/rails_ai_context.rb` and `.rails-ai-context.yml`. In a git repo with no pre-commit hook it also offers to install one. An empty answer to the tools question selects all five, and `--defaults` takes the default for each question. Re-running it is safe; it keeps what you have and adds what is missing, and in a context file you wrote yourself, such as `CLAUDE.md` or `app/models/AGENTS.md`, it rewrites only the section between its `<!-- BEGIN/END rails-ai-context -->` markers. Every step is in [What the install generator does](docs/GUIDE.md#what-the-install-generator-does).
91
92
 
92
93
  Keeping your own `CLAUDE.md` and `AGENTS.md`? `rails generate rails_ai_context:install --mcp-only` writes the MCP config and leaves every context file alone.
93
94
 
@@ -129,7 +130,7 @@ bundle exec rails-ai-context serve # in-Gemfile
129
130
  rails-ai-context serve # standalone
130
131
  ```
131
132
 
132
- `rails ai:serve` starts the same server from rake.
133
+ On Windows each config starts it through `cmd /c`, since a gem's executables there are batch files. `rails ai:serve` starts the same server from rake.
133
134
 
134
135
  ### MCP over HTTP
135
136
 
@@ -137,13 +138,16 @@ Mount the server inside your app. It inherits your routing, auth and middleware,
137
138
 
138
139
  ```ruby
139
140
  # config/routes.rb
140
- mount RailsAiContext::Engine, at: "/mcp"
141
+ mount RailsAiContext::Engine, at: "/mcp" if defined?(RailsAiContext::Engine)
141
142
  ```
142
143
 
144
+ 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.
145
+
146
+ Outside development and test (production, staging) the endpoint refuses every request with a 403 unless you set `config.allow_http_in_production = true`, since it has no authentication of its own; see [Security](docs/SECURITY.md#mcp-http-transport).
147
+
143
148
  Point the client at `http://localhost:3000/mcp`. There is also a standalone HTTP process: `rails-ai-context serve --transport http --port 6029`.
144
149
 
145
- > [!WARNING]
146
- > Each connected client that opens the SSE channel holds one server thread for the life of the connection. Fine for development; raise Puma's thread count or use the standalone HTTP process if several clients share the app.
150
+ The mounted engine opens no server-push channel (it answers that `GET` with 405, as the MCP spec allows), so a connected client holds a server thread only while one of its requests runs.
147
151
 
148
152
  ### CLI
149
153
 
@@ -169,7 +173,7 @@ Tool names resolve loosely: `schema`, `get_schema` and `rails_get_schema` all wo
169
173
  | `rails ai:watch` | `rails-ai-context watch` | Regenerate on file change |
170
174
  | `rails 'ai:preset[NAME]'` | `rails-ai-context preset NAME` | Run a multi-tool preset (`architecture`, `debugging`, `migration`) |
171
175
 
172
- Flags shared by the app-reading commands: `--app-path PATH` to target another directory, `--environment ENV` to set `RAILS_ENV`, and `--no-boot` to skip the boot attempt and answer from source. Full list in the [CLI reference](docs/CLI.md).
176
+ The binary finds the app the way `bin/rails` does, so it runs from any subdirectory, and from a folder holding one app. Flags shared by the app-reading commands: `--app-path PATH` to target another directory, `--environment ENV` to set `RAILS_ENV`, and `--no-boot` to skip the boot attempt and answer from source. Full list in the [CLI reference](docs/CLI.md).
173
177
 
174
178
  ## Tools
175
179
 
@@ -213,7 +217,7 @@ Plus 9 static resources: `rails://schema`, `routes`, `conventions`, `gems`, `con
213
217
 
214
218
  ## Anti-hallucination rules
215
219
 
216
- Every generated context file (`CLAUDE.md`, `.cursor/rules/`, `.github/instructions/`, `AGENTS.md`) ships with six rules the assistant reads before writing code:
220
+ Each AI tool's main context file (`CLAUDE.md`, `AGENTS.md` for OpenCode and Codex, `.cursorrules` and `.cursor/rules/rails-mcp-tools.mdc` for Cursor, `.github/copilot-instructions.md`) ships with six rules the assistant reads before writing code, in compact and full mode alike. With `generate_root_files` off, `.claude/rules/rails-mcp-tools.md` and `.github/instructions/rails-mcp-tools.instructions.md` carry them instead:
217
221
 
218
222
  1. Verify before you write. Never reference a column, association, route, helper, method, class, partial or gem that a tool call in this turn did not confirm.
219
223
  2. Mark every assumption with `[ASSUMPTION]`. "I'd need to check X first" is a good answer.
data/Rakefile CHANGED
@@ -14,6 +14,7 @@ RSpec::Core::RakeTask.new(:spec)
14
14
  # bundle exec rake e2e:in_gemfile # just Path A
15
15
  # bundle exec rake e2e:standalone # just Path B
16
16
  # bundle exec rake e2e:zero_config # just Path C
17
+ # bundle exec rake e2e:workspace # just Path D: init in a folder of apps
17
18
  # bundle exec rake e2e:mcp # stdio + HTTP protocol specs
18
19
  namespace :e2e do
19
20
  RSpec::Core::RakeTask.new(:in_gemfile) do |t|
@@ -31,6 +32,11 @@ namespace :e2e do
31
32
  ENV["E2E"] = "1"
32
33
  end
33
34
 
35
+ RSpec::Core::RakeTask.new(:workspace) do |t|
36
+ t.pattern = "spec/e2e/workspace_install_spec.rb"
37
+ ENV["E2E"] = "1"
38
+ end
39
+
34
40
  RSpec::Core::RakeTask.new(:mcp) do |t|
35
41
  t.pattern = "spec/e2e/mcp_{stdio,http}_protocol_spec.rb"
36
42
  ENV["E2E"] = "1"
@@ -1,49 +1,93 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "delegate"
4
-
5
3
  module RailsAiContext
6
4
  # Rails controller for serving MCP over Streamable HTTP.
7
5
  # Alternative to the Rack middleware - integrates with Rails routing,
8
6
  # authentication, and middleware stack.
9
7
  #
10
- # Mount in routes: mount RailsAiContext::Engine, at: "/mcp"
8
+ # Mount in routes, guarded so the routes file still loads where the gem is
9
+ # not (production, with the gem in the development group, or after it is
10
+ # removed): mount RailsAiContext::Engine, at: "/mcp" if defined?(RailsAiContext::Engine)
11
+ #
12
+ # Every request is answered whole, on the thread Rails runs it on. The SDK
13
+ # answers a request inside a session with a body that writes an SSE stream;
14
+ # this controller runs that body into a buffer and sends the text once the
15
+ # call is done, as a plain string, which every middleware in the app's
16
+ # stack passes through (Rails 7's Rack::ETag 500'd on the callable itself).
17
+ #
18
+ # It used to stream through ActionController::Live, which runs the action
19
+ # on a thread of its own, and that deadlocked a development server. The
20
+ # request's thread holds a share of the code-loading interlock and waits
21
+ # for the Live thread to answer; the Live thread needs a share of its own
22
+ # to start; once another request has queued a code reload after an edit,
23
+ # no new share is granted until the reload runs, and the reload waits for
24
+ # the request's thread to let go of its share. With several clients
25
+ # calling while an agent edited, every request hung within seconds, and
26
+ # SIGTERM waited on them. Live's thread also kept the database connection
27
+ # a call used, and from Rails 8.0 that thread lives on in a pool, so each
28
+ # call after an edit waited ten seconds for the reload to give up on it.
29
+ # On the request's own thread the call needs no second share, the reload
30
+ # waits for it to finish, and Rails returns its connection when the
31
+ # request completes.
32
+ #
33
+ # The cost: what a tool sends during a call reaches the client with the
34
+ # answer, not as it is sent. A custom tool's progress or log notifications
35
+ # arrive together at the end, and a request a tool makes of the client
36
+ # mid-call (sampling, elicitation, roots) fails at once, since no answer
37
+ # could reach it before the call is over (McpEdge::AnsweredWhole). This
38
+ # gem's own tools send neither, and the engine opens no stream of its own
39
+ # (refuse_server_push).
11
40
  class McpController < ActionController::API
12
- # Live: the transport answers SSE-mode requests with a Rack 3 streaming
13
- # body (a Proc that writes to a stream). Handing that Proc to a plain
14
- # controller works only where the response body passes through untouched;
15
- # Rails 7.x's response buffer feeds it through Rack::ETag, which 500s on
16
- # the callable. Live's stream is exactly the interface the Proc expects
17
- # (write/close) and keeps every middleware away from the body on all
18
- # supported Rails versions.
19
- include ActionController::Live
20
-
21
- # The MCP SDK's SSE writer calls stream.flush after every event, but
22
- # Live's buffer writes straight through to the client and defines no
23
- # flush - the NoMethodError would 500 the request after the payload was
24
- # already delivered and tear down long-lived GET streams.
25
- class FlushableStream < SimpleDelegator
26
- def flush; end
41
+ # What the SDK's SSE writer needs of a stream - write, flush, close,
42
+ # closed? - held in memory until the call is done. A write after close
43
+ # raises IOError, as a closed socket's does, so the SDK drops a stream
44
+ # the answer has already left on.
45
+ class BufferedStream
46
+ def initialize
47
+ @chunks = []
48
+ @closed = false
49
+ end
50
+
51
+ def write(chunk)
52
+ raise IOError, "closed stream" if @closed
53
+
54
+ text = chunk.to_s
55
+ @chunks << text
56
+ text.bytesize
57
+ end
58
+
59
+ def flush
60
+ self
61
+ end
62
+
63
+ def close
64
+ @closed = true
65
+ nil
66
+ end
67
+
68
+ def closed?
69
+ @closed
70
+ end
71
+
72
+ def string
73
+ @chunks.join
74
+ end
27
75
  end
28
76
 
29
77
  def handle
78
+ if (refused = McpEdge.production_refusal || McpEdge.host_refusal(request.env))
79
+ return answer_rack(refused)
80
+ end
81
+ return refuse_server_push if request.get?
82
+
30
83
  Tools::BaseTool.with_session_for(request.env) do
31
84
  serve_transport
32
85
  end
33
86
  rescue => e
34
- # Once the response is committed the status and headers are already on
35
- # the wire, so a JSON-RPC frame written here cannot reach the client.
36
- # Worse, assigning a body swaps the stream out from under the thread
37
- # still draining the old one, turning a truncated SSE stream into a
38
- # garbled one. The streaming branch's ensure always closes the stream,
39
- # and closing commits, so every streaming failure lands here committed.
40
- # Hand those to Live, which logs them with a backtrace and tears the
41
- # connection down.
42
- raise if response.committed?
43
-
44
87
  # A transport failure must still answer in JSON-RPC shape. Without this
45
88
  # the exception escapes into a generic Rails 500 (HTML), breaking the
46
- # client's JSON-RPC loop.
89
+ # client's JSON-RPC loop. Nothing has been sent yet, whatever the body
90
+ # had written, so the frame replaces it whole.
47
91
  RailsAiContext.log_warn "[rails-ai-context] MCP request failed: #{e.class}: #{e.message}"
48
92
  self.status = 500
49
93
  response.headers["Content-Type"] = "application/json"
@@ -52,47 +96,39 @@ module RailsAiContext
52
96
 
53
97
  private
54
98
 
99
+ # The body is read before the status and headers are set, so a body
100
+ # that fails leaves nothing of the SDK's answer on the error frame.
55
101
  def serve_transport
56
- status_code, rack_headers, body = self.class.mcp_transport.handle_request(request)
102
+ status_code, rack_headers, body = McpEdge.engine_transport.handle_request(request)
103
+ text = read_body(body)
57
104
  self.status = status_code
58
105
  apply_transport_headers(rack_headers)
106
+ self.response_body = text
107
+ end
108
+
109
+ # The SDK's answer as one string: a plain body (initialize, an error, a
110
+ # notification's 202) joined, and a streaming one - the SSE stream a
111
+ # request inside a session gets - run here into a buffer. The tool runs
112
+ # inside it, on this thread, inside this request's session scope. The
113
+ # buffer is closed once the body returns, as the answer leaves with it.
114
+ def read_body(body)
59
115
  if body.respond_to?(:each)
60
- # Plain enumerable body (initialize, errors, JSON mode): join to a
61
- # string so Content-Length/ETag semantics stay conventional.
62
116
  chunks = []
63
117
  body.each { |chunk| chunks << chunk }
64
- body.close if body.respond_to?(:close)
65
- self.response_body = chunks.join
118
+ chunks.join
66
119
  elsif body.respond_to?(:call)
67
- stream = response.stream
68
- stream = FlushableStream.new(stream) unless stream.respond_to?(:flush)
120
+ stream = BufferedStream.new
69
121
  begin
70
122
  body.call(stream)
71
- # A GET opens the server-push channel: the transport registers the
72
- # stream and returns, expecting it to outlive this call. Live closes
73
- # the response when the action returns, so hold the thread until the
74
- # transport's keepalive (or the client) closes the stream. Live also
75
- # sends headers only on the first write - the transport writes
76
- # nothing until its first keepalive ping, so commit with an SSE
77
- # comment up front or clients sit waiting on headers.
78
- if request.get?
79
- begin
80
- stream.write(": connected\n\n")
81
- rescue IOError
82
- nil
83
- end
84
- wait_for_stream_close
85
- end
86
123
  ensure
87
- begin
88
- response.stream.close
89
- rescue StandardError
90
- nil
91
- end
124
+ stream.close
92
125
  end
126
+ stream.string
93
127
  else
94
- self.response_body = body
128
+ body
95
129
  end
130
+ ensure
131
+ body.close if body.respond_to?(:close)
96
132
  end
97
133
 
98
134
  # Rack 3 transports name their headers in lowercase, and the MCP SDK
@@ -111,43 +147,35 @@ module RailsAiContext
111
147
  end
112
148
  end
113
149
 
114
- def wait_for_stream_close
115
- sleep 0.5 until stream_finished?
116
- rescue IOError
117
- nil
150
+ # A Rack triple answered whole, for the refusals McpEdge builds.
151
+ def answer_rack((status, headers, body))
152
+ self.status = status
153
+ apply_transport_headers(headers)
154
+ self.response_body = body.join
118
155
  end
119
156
 
120
- # A client hangup aborts the buffer instead of closing it, so `closed?`
121
- # alone leaves this thread parked until the transport's next keepalive
122
- # write notices the hangup - up to the keepalive interval per dropped
123
- # client. Live's buffer reports the hangup through `connected?`; the plain
124
- # buffer used off the streaming path does not define it, so ask first.
125
- def stream_finished?
126
- stream = response.stream
127
- return true if stream.closed?
128
-
129
- stream.respond_to?(:connected?) && !stream.connected?
157
+ # The engine has nothing to push: live reload, the one thing that sends
158
+ # a server-initiated message, runs only in the standalone server. Held
159
+ # open, the GET channel carried keepalive pings and nothing else, cost a
160
+ # server thread per connected client, and a client that stayed connected
161
+ # kept `rails server` from stopping. A server without the channel answers
162
+ # the GET with 405, which clients read as "POST only".
163
+ def refuse_server_push
164
+ self.status = 405
165
+ response.headers["Allow"] = "POST, DELETE"
166
+ response.headers["Content-Type"] = "application/json"
167
+ self.response_body = McpEdge.error_frame(McpEdge::INVALID_REQUEST,
168
+ "Method not allowed: this endpoint opens no server-push stream. Send requests with POST.")
130
169
  end
131
170
 
132
171
  class << self
133
- # Class-level memoization - transport persists across requests.
134
- # Thread-safe: MCP::Server and transport are stateless for reads.
135
172
  def mcp_transport
136
- @transport_mutex.synchronize { @mcp_transport ||= McpEdge.build_transport }
173
+ McpEdge.engine_transport
137
174
  end
138
175
 
139
176
  def reset_transport!
140
- @transport_mutex.synchronize { @mcp_transport = nil }
141
- end
142
-
143
- private
144
-
145
- def inherited(subclass)
146
- super
147
- subclass.instance_variable_set(:@transport_mutex, Mutex.new)
177
+ McpEdge.reset_engine_transport!
148
178
  end
149
179
  end
150
-
151
- @transport_mutex = Mutex.new
152
180
  end
153
181
  end
data/config/routes.rb CHANGED
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  RailsAiContext::Engine.routes.draw do
4
- # The engine is not namespace-isolated, so the controller must be
5
- # referenced by its full path or Rails resolves a top-level McpController.
6
- match "/", to: "rails_ai_context/mcp#handle", via: :all
4
+ # The class, not "rails_ai_context/mcp", which the router camelizes with the app's inflections.
5
+ match "/", to: RailsAiContext::McpController.action(:handle), via: :all
7
6
  end
data/docs/ARCHITECTURE.md CHANGED
@@ -26,7 +26,7 @@ graph TD
26
26
  subgraph engine["Introspection Engine"]
27
27
  direction LR
28
28
  I["Introspectors\n40 modules\nPresets\nCached"]
29
- AST["AST Engine\nPrism\n44 listeners\nConfidence tags"]
29
+ AST["AST Engine\nPrism\n45 listeners\nConfidence tags"]
30
30
  H["Hydration Layer\nSchema hints\ninjected into\ntool responses"]
31
31
  end
32
32
 
@@ -115,7 +115,7 @@ Five modules answer questions every introspector used to answer for itself:
115
115
 
116
116
  - **PathResolver** - where a kind of app code lives: `app/<kind>`, packs, and the in-repo code roots read from the tree (a directory holding its own `app/` plus a gemspec, `plugin.rb` or `lib/**/engine.rb`), memoized per root
117
117
  - **SourceScan** - one walk over a kind of app source, across every directory `PathResolver` resolves: `paths` stats, `each` reads, `classes` names by the declared constant
118
- - **EagerLoad** - loads a directory's constants for a booted-tier walk, one file at a time, so an unloadable file costs only itself
118
+ - **EagerLoad** - loads a directory's constants for a booted-tier walk, one file at a time, so an unloadable file costs only itself. A file Prism finds a syntax error in is never required, nor is one whose class body names its constant (a subclass, an includer); each is left out with the reason, which a model's or a controller's answer gives in its place. A tool that looks an app class up by name goes through `EagerLoad.constant`, which loads it as `constantize` would and never requires such a file
119
119
  - **GemLock** - which gems the app resolved and at what version, read once per lockfile and matched by exact name across GEM, GIT and PATH
120
120
  - **PackageJson** - which npm packages the app depends on, read once per `package.json`: present means named in `dependencies` or `devDependencies`, so an `overrides` pin is not a dependency, and `@tailwindcss/vite` counts as tailwindcss
121
121
 
@@ -170,7 +170,7 @@ rails-ai-context://views/posts/show.html.erb → template content
170
170
  rails-ai-context://routes/posts → filtered routes
171
171
  ```
172
172
 
173
- Every resolve call introspects fresh - zero stale data.
173
+ Every resolve call introspects afresh, once the server has reloaded the app's code for any edit (see Caching).
174
174
 
175
175
  ### Hydration Layer (`lib/rails_ai_context/hydrators/`)
176
176
 
@@ -213,7 +213,8 @@ Result: controller and view tools automatically include relevant schema informat
213
213
 
214
214
  Thor-based CLI that works standalone (no Gemfile entry):
215
215
 
216
- - `EntryBoot` - Finds and boots the host Rails app for the standalone binary, or says which tier it fell back to
216
+ - `AppRoot` - Decides which app a command reads before anything reads the working directory: `--app-path` as given, else the working directory, else the nearest app root above it, else the one app below it (several are refused by name, and `init` sets them all up)
217
+ - `EntryBoot` - Boots the app `AppRoot` chose for the standalone binary, or says which tier it fell back to
217
218
  - `ToolRunner` - Parses CLI args, resolves tool names, executes tools, formats output
218
219
  - Supports `--json` mode for machine-readable output
219
220
  - Same 45 tools available as MCP and CLI
@@ -232,7 +233,7 @@ Four cache layers:
232
233
  3. **Run cache** (`RunCache`) - thread-local, lives for one `Introspector#call` or `generate_context`: file lists, stats and directory answers every section would otherwise ask again
233
234
  4. **Session cache** (`BaseTool.SESSION_CONTEXT`) - Mutex-protected call history, resets on server restart
234
235
 
235
- `LiveReload` watches files and calls `reset_all_caches!` when changes are detected.
236
+ Every server - stdio, HTTP, or the endpoints mounted in the app - checks the files at the start of each tool call and resource read (`BaseTool.refresh_if_files_changed!`, through `Fingerprinter::Snapshot`), reloading the app's code and resetting the caches when they moved, so an answer sees every edit made before its call. That check runs outside the introspection cache's mutex, since a code reload waits for running calls, and a call shares only a check that began after it arrived. `LiveReload` watches the same files and tells clients when they change; it loads no code and drops no cache.
236
237
 
237
238
  ### Fingerprinter (`lib/rails_ai_context/fingerprinter.rb`)
238
239
 
@@ -241,6 +242,7 @@ SHA256-based change detection:
241
242
  - Watches: `app/`, `config/`, `db/`, `lib/`, `rakelib/`, `test/`, `spec/`, the Gemfile and Gemfile.lock (or `gems.rb` and `gems.locked`), `package.json`, `tsconfig.json`, `config.ru`, the Rakefile
242
243
  - Computes a composite fingerprint from all watched files
243
244
  - Used by introspection cache and live reload to detect actual changes
245
+ - `Fingerprinter::Snapshot` answers the same question before every call by stat alone, keeping each directory's listing while the directory's stat holds; a file young enough to have been written twice in one clock tick has its content compared too, and a directory linked in from the app's repository is followed as the readers follow it, its link asked where it leads at every look
244
246
 
245
247
  ## Key design decisions
246
248