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
@@ -4,7 +4,7 @@ require "did_you_mean"
4
4
 
5
5
  require "mcp"
6
6
  require "active_support"
7
- require "active_support/number_helper"
7
+ require "bigdecimal"
8
8
 
9
9
  module RailsAiContext
10
10
  module Tools
@@ -70,6 +70,16 @@ module RailsAiContext
70
70
  # for thread safety in multi-threaded servers (e.g., Puma).
71
71
  SHARED_CACHE = { mutex: Mutex.new }
72
72
 
73
+ # What a server last saw of its app's files (a Fingerprinter::Snapshot),
74
+ # when the check now running began, and when the last finished one
75
+ # did. The snapshot is nil until a server starts, so a process that is
76
+ # no server (the CLI, rake) never walks the tree. `stale_code` is what a
77
+ # server that cannot reload found changed since it loaded it
78
+ # (CodeReloader.changed_code), and `compile_cached` what a server that
79
+ # can found rewritten where Bootsnap's compile cache cannot see it
80
+ # (CodeReloader.compile_cached).
81
+ FILE_CHECK = { mutex: Mutex.new, done: ConditionVariable.new, snapshot: nil, running: nil, finished: nil, stale_code: nil, compile_cached: nil }
82
+
73
83
  # Session-level context tracking. Lets AI avoid redundant queries
74
84
  # by recording what tools have been called with what params.
75
85
  # In-memory only - resets on server restart.
@@ -80,8 +90,11 @@ module RailsAiContext
80
90
  # Mcp-Session-Id gets its own bucket and one client's history stays out
81
91
  # of another's.
82
92
  # A plain Hash, not one with a default block: a default block writes on
83
- # lookup, so merely reading a session's history created it.
84
- SESSION_CONTEXT = { mutex: Mutex.new, queries: {} }
93
+ # lookup, so merely reading a session's history created it. `dropped`
94
+ # counts, per session, the queries its record no longer keeps. `asked`
95
+ # numbers queries in the order first asked, which a timestamp cannot
96
+ # tell apart within one second.
97
+ SESSION_CONTEXT = { mutex: Mutex.new, queries: {}, dropped: {}, asked: 0 }
85
98
 
86
99
  DEFAULT_SESSION = :default
87
100
 
@@ -91,6 +104,10 @@ module RailsAiContext
91
104
  # MAX_SESSIONS is the one least likely to ask about its own history.
92
105
  MAX_SESSIONS = 100
93
106
  MAX_SESSION_ID_LENGTH = 200
107
+ # What one session's record holds is capped the same way: its latest
108
+ # queries, each param kept as an answer echoes it. A client sending a
109
+ # 100 KB argument on every call otherwise grew the record without end.
110
+ MAX_SESSION_QUERIES = 200
94
111
 
95
112
  # One row of the generated tool guide, declared beside the tool's own
96
113
  # description so adding a tool touches one file. `order` fixes where the
@@ -119,6 +136,15 @@ module RailsAiContext
119
136
  RailsAiContext.configuration
120
137
  end
121
138
 
139
+ # Every tool's arguments are checked through ArgumentSchema, so an
140
+ # app whose locale files do not load still gets argument errors.
141
+ def input_schema(*args)
142
+ return super if args.empty?
143
+
144
+ value = args.first
145
+ super(value.is_a?(Hash) ? ArgumentSchema.new(value) : value)
146
+ end
147
+
122
148
  # A bare `Rails.env` raises NameError under --no-boot or early boot death.
123
149
  def rails_env_name
124
150
  RailsAiContext.environment_name
@@ -145,12 +171,9 @@ module RailsAiContext
145
171
  ttl = RailsAiContext.configuration.cache_ttl
146
172
 
147
173
  # Fast path: within TTL window, trust the cache and skip the
148
- # fingerprint walk entirely. Fingerprinter stats every *.rb file
149
- # in WATCHED_DIRS (plus, in dev-mode path: installs, every file
150
- # in the gem's own lib/ tree) - measured at ~12ms per call in
151
- # dev mode, ~0.5ms in production. Since LiveReload fires
152
- # reset_all_caches! on actual file-change events, stale-cache
153
- # risk during a short TTL window is already covered.
174
+ # fingerprint walk entirely. A server has already dropped it if a
175
+ # file changed: each call asks the files at its start
176
+ # (refresh_if_files_changed!).
154
177
  if SHARED_CACHE[:context] && (now - SHARED_CACHE[:timestamp]) < ttl
155
178
  return SHARED_CACHE[:context].deep_dup
156
179
  end
@@ -182,7 +205,8 @@ module RailsAiContext
182
205
  end
183
206
  end
184
207
 
185
- # Reset the shared cache. Used by LiveReload to invalidate on file change.
208
+ # Every cache that describes the app's files, dropped once a call's
209
+ # check finds them moved.
186
210
  def reset_all_caches!
187
211
  reset_cache!
188
212
  session_reset!
@@ -190,6 +214,95 @@ module RailsAiContext
190
214
  PathResolver.clear_code_roots
191
215
  end
192
216
 
217
+ # Called by every server as it starts - stdio, HTTP, the endpoints
218
+ # inside the app - live reload or not. From here on each call checks
219
+ # the files against what they are now.
220
+ #
221
+ # A server that cannot read its files still serves; it only goes
222
+ # without the check.
223
+ def check_files_per_call!(app)
224
+ snapshot = Fingerprinter::Snapshot.new(app.root)
225
+ snapshot.changed?
226
+ FILE_CHECK[:mutex].synchronize { FILE_CHECK.merge!(snapshot: snapshot, running: nil, finished: nil) }
227
+ rescue StandardError => e
228
+ RailsAiContext.debug_fail(e, nil, label: "check_files_per_call!")
229
+ end
230
+
231
+ # Before a call reads anything, it sees every edit made before it
232
+ # began: when a file moved, the app's code is reloaded on the
233
+ # calling thread and the caches dropped. Live reload only tells
234
+ # clients: `listen` delivers a change after its debounce, a second
235
+ # and a half on, and an agent that edits a model and asks about it
236
+ # at once was answered from before the edit.
237
+ #
238
+ # The check is a stat of each watched directory and file
239
+ # (Fingerprinter::Snapshot): about a millisecond on a small app, about
240
+ # 30 at 10,000 files. Only a check that began after a call did can
241
+ # stand in for that call's own, so two calls one after the other each
242
+ # check, and concurrent calls wait for one check together. A tool
243
+ # another tool calls is part of the first call and asks nothing.
244
+ #
245
+ # Outside SHARED_CACHE's mutex, unlike the TTL walk: a code reload
246
+ # waits for running calls to finish, and they may be waiting on it.
247
+ #
248
+ # An app that cannot reload keeps what reflection read at boot, so
249
+ # instead the call notes which of its files changed since they were
250
+ # loaded, for every answer to name (stale_code_note). One that can
251
+ # notes the files whose reload Bootsnap answers with their code from
252
+ # before the last write.
253
+ def refresh_if_files_changed!
254
+ return if RunCache.active?
255
+
256
+ snapshot = await_check(Process.clock_gettime(Process::CLOCK_MONOTONIC))
257
+ return unless snapshot
258
+
259
+ begin
260
+ changed = snapshot.changed?
261
+ react_to_change(snapshot) if changed
262
+ done = true
263
+ ensure
264
+ FILE_CHECK[:mutex].synchronize do
265
+ FILE_CHECK[:finished] = FILE_CHECK[:running] if done
266
+ FILE_CHECK[:running] = nil
267
+ FILE_CHECK[:done].broadcast
268
+ end
269
+ end
270
+ rescue StandardError => e
271
+ RailsAiContext.debug_fail(e, nil, label: "refresh_if_files_changed!")
272
+ end
273
+
274
+ # Nil once a check that began after the call arrived has finished,
275
+ # since it saw every edit made before; otherwise the snapshot, for
276
+ # this call to check with. A call that finds a check running waits
277
+ # for it, and then for the next if that one began too early.
278
+ private def await_check(arrived)
279
+ FILE_CHECK[:mutex].synchronize do
280
+ loop do
281
+ return nil unless FILE_CHECK[:snapshot]
282
+ return nil if FILE_CHECK[:finished] && FILE_CHECK[:finished] >= arrived
283
+
284
+ unless FILE_CHECK[:running]
285
+ FILE_CHECK[:running] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
286
+ return FILE_CHECK[:snapshot]
287
+ end
288
+ FILE_CHECK[:done].wait(FILE_CHECK[:mutex])
289
+ end
290
+ end
291
+ end
292
+
293
+ private def react_to_change(snapshot)
294
+ if CodeReloader.reloadable?
295
+ # A request the app serves already ran Rails' own reloader.
296
+ CodeReloader.reload! unless CodeReloader.inside_app_executor?
297
+ cached = CodeReloader.compile_cached(snapshot.same_stat_rewrites)
298
+ FILE_CHECK[:mutex].synchronize { FILE_CHECK[:compile_cached] = cached }
299
+ else
300
+ changed = CodeReloader.changed_code
301
+ FILE_CHECK[:mutex].synchronize { FILE_CHECK[:stale_code] = changed }
302
+ end
303
+ reset_all_caches!
304
+ end
305
+
193
306
  # ── Session context helpers ──────────────────────────────────────
194
307
 
195
308
  # Run a block against one conversation's session record. The HTTP
@@ -223,27 +336,59 @@ module RailsAiContext
223
336
  end
224
337
 
225
338
  def session_record(tool_name, params, summary = nil)
339
+ params = recorded_params(params)
226
340
  SESSION_CONTEXT[:mutex].synchronize do
227
341
  bucket = touch_session
228
342
  evict_oldest_sessions
229
343
  key = session_key(tool_name, params)
230
- existing = bucket[key]
344
+ # Taken out and put back, so the bucket runs least recently used
345
+ # first and the cap below drops the query asked longest ago.
346
+ existing = bucket.delete(key)
231
347
  if existing
232
348
  existing[:call_count] = (existing[:call_count] || 1) + 1
233
349
  existing[:last_timestamp] = Time.now.iso8601
234
350
  existing[:summary] = summary if summary
351
+ bucket[key] = existing
235
352
  else
236
353
  bucket[key] = {
237
354
  tool: tool_name.to_s,
238
355
  params: params,
239
356
  call_count: 1,
357
+ asked: SESSION_CONTEXT[:asked] += 1,
240
358
  timestamp: Time.now.iso8601,
241
359
  summary: summary
242
360
  }
361
+ drop_oldest_queries(bucket)
243
362
  end
244
363
  end
245
364
  end
246
365
 
366
+ # How many queries the calling session's record has dropped to stay
367
+ # within MAX_SESSION_QUERIES.
368
+ def session_dropped
369
+ SESSION_CONTEXT[:mutex].synchronize { SESSION_CONTEXT[:dropped].fetch(current_session, 0) }
370
+ end
371
+
372
+ # Each param as an answer would echo it, so the record keeps no more
373
+ # of a 100 KB argument than a reply does.
374
+ def recorded_params(params)
375
+ return echo_input(params) if params.is_a?(String)
376
+ return params unless params.is_a?(Hash)
377
+
378
+ params.transform_values { |value| value.to_s.length > ECHO_LENGTH ? echo_input(value) : value }
379
+ end
380
+ private :recorded_params
381
+
382
+ # Called with the mutex held.
383
+ def drop_oldest_queries(bucket)
384
+ dropped = SESSION_CONTEXT[:dropped]
385
+ while bucket.size > MAX_SESSION_QUERIES
386
+ bucket.shift
387
+ dropped[current_session] = dropped.fetch(current_session, 0) + 1
388
+ end
389
+ end
390
+ private :drop_oldest_queries
391
+
247
392
  # Deep copies: the entries stay live inside the record and keep being
248
393
  # mutated by later calls, so handing the originals out would let a
249
394
  # caller's snapshot change under it.
@@ -253,9 +398,22 @@ module RailsAiContext
253
398
  end
254
399
  end
255
400
 
401
+ # Every conversation's record: for a change that makes every earlier
402
+ # answer stale (live reload), and for a test that starts clean.
256
403
  def session_reset!
257
404
  SESSION_CONTEXT[:mutex].synchronize do
258
405
  SESSION_CONTEXT[:queries].clear
406
+ SESSION_CONTEXT[:dropped].clear
407
+ end
408
+ end
409
+
410
+ # The calling conversation's record alone, which is what a client's
411
+ # `action: "reset"` asks for. One HTTP process serves every client,
412
+ # and clearing them all let one client empty the others' records.
413
+ def current_session_reset!
414
+ SESSION_CONTEXT[:mutex].synchronize do
415
+ SESSION_CONTEXT[:queries].delete(current_session)
416
+ SESSION_CONTEXT[:dropped].delete(current_session)
259
417
  end
260
418
  end
261
419
 
@@ -274,7 +432,7 @@ module RailsAiContext
274
432
  # Called with the mutex held.
275
433
  def evict_oldest_sessions
276
434
  queries = SESSION_CONTEXT[:queries]
277
- queries.shift while queries.size > MAX_SESSIONS
435
+ SESSION_CONTEXT[:dropped].delete(queries.shift.first) while queries.size > MAX_SESSIONS
278
436
  end
279
437
  private :evict_oldest_sessions
280
438
 
@@ -307,7 +465,7 @@ module RailsAiContext
307
465
  def not_found_response(type, name, available, recovery_tool: nil, note: nil)
308
466
  # Don't suggest the exact same string the user typed - that's useless
309
467
  suggestions = find_closest_matches(name, available) - [ name ]
310
- lines = [ "#{type} '#{name}' not found." ]
468
+ lines = [ "#{type} '#{echo_input(name)}' not found." ]
311
469
  if suggestions.size == 1
312
470
  lines << "Did you mean '#{suggestions.first}'?"
313
471
  elsif suggestions.any?
@@ -372,8 +530,8 @@ module RailsAiContext
372
530
  def response_text(response)
373
531
  first = response.content.first
374
532
  text = first.is_a?(Hash) ? first[:text].to_s : ""
375
- # The composing tool's own response carries the tier banner, once.
376
- (banner = static_tier_banner) ? text.gsub(banner, "") : text
533
+ # The composing tool's own response carries the banners, once.
534
+ [ static_tier_banner, stale_code_banner ].compact.reduce(text) { |stripped, banner| stripped.gsub(banner, "") }
377
535
  end
378
536
 
379
537
  # One-line banner listing introspectors that failed during context
@@ -472,6 +630,35 @@ module RailsAiContext
472
630
  "\n\n---\n_#{note}_"
473
631
  end
474
632
 
633
+ # The same, once app code changed under a server that cannot reload:
634
+ # what reflection read is the code as it booted, whatever the file
635
+ # says now. Validations and callbacks are read off the source, so
636
+ # they follow the edit; associations and enums do not. A server that
637
+ # reloads says the same of a file whose reload Bootsnap answered with
638
+ # the code from before its last save (CodeReloader.compile_cached).
639
+ def stale_code_banner
640
+ note = stale_code_note
641
+ note && "\n\n---\n_#{note}_"
642
+ end
643
+
644
+ def stale_code_note
645
+ if (files = FILE_CHECK[:stale_code])&.any?
646
+ "App code changed since this server booted (#{named_files(files)}); RAILS_ENV=#{RailsAiContext.environment_name} " \
647
+ "does not reload code, so what reflection reads, such as associations and enums, is as of boot. " \
648
+ "Restart the server to see the #{files.size == 1 ? "edit" : "edits"}."
649
+ elsif (files = FILE_CHECK[:compile_cached])&.any?
650
+ "App code was saved twice within one second at the same size (#{named_files(files)}); Bootsnap's " \
651
+ "compile cache knows a file by its size and the second it was saved, so what reflection reads, such as " \
652
+ "associations and enums, may be from the earlier save. Save the #{files.size == 1 ? "file" : "files"} " \
653
+ "again a second later, or start the server with DISABLE_BOOTSNAP_COMPILE_CACHE=1."
654
+ end
655
+ end
656
+
657
+ def named_files(files)
658
+ files.size > 3 ? "#{files.first(3).join(", ")} and #{files.size - 3} more" : files.join(", ")
659
+ end
660
+ private :named_files
661
+
475
662
  # The banner without its markdown wrapper, so a JSON body can carry
476
663
  # the same sentence under a key instead of a footer that would stop it
477
664
  # parsing.
@@ -486,7 +673,7 @@ module RailsAiContext
486
673
  else reason ? "App boot failed (#{reason})" : "Static mode"
487
674
  end
488
675
  "[STATIC] #{headline}. Serving static analysis; runtime-only data is marked " \
489
- "[UNAVAILABLE]. Run `rails-ai-context doctor` for details."
676
+ "[UNAVAILABLE]. Run `#{RailsAiContext.doctor_command}` for details."
490
677
  end
491
678
 
492
679
  # Tools that only make sense against a booted app must refuse in the
@@ -500,7 +687,7 @@ module RailsAiContext
500
687
  remedy = case RailsAiContext.static_kind
501
688
  when :requested then "Rerun without `--no-boot`."
502
689
  when :source_only then "This tree has no `config/environment.rb`; add one (or run from the app root) for runtime data."
503
- else "Fix the boot failure (see `rails-ai-context doctor`)."
690
+ else "Fix the boot failure (see `#{RailsAiContext.doctor_command}`)."
504
691
  end
505
692
  text_response(
506
693
  "[UNAVAILABLE: static tier] #{capability} requires a booted Rails app" \
@@ -520,6 +707,21 @@ module RailsAiContext
520
707
  find_closest_matches(input, available).first
521
708
  end
522
709
 
710
+ # Longer than any model, controller, table or file name an app has.
711
+ MAX_NAME_LENGTH = 256
712
+ ECHO_LENGTH = 80
713
+
714
+ # The caller's input as an answer repeats it: a 100 KB argument came
715
+ # back whole in a "not found", a 100,321-character reply.
716
+ def echo_input(value)
717
+ text = value.to_s
718
+ text.length > ECHO_LENGTH ? "#{text[0, ECHO_LENGTH]}... (#{text.length} characters)" : text
719
+ end
720
+
721
+ def name_too_long?(value)
722
+ value.to_s.length > MAX_NAME_LENGTH
723
+ end
724
+
523
725
  # Every name that matches as well as the best one does. A bare
524
726
  # `ReportsController` names three real controllers under different
525
727
  # namespaces, and answering with one of them arbitrarily hides the
@@ -530,6 +732,9 @@ module RailsAiContext
530
732
  # so it would otherwise surface an arbitrary "Did you mean" suggestion
531
733
  # for input that isn't a typo at all - just missing.
532
734
  return [] if input.to_s.strip.empty?
735
+ # No name is this long, and spell-checking a 100 KB string against
736
+ # every model took seconds to find nothing.
737
+ return [] if input.to_s.length > MAX_NAME_LENGTH
533
738
 
534
739
  exact = exact_matches(input, available)
535
740
  return exact if exact.any?
@@ -746,6 +951,9 @@ module RailsAiContext
746
951
  return "skip_callback :#{event}, :#{kind}, #{callback_target(callback[:method])}#{callback_options_tail(callback[:options])}"
747
952
  end
748
953
 
954
+ # A broadcast macro is its own declaration: the call is the line the file holds.
955
+ return callback[:source].to_s.strip.gsub(/\s+/, " ") if callback[:runs]
956
+
749
957
  name = callback[:name] || callback[:type]
750
958
  method = callback[:method].to_s
751
959
  target = inline_block_callback?(method) ? "do" : callback_target(method)
@@ -813,10 +1021,12 @@ module RailsAiContext
813
1021
  # the text itself when untruncated) so callers can attach a short trailing
814
1022
  # note that must survive truncation instead of being cut off with the tail.
815
1023
  # In static tier, the tier banner rides along on the same mechanism so
816
- # every response - caller-suffixed or not - ends with it.
1024
+ # every response - caller-suffixed or not - ends with it, and so does
1025
+ # the note that app code changed under a server that cannot reload.
817
1026
  def text_response(text, suffix: nil)
818
- suffix = [ suffix, static_tier_banner ].compact.join
1027
+ suffix = [ suffix, static_tier_banner, stale_code_banner ].compact.join
819
1028
  suffix = nil if suffix.empty?
1029
+ text = served_hints(text)
820
1030
 
821
1031
  record_call(text)
822
1032
 
@@ -825,29 +1035,133 @@ module RailsAiContext
825
1035
  truncated = text[0...max]
826
1036
  truncated += "\n\n---\n_Response truncated (#{text.length} chars). Use `detail:\"summary\"` for an overview, or filter by a specific item (e.g. `table:\"users\"`)._"
827
1037
  truncated += suffix if suffix
828
- MCP::Tool::Response.new([ { type: "text", text: truncated } ])
1038
+ MCP::Tool::Response.new([ { type: "text", text: cli_form(truncated) } ])
829
1039
  else
830
1040
  text += suffix if suffix
831
- MCP::Tool::Response.new([ { type: "text", text: text } ])
1041
+ MCP::Tool::Response.new([ { type: "text", text: cli_form(text) } ])
1042
+ end
1043
+ end
1044
+
1045
+ # A call to one of this gem's tools as an answer writes it:
1046
+ # `rails_get_schema(table:"posts")`. Under tool_mode :cli no MCP server
1047
+ # is set up, so the answer names the command that runs instead, as the
1048
+ # context files do; a lone `detail:"summary"` hint becomes `detail=summary`.
1049
+ MCP_CALL = /\b(rails_\w+)\(([^()]*)\)/
1050
+ PARAM_HINT = /`((?:\w+:(?:"[^"`]*"|[\w.]+)(?:,\s*)?)+)`/
1051
+
1052
+ # A `_Next:` hint points at other tools, one pointer per tool, joined
1053
+ # by ` | ` on one line or onto lines of their own. A tool skip_tools
1054
+ # turned off is no next step, so its pointer goes, and a hint left
1055
+ # with none goes whole. Every answer passes here, so no tool has to
1056
+ # check the served set itself.
1057
+ def served_hints(text)
1058
+ return text unless text.is_a?(String) && text.include?("_Next: ")
1059
+
1060
+ skipped = RailsAiContext::Server.skipped_tools
1061
+ return text if skipped.empty?
1062
+
1063
+ lines = text.split("\n", -1)
1064
+ kept = []
1065
+ index = 0
1066
+ while index < lines.size
1067
+ block = [ lines[index] ]
1068
+ if block.first.start_with?("_Next: ")
1069
+ block << lines[index += 1] while lines[index + 1]&.start_with?(" | ")
1070
+ hint = served_hint(block, skipped)
1071
+ if hint
1072
+ kept.concat(hint)
1073
+ elsif kept.last == "" && lines[index + 1].to_s.empty?
1074
+ kept.pop
1075
+ end
1076
+ else
1077
+ kept << block.first
1078
+ end
1079
+ index += 1
1080
+ end
1081
+ kept.join("\n")
1082
+ rescue StandardError => e
1083
+ RailsAiContext.debug_fail(e, text, label: "served_hints")
1084
+ end
1085
+
1086
+ # The hint's lines with the pointers to skipped tools taken out; nil
1087
+ # when none is left.
1088
+ def served_hint(block, skipped)
1089
+ body = block.join("\n").delete_prefix("_Next: ")
1090
+ closing = body[/\.?_\z/].to_s
1091
+ pointers = body.delete_suffix(closing).split(/\n? \| /)
1092
+ served = pointers.reject { |pointer| skipped.include?(pointer[/\brails_\w+/]) }
1093
+ return block if served.size == pointers.size
1094
+ return nil if served.empty?
1095
+
1096
+ "_Next: #{served.join(block.size > 1 ? "\n | " : " | ")}#{closing}".split("\n")
1097
+ end
1098
+ private :served_hint
1099
+
1100
+ def cli_form(text)
1101
+ return text unless RailsAiContext.configuration.tool_mode == :cli && text.is_a?(String)
1102
+
1103
+ names = BaseTool.registered_tools.map { |tool| tool.tool_name.to_s }
1104
+ converted = text.gsub(MCP_CALL) do
1105
+ whole = Regexp.last_match(0)
1106
+ name, args = Regexp.last_match(1), Regexp.last_match(2)
1107
+ params = names.include?(name) && cli_params(args)
1108
+ next whole unless params
1109
+
1110
+ short = name.sub(/\Arails_get_/, "").sub(/\Arails_/, "")
1111
+ command = RailsAiContext::InstallMode.tool_command(short)
1112
+ [ command, *params ].join(" ")
1113
+ end
1114
+ converted.gsub(PARAM_HINT) { (params = cli_params(Regexp.last_match(1))) ? "`#{params.join(' ')}`" : Regexp.last_match(0) }
1115
+ rescue StandardError => e
1116
+ RailsAiContext.debug_fail(e, text, label: "cli_form")
1117
+ end
1118
+
1119
+ # `model:"User", files:["a.rb","b.rb"]` as `model=User files=a.rb,b.rb`,
1120
+ # quoted for a shell where a value needs it; nil when the text is not
1121
+ # keyword arguments.
1122
+ def cli_params(args)
1123
+ return [] if args.strip.empty?
1124
+
1125
+ call = RailsAiContext::AstCache.parse_string("f(#{args})").value.statements.body.first
1126
+ hash = call.is_a?(Prism::CallNode) ? call.arguments&.arguments&.first : nil
1127
+ return nil unless hash.is_a?(Prism::KeywordHashNode)
1128
+
1129
+ hash.elements.map do |pair|
1130
+ return nil unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
1131
+
1132
+ value = case (node = pair.value)
1133
+ when Prism::StringNode, Prism::SymbolNode then node.unescaped
1134
+ when Prism::ArrayNode then node.elements.map { |e| e.respond_to?(:unescaped) ? e.unescaped : e.slice }.join(",")
1135
+ else node.slice
1136
+ end
1137
+ "#{pair.key.unescaped}=#{value.match?(%r{\A[\w.,/:@+-]*\z}) && !value.empty? ? value : "\"#{value.gsub(/["\\$`]/) { "\\#{Regexp.last_match(0)}" }}\""}"
832
1138
  end
833
1139
  end
834
1140
 
835
1141
  # Key the static-tier note rides under in a JSON body. Underscored the
836
1142
  # way JsonBudget's own report key is, so a reader tells it from data.
837
1143
  STATIC_TIER_KEY = "_static_tier"
1144
+ STALE_CODE_KEY = "_stale_code"
838
1145
 
839
- # A JSON body has to parse, so it cannot take the markdown banner and
1146
+ # A JSON body has to parse, so it cannot take the markdown banners and
840
1147
  # cannot be sliced at the response cap: JsonBudget drops whole
841
- # elements instead, and the tier note rides under a reserved key.
1148
+ # elements instead, and the notes ride under reserved keys.
842
1149
  def json_response(data)
843
- note = static_tier_note
844
- data = data.merge(STATIC_TIER_KEY => note) if note && data.is_a?(Hash)
845
-
846
- text = JsonBudget.generate(data, RailsAiContext.configuration.max_tool_response_chars)
1150
+ text = JsonBudget.generate(json_notes(data), RailsAiContext.configuration.max_tool_response_chars)
847
1151
  record_call(text)
848
1152
  MCP::Tool::Response.new([ { type: "text", text: text } ])
849
1153
  end
850
1154
 
1155
+ # The notes a text answer ends with, under their keys: that the answer
1156
+ # is static analysis, and that what reflection read is not the code
1157
+ # the files hold now. A tool's JSON answer and a resource's body both
1158
+ # carry them, so neither reads as current when it is not.
1159
+ def json_notes(data)
1160
+ return data unless data.is_a?(Hash)
1161
+
1162
+ data.merge({ STATIC_TIER_KEY => static_tier_note, STALE_CODE_KEY => stale_code_note }.compact)
1163
+ end
1164
+
851
1165
  # Helper: wrap text in an MCP::Tool::Response flagged as an error
852
1166
  # (isError: true) so MCP clients and the CLI treat the call as failed
853
1167
  # (non-zero exit). Mirrors the SafeCall rescue wrapper. Use for an
@@ -856,12 +1170,8 @@ module RailsAiContext
856
1170
  # would not answer. Guidance and "found nothing" stay informational
857
1171
  # via text_response and empty_response.
858
1172
  def error_response(text)
859
- # A failed call must not leak its recorded params into the next
860
- # call's session entry.
861
- Thread.current[:rails_ai_context_call_params] = nil
862
- banner = static_tier_banner
863
- text += banner if banner
864
- MCP::Tool::Response.new([ { type: "text", text: text } ], error: true)
1173
+ text += [ static_tier_banner, stale_code_banner ].compact.join
1174
+ MCP::Tool::Response.new([ { type: "text", text: cli_form(text) } ], error: true)
865
1175
  end
866
1176
 
867
1177
  private
@@ -882,22 +1192,32 @@ module RailsAiContext
882
1192
  Array(RailsAiContext::Payload.section(cached_context, :i18n)&.dig(:available_locales)).map(&:to_s)
883
1193
  end
884
1194
 
885
- # English units whatever the locale, unless the app offers no English at all.
1195
+ STORAGE_UNITS = %w[Bytes KB MB GB TB PB EB ZB].freeze
1196
+
1197
+ # number_to_human_size's English output, without I18n: an app's backend can raise after a failed boot.
886
1198
  def human_size(bytes)
887
- ActiveSupport::NumberHelper.number_to_human_size(bytes.to_i, locale: :en)
888
- rescue I18n::InvalidLocale
889
- ActiveSupport::NumberHelper.number_to_human_size(bytes.to_i)
1199
+ bytes = bytes.to_i
1200
+ return "#{bytes} #{bytes == 1 ? "Byte" : "Bytes"}" if bytes < 1024
1201
+
1202
+ exponent = [ (Math.log(bytes) / Math.log(1024)).to_i, STORAGE_UNITS.size - 1 ].min
1203
+ value = bytes.to_f / (1024**exponent)
1204
+ places = 3 - (Math.log10(value) + 1).floor
1205
+ rounded = format("%.#{[ places, 0 ].max}f", BigDecimal(value.to_s).round(places, :half_up))
1206
+ "#{rounded.sub(/(\.\d*?)0+\z/, '\1').delete_suffix(".")} #{STORAGE_UNITS[exponent]}"
890
1207
  end
891
1208
 
892
1209
  # Every answered call is recorded so session_context(action:"status")
893
- # can list it. SessionContext itself is skipped to avoid recursion.
1210
+ # can list it: the call SafeCall says the client made, once, with its
1211
+ # own params. A tool another tool called answers it, not the client.
1212
+ # SessionContext itself is skipped to avoid recursion.
894
1213
  def record_call(text)
1214
+ call = Thread.current[:rails_ai_context_call]
1215
+ return unless call && call[:tool].equal?(self) && !call[:recorded]
895
1216
  return unless respond_to?(:tool_name) && tool_name != "rails_session_context"
896
1217
 
897
1218
  summary = text.lines.first&.strip&.truncate(80)
898
- params = Thread.current[:rails_ai_context_call_params] || {}
899
- session_record(tool_name, params, summary)
900
- Thread.current[:rails_ai_context_call_params] = nil
1219
+ session_record(tool_name, call[:params], summary)
1220
+ call[:recorded] = true
901
1221
  end
902
1222
 
903
1223
  def session_key(tool_name, params)
@@ -935,8 +1255,8 @@ module RailsAiContext
935
1255
  # @return [String, nil] the refusal for a path the caller may not read
936
1256
  def unsafe_path_message(path)
937
1257
  case RailsAiContext::SafePath.locate(path.to_s, under: rails_app.root.to_s).refusal
938
- when :traversal, :outside then "Path not allowed: #{path}"
939
- when :sensitive then "Path not allowed: #{path} (sensitive file)"
1258
+ when :traversal, :outside then "Path not allowed: #{echo_input(path)}"
1259
+ when :sensitive then "Path not allowed: #{echo_input(path)} (sensitive file)"
940
1260
  end
941
1261
  end
942
1262
 
@@ -986,13 +1306,14 @@ module RailsAiContext
986
1306
 
987
1307
  # Whether the loaded model class defines the method: true, false, or
988
1308
  # nil when there is no loaded class to ask (the static tier, a name
989
- # that is not a model). A private method counts as defined, since the
990
- # error for calling one says so rather than "undefined".
1309
+ # that is not a model, a model whose file does not compile, which is
1310
+ # never required to ask it). A private method counts as defined, since
1311
+ # the error for calling one says so rather than "undefined".
991
1312
  def live_method_defined?(receiver, method_name)
992
1313
  return nil if RailsAiContext.static_tier?
993
1314
  return nil unless defined?(ActiveRecord::Base)
994
1315
 
995
- klass = receiver.to_s.safe_constantize
1316
+ klass = Introspectors::EagerLoad.constant(receiver)
996
1317
  return nil unless klass.is_a?(Class) && klass < ActiveRecord::Base
997
1318
 
998
1319
  define_attribute_methods(klass)
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Tools
5
+ # Brakeman's vendored ruby_parser runs `$DEBUG = true if ENV["DEBUG"]`
6
+ # when it loads, and with $DEBUG on Ruby prints every exception raised
7
+ # anywhere in the process, rescued or not: a `DEBUG=1` run, which this
8
+ # gem's own error text tells users to try, printed some 440 lines of them.
9
+ # Loading and running brakeman with DEBUG out of its sight, and putting
10
+ # $DEBUG back after, leaves that switch the user's.
11
+ module BrakemanGuard
12
+ module_function
13
+
14
+ def quietly
15
+ hidden = ENV.delete("DEBUG")
16
+ saved = $DEBUG
17
+ yield
18
+ ensure
19
+ $DEBUG = saved
20
+ ENV["DEBUG"] = hidden if hidden
21
+ end
22
+ end
23
+ end
24
+ end