rails-ai-context 5.30.2 → 5.32.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 (222) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +868 -0
  3. data/CONTEXT.md +7 -3
  4. data/README.md +1 -1
  5. data/docs/ARCHITECTURE.md +3 -3
  6. data/docs/CLI.md +1 -1
  7. data/docs/COMPATIBILITY.md +50 -14
  8. data/docs/CONFIGURATION.md +4 -1
  9. data/docs/FAQ.md +1 -1
  10. data/docs/GUIDE.md +14 -8
  11. data/docs/INTROSPECTORS.md +50 -23
  12. data/docs/SECURITY.md +56 -1
  13. data/docs/TOOLS.md +58 -12
  14. data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
  15. data/lib/rails_ai_context/action_filters.rb +121 -48
  16. data/lib/rails_ai_context/app_commands.rb +2 -2
  17. data/lib/rails_ai_context/app_kind.rb +65 -8
  18. data/lib/rails_ai_context/ast_cache.rb +53 -18
  19. data/lib/rails_ai_context/base_mixins.rb +46 -16
  20. data/lib/rails_ai_context/boot_manager.rb +5 -1
  21. data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
  22. data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
  23. data/lib/rails_ai_context/concern_macros.rb +791 -111
  24. data/lib/rails_ai_context/concern_membership.rb +25 -5
  25. data/lib/rails_ai_context/concern_paths.rb +63 -17
  26. data/lib/rails_ai_context/config_yaml.rb +40 -0
  27. data/lib/rails_ai_context/configuration.rb +3 -3
  28. data/lib/rails_ai_context/data/docs/index.json +324 -0
  29. data/lib/rails_ai_context/database_yml.rb +136 -0
  30. data/lib/rails_ai_context/doctor.rb +30 -37
  31. data/lib/rails_ai_context/erb_source.rb +40 -0
  32. data/lib/rails_ai_context/fingerprinter.rb +48 -22
  33. data/lib/rails_ai_context/fixture_keys.rb +42 -1
  34. data/lib/rails_ai_context/gem_lock.rb +275 -47
  35. data/lib/rails_ai_context/git_ignore.rb +13 -11
  36. data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
  37. data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
  38. data/lib/rails_ai_context/introspector.rb +10 -1
  39. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
  40. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
  41. data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
  42. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
  43. data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
  44. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
  45. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
  46. data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
  47. data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
  48. data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
  49. data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
  50. data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
  51. data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
  52. data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
  53. data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
  54. data/lib/rails_ai_context/introspectors/base.rb +38 -0
  55. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
  56. data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
  57. data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
  58. data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
  59. data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
  60. data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
  61. data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
  63. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
  65. data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
  66. data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
  67. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
  68. data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
  69. data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
  70. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
  71. data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
  72. data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
  73. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
  74. data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
  75. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
  76. data/lib/rails_ai_context/introspectors/includers.rb +6 -1
  77. data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
  78. data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
  79. data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
  80. data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
  81. data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
  82. data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
  83. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
  84. data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
  85. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
  86. data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
  87. data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
  88. data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
  90. data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
  91. data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
  92. data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
  93. data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
  94. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
  95. data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
  96. data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
  97. data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
  98. data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
  99. data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
  100. data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
  101. data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
  102. data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
  103. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
  104. data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
  105. data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
  106. data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
  107. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
  108. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
  109. data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
  110. data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
  111. data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
  112. data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
  113. data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
  114. data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
  115. data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
  116. data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
  117. data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
  118. data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
  119. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
  120. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
  121. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
  122. data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
  123. data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
  124. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
  125. data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
  126. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
  127. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
  128. data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
  129. data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
  130. data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
  131. data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
  132. data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
  133. data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
  134. data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
  135. data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
  136. data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
  137. data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
  138. data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
  139. data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
  140. data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
  141. data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
  142. data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
  143. data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
  144. data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
  145. data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
  146. data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
  147. data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
  148. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
  149. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
  150. data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
  151. data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
  152. data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
  153. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
  154. data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
  155. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
  156. data/lib/rails_ai_context/method_name.rb +31 -0
  157. data/lib/rails_ai_context/migration_status.rb +5 -5
  158. data/lib/rails_ai_context/output_guard.rb +6 -4
  159. data/lib/rails_ai_context/package_json.rb +96 -5
  160. data/lib/rails_ai_context/path_resolver.rb +170 -15
  161. data/lib/rails_ai_context/payload.rb +48 -0
  162. data/lib/rails_ai_context/pending_migrations.rb +15 -18
  163. data/lib/rails_ai_context/portable_path.rb +76 -4
  164. data/lib/rails_ai_context/redaction.rb +8 -2
  165. data/lib/rails_ai_context/route_coverage.rb +5 -1
  166. data/lib/rails_ai_context/safe_file.rb +4 -1
  167. data/lib/rails_ai_context/safe_path.rb +61 -10
  168. data/lib/rails_ai_context/schema_hint.rb +8 -2
  169. data/lib/rails_ai_context/schema_version.rb +7 -9
  170. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
  171. data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
  172. data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
  173. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
  174. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
  175. data/lib/rails_ai_context/server.rb +26 -7
  176. data/lib/rails_ai_context/test_framework.rb +32 -5
  177. data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
  178. data/lib/rails_ai_context/tools/base_tool.rb +69 -30
  179. data/lib/rails_ai_context/tools/diagnose.rb +1 -1
  180. data/lib/rails_ai_context/tools/generate_test.rb +8 -9
  181. data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
  182. data/lib/rails_ai_context/tools/get_api.rb +17 -11
  183. data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
  184. data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
  185. data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
  186. data/lib/rails_ai_context/tools/get_concern.rb +31 -18
  187. data/lib/rails_ai_context/tools/get_config.rb +52 -18
  188. data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
  189. data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
  190. data/lib/rails_ai_context/tools/get_engines.rb +2 -0
  191. data/lib/rails_ai_context/tools/get_env.rb +209 -52
  192. data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
  193. data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
  194. data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
  195. data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
  196. data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
  197. data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
  198. data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
  199. data/lib/rails_ai_context/tools/get_routes.rb +35 -9
  200. data/lib/rails_ai_context/tools/get_schema.rb +160 -61
  201. data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
  202. data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
  203. data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
  204. data/lib/rails_ai_context/tools/get_view.rb +37 -20
  205. data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
  206. data/lib/rails_ai_context/tools/onboard.rb +174 -88
  207. data/lib/rails_ai_context/tools/performance_check.rb +5 -13
  208. data/lib/rails_ai_context/tools/query.rb +122 -28
  209. data/lib/rails_ai_context/tools/read_logs.rb +45 -17
  210. data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
  211. data/lib/rails_ai_context/tools/safe_call.rb +20 -15
  212. data/lib/rails_ai_context/tools/search_code.rb +114 -40
  213. data/lib/rails_ai_context/tools/search_docs.rb +22 -11
  214. data/lib/rails_ai_context/tools/security_scan.rb +51 -4
  215. data/lib/rails_ai_context/tools/validate.rb +21 -4
  216. data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
  217. data/lib/rails_ai_context/version.rb +1 -1
  218. data/lib/rails_ai_context/vfs.rb +2 -2
  219. data/lib/rails_ai_context/view_file.rb +53 -3
  220. data/lib/rails_ai_context.rb +10 -2
  221. data/server.json +1 -1
  222. metadata +34 -3
@@ -198,7 +198,7 @@ module RailsAiContext
198
198
  when "csv"
199
199
  format_csv(redacted)
200
200
  else
201
- format_table(redacted)
201
+ format_table(redacted) + unbounded_note(result)
202
202
  end
203
203
 
204
204
  text_response(output)
@@ -312,16 +312,19 @@ module RailsAiContext
312
312
  # Build the combined list ONCE per call and dedupe.
313
313
  configured = Array(config.query_redacted_columns).map { |c| c.to_s.downcase }
314
314
  suffixed = SENSITIVE_COLUMN_SUFFIXES.map(&:downcase)
315
- # An app whose own column merely looks sensitive (oauth_applications.secret)
316
- # exempts it here; the built-in list is frozen and cannot be subtracted from.
317
- allowed = Array(config.query_allowed_columns).map { |c| c.to_s.downcase }
318
- ((configured + suffixed).uniq - allowed).each do |col|
315
+ ((configured + suffixed).uniq - allowed_columns.to_a).each do |col|
319
316
  next if col.empty?
320
317
  return col if down.match?(/\b#{Regexp.escape(col)}\b/)
321
318
  end
322
319
  nil
323
320
  end
324
321
 
322
+ # An app whose own column merely looks sensitive (oauth_applications.secret)
323
+ # exempts it by name, from the pre-query check and from result redaction alike.
324
+ private_class_method def self.allowed_columns
325
+ Array(config.query_allowed_columns).to_set { |c| c.to_s.downcase }
326
+ end
327
+
325
328
  # ── Database-level execution (Layer 2) ──────────────────────────
326
329
  private_class_method def self.execute_safely(sql, row_limit, timeout_seconds)
327
330
  conn = ActiveRecord::Base.connection
@@ -383,29 +386,115 @@ module RailsAiContext
383
386
  result
384
387
  end
385
388
 
389
+ # sqlite3-ruby cannot interrupt a running statement, so a killable child runs it instead.
386
390
  private_class_method def self.execute_sqlite(conn, sql, timeout)
387
- raw = conn.raw_connection
391
+ if (path = sqlite_fork_path(conn))
392
+ begin
393
+ return execute_sqlite_in_child(conn, path, sql, timeout)
394
+ rescue ActiveRecord::StatementInvalid => e
395
+ raise unless e.message.match?(SQLITE_APP_CONNECTION_ONLY)
396
+ end
397
+ end
398
+
388
399
  result = nil
389
400
  begin
390
401
  conn.execute("PRAGMA query_only = ON")
391
- # SQLite has no native statement timeout. Use a progress handler
392
- # to abort queries that run too long (checked every 1000 VM steps).
393
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
394
- if raw.respond_to?(:set_progress_handler)
395
- raw.set_progress_handler(1000) do
396
- if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
397
- 1 # non-zero = abort
398
- else
399
- 0
400
- end
401
- end
402
- end
403
402
  result = conn.select_all(sql)
404
403
  ensure
405
- raw.set_progress_handler(0, nil) if raw.respond_to?(:set_progress_handler)
406
404
  conn.execute("PRAGMA query_only = OFF")
407
405
  end
408
- result
406
+ ResultProxy.new(result.columns, result.rows, true)
407
+ end
408
+
409
+ # Errors from state only the app's own connection has: registered functions,
410
+ # virtual table modules, collations, an encryption key.
411
+ SQLITE_APP_CONNECTION_ONLY = /no such (?:function|module|collation)|file is not a database/i
412
+
413
+ # A row-capped result is far below this; a larger prefix is a corrupt one.
414
+ SQLITE_MAX_RESULT_BYTES = 1 << 30
415
+
416
+ private_class_method def self.read_before(reader, size, deadline)
417
+ data = "".b
418
+ while data.bytesize < size
419
+ left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
420
+ raise ActiveRecord::StatementInvalid, "SQLite query exceeded the statement timeout" unless left.positive? && IO.select([ reader ], nil, nil, left)
421
+
422
+ chunk = reader.read_nonblock([ size - data.bytesize, 1 << 16 ].min, exception: false)
423
+ raise ActiveRecord::StatementInvalid, "the SQLite query process exited without a result" if chunk.nil?
424
+
425
+ data << chunk unless chunk == :wait_readable
426
+ end
427
+ data
428
+ end
429
+
430
+ # nil for an in-memory or temporary database, or where there is no fork.
431
+ private_class_method def self.sqlite_fork_path(conn)
432
+ return nil unless Process.respond_to?(:fork)
433
+
434
+ path = conn.raw_connection.filename("main").to_s
435
+ path unless path.empty?
436
+ end
437
+
438
+ private_class_method def self.execute_sqlite_in_child(conn, path, sql, timeout)
439
+ parent = Process.pid
440
+ extensions = Array(conn.pool.db_config.configuration_hash[:extensions]).map { |ext| (ext.is_a?(String) && ext.safe_constantize) || ext }
441
+ # sqlite3 2.x warns in every child that inherits a writable handle; Rails 8 silences it the same way.
442
+ SQLite3::ForkSafety.suppress_warnings! if defined?(SQLite3::ForkSafety)
443
+
444
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
445
+ reader, writer = IO.pipe.each(&:binmode)
446
+ pid = fork { answer_in_child(reader, writer, path, sql, timeout, extensions) }
447
+ writer.close
448
+ read_child_result(reader, deadline)
449
+ ensure
450
+ # Also reached in a child whose fork hooks raised: it must not carry on as a second server.
451
+ exit!(1) if parent && Process.pid != parent
452
+ reader&.close
453
+ writer&.close
454
+ reap_child(pid) if pid
455
+ end
456
+
457
+ # Writes the length-prefixed result and exits without the parent's at_exit hooks.
458
+ private_class_method def self.answer_in_child(reader, writer, path, sql, timeout, extensions)
459
+ reader.close
460
+ payload = begin
461
+ db = SQLite3::Database.new(path, readonly: true, extensions: extensions)
462
+ db.busy_timeout = (timeout * 1000).to_i
463
+ stmt = db.prepare(sql)
464
+ [ :ok, stmt.columns, stmt.to_a ]
465
+ rescue => e
466
+ [ :error, "#{e.class}: #{e.message}" ]
467
+ end
468
+ bytes = Marshal.dump(payload)
469
+ writer.write([ bytes.bytesize ].pack("Q>"), bytes)
470
+ ensure
471
+ exit!(0)
472
+ end
473
+
474
+ # Read by length, not to EOF: any other process forked meanwhile holds a copy of the writer.
475
+ private_class_method def self.read_child_result(reader, deadline)
476
+ size = read_before(reader, 8, deadline).unpack1("Q>")
477
+ status, *rest = begin
478
+ raise ArgumentError, "result length out of range" if size > SQLITE_MAX_RESULT_BYTES
479
+
480
+ Marshal.load(read_before(reader, size, deadline))
481
+ rescue ArgumentError, TypeError
482
+ raise ActiveRecord::StatementInvalid, "the SQLite query process exited without a result"
483
+ end
484
+ raise ActiveRecord::StatementInvalid, rest.first if status == :error
485
+
486
+ ActiveRecord::Result.new(*rest)
487
+ end
488
+
489
+ # KILL: TERM cannot land while `step` holds the GVL, and would run inherited at_exit hooks.
490
+ # Runs in an ensure, so it rescues everything: nothing here may replace the query's own error.
491
+ private_class_method def self.reap_child(pid)
492
+ Process.kill(:KILL, pid) rescue nil
493
+ Process.wait(pid) rescue nil
494
+ end
495
+
496
+ private_class_method def self.unbounded_note(result)
497
+ result.is_a?(ResultProxy) && result.unbounded ? UNBOUNDED_SQLITE_NOTE : ""
409
498
  end
410
499
 
411
500
  # ── EXPLAIN execution ────────────────────────────────────────────
@@ -462,7 +551,7 @@ module RailsAiContext
462
551
  lines << parsed[:raw]
463
552
  lines << "```"
464
553
 
465
- text_response(lines.join("\n"))
554
+ text_response(lines.join("\n") + unbounded_note(result))
466
555
  rescue ActiveRecord::StatementInvalid => e
467
556
  text_response("EXPLAIN failed: #{clean_error_message(e.message)}")
468
557
  end
@@ -592,14 +681,15 @@ module RailsAiContext
592
681
 
593
682
  # ── Column redaction (Layer 4) ──────────────────────────────────
594
683
  private_class_method def self.redact_results(result)
595
- redacted_cols = config.query_redacted_columns.map(&:downcase).to_set
684
+ allowed = allowed_columns
685
+ redacted_cols = config.query_redacted_columns.map(&:downcase).to_set - allowed
686
+ encrypted_cols = Set.new
596
687
 
597
- # Auto-redact columns declared with `encrypts` in models
598
688
  models_data = cached_context&.dig(:models)
599
689
  if models_data.is_a?(Hash)
600
690
  models_data.each_value do |data|
601
691
  next unless data.is_a?(Hash)
602
- (data[:encrypts] || []).each { |col| redacted_cols << col.to_s.downcase }
692
+ (data[:encrypts] || []).each { |col| encrypted_cols << col.to_s.downcase }
603
693
  end
604
694
  end
605
695
  columns = result.columns
@@ -609,8 +699,9 @@ module RailsAiContext
609
699
  sensitive_suffixes = %w[password secret token key digest hash].freeze
610
700
  redacted_indices = columns.each_with_index.filter_map { |col, i|
611
701
  col_down = col.downcase
612
- i if redacted_cols.include?(col_down) ||
613
- sensitive_suffixes.any? { |suffix| col_down.end_with?(suffix) || col_down.include?("password") || col_down.include?("secret") || col_down.include?("token") }
702
+ i if encrypted_cols.include?(col_down) || redacted_cols.include?(col_down) ||
703
+ (!allowed.include?(col_down) &&
704
+ sensitive_suffixes.any? { |suffix| col_down.end_with?(suffix) || col_down.include?("password") || col_down.include?("secret") || col_down.include?("token") })
614
705
  }
615
706
 
616
707
  return result if redacted_indices.empty?
@@ -702,8 +793,11 @@ module RailsAiContext
702
793
  message.lines.first&.strip || message.strip
703
794
  end
704
795
 
705
- # Quacks like ActiveRecord::Result for redacted output.
706
- ResultProxy = Struct.new(:columns, :rows)
796
+ # Quacks like ActiveRecord::Result for redacted output, or for a SQLite
797
+ # query that ran in-process with no time limit.
798
+ ResultProxy = Struct.new(:columns, :rows, :unbounded)
799
+ UNBOUNDED_SQLITE_NOTE = "\n\n_This SQLite query ran without a time limit: query_timeout needs a file-backed database, " \
800
+ "a platform with fork, and nothing that only the app's own connection has._"
707
801
  end
708
802
  end
709
803
  end
@@ -22,7 +22,7 @@ module RailsAiContext
22
22
  },
23
23
  file: {
24
24
  type: "string",
25
- description: "Log file name (e.g. 'production', 'sidekiq'). Defaults to current Rails.env log. '.log' suffix optional."
25
+ description: "Log file name (e.g. 'production', 'sidekiq', or a rotated 'development.log.0'). Defaults to current Rails.env log. '.log' suffix optional."
26
26
  },
27
27
  search: {
28
28
  type: "string",
@@ -90,6 +90,10 @@ module RailsAiContext
90
90
 
91
91
  # Detect format and filter by level
92
92
  format = detect_format(raw_lines)
93
+ if level != "all" && !severity_format?(raw_lines, format)
94
+ warnings << "most of these lines have no severity field, so they cannot be filtered by level; showing every line"
95
+ level = "all"
96
+ end
93
97
  filtered = filter_by_level(raw_lines, level, format)
94
98
 
95
99
  redacted = RailsAiContext::Redaction.redact_log_lines(filtered, search: search)
@@ -119,15 +123,15 @@ module RailsAiContext
119
123
 
120
124
  # ── Log file resolution ─────────────────────────────────────────
121
125
 
126
+ ROTATED_LOG = /\.log\.\d+\z/
127
+
122
128
  private_class_method def self.resolve_log_file(file_name)
123
- name = if file_name
124
- File.basename(file_name.to_s.strip.delete("\0").delete_suffix(".log"))
125
- else
126
- rails_env_name
127
- end
129
+ base = file_name ? File.basename(file_name.to_s.strip.delete("\0")) : rails_env_name.to_s
130
+ # Logger rotates by size to development.log.0, .1 and so on.
131
+ base = "#{base.delete_suffix(".log")}.log" unless base.match?(ROTATED_LOG)
128
132
 
129
133
  # A log is tailed, never read whole, so the per-file cap does not apply.
130
- located = RailsAiContext::SafePath.locate(File.join("log", "#{name}.log"), under: rails_app.root.to_s, max_size: Float::INFINITY)
134
+ located = RailsAiContext::SafePath.locate(File.join("log", base), under: rails_app.root.to_s, max_size: Float::INFINITY)
131
135
  located.ok? ? located.realpath : nil
132
136
  end
133
137
 
@@ -156,18 +160,42 @@ module RailsAiContext
156
160
  :standard
157
161
  end
158
162
 
163
+ SEVERITY = "DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL|UNKNOWN|ANY"
164
+ # Only where a formatter writes a severity: Logger::Formatter's "I, [ts] INFO --",
165
+ # a leading "INFO"/"[INFO]", "[ts] INFO", "<timestamp> INFO", Sidekiq 6/7's
166
+ # "pid=.. tid=.. INFO: ", logfmt level=, or semantic_logger's "<timestamp> E [pid:thread]".
167
+ SEVERITY_FIELD = Regexp.union(
168
+ /\A\d{4}-\d\d-\d\d[T ][\d:.]+ ([TDIWEF]) \[/,
169
+ /\A[DIWEFA], \[[^\]]*\]\s+(#{SEVERITY}) -- /o,
170
+ /\A\[?(#{SEVERITY})\]?(?=[\s:]|\z)/o,
171
+ /\A\[[^\]]*\]\s+\[?(#{SEVERITY})\]?\s/o,
172
+ /\A\d{4}-\d\d-\d\d[T ][\d:.,]+(?:Z|[+-]\d\d:?\d\d)?\s+\[?(#{SEVERITY})\]?\s/o,
173
+ /\A(?:\S+ )?pid=\d+ tid=\S+(?: [\w.]+=\S*)* (#{SEVERITY}): /o,
174
+ /\b(?:level|severity)=(#{SEVERITY})\b/io
175
+ )
176
+
177
+ # semantic_logger writes the first letter of its level; trace sits below debug.
178
+ LEVEL_ALIASES = { "WARNING" => "WARN", "T" => "DEBUG", "D" => "DEBUG", "I" => "INFO", "W" => "WARN", "E" => "ERROR", "F" => "FATAL" }.freeze
179
+ ANSI_COLOR = /\e\[[\d;]*m/
180
+ BACKTRACE_FRAME = /\S:\d+:in /
181
+
182
+ # Filtering by level needs a severity on most lines: one message that
183
+ # happens to start with "WARN:" in a log that writes no severity must
184
+ # not turn every other line into its continuation.
185
+ private_class_method def self.severity_format?(lines, format)
186
+ heads = lines.reject { |l| l.strip.empty? || l.match?(/\A\s/) || l.match?(BACKTRACE_FRAME) }
187
+ heads.count { |l| extract_level(l, format) } * 2 > heads.size
188
+ end
189
+
159
190
  private_class_method def self.extract_level(line, format)
160
191
  case format
161
192
  when :json
162
- match = line.match(/"level"\s*:\s*"(\w+)"/i)
193
+ match = line.match(/"(?:level|severity|lvl)"\s*:\s*"(\w+)"/i)
163
194
  match[1].upcase if match
164
195
  when :standard
165
- # Rails format: I, [timestamp] INFO -- : message
166
- # Or: [2026-03-29 10:00:00] INFO message
167
- match = line.match(/\b(DEBUG|INFO|WARN(?:ING)?|ERROR|FATAL)\b/i)
168
- level = match[1].upcase if match
169
- level = "WARN" if level == "WARNING"
170
- level
196
+ match = line.gsub(ANSI_COLOR, "").match(SEVERITY_FIELD)
197
+ level = match&.captures&.compact&.first&.upcase
198
+ LEVEL_ALIASES.fetch(level, level)
171
199
  end
172
200
  end
173
201
 
@@ -182,7 +210,7 @@ module RailsAiContext
182
210
  lines.each do |line|
183
211
  level = extract_level(line, format)
184
212
  if level
185
- rank = LEVEL_HIERARCHY[level] || 0
213
+ rank = LEVEL_HIERARCHY.fetch(level, LEVEL_HIERARCHY["FATAL"])
186
214
  include_continuation = rank >= min_rank
187
215
  end
188
216
  # Lines without a level are continuations (stack traces)
@@ -197,9 +225,9 @@ module RailsAiContext
197
225
  private_class_method def self.available_log_files
198
226
  log_dir = File.join(rails_app.root.to_s, "log")
199
227
  return [] unless Dir.exist?(log_dir)
200
- Dir.glob(File.join(log_dir, "*.log"))
228
+ Dir.glob(File.join(log_dir, "*.log{,.*}"))
201
229
  .map { |f| File.basename(f) }
202
- .select { |f| f.match?(/\A[\w.\-]+\.log\z/) } # Only clean filenames (alphanumeric, dots, hyphens, underscores)
230
+ .select { |f| f.match?(/\A[\w.\-]+\.log(?:\.\d+)?\z/) } # Only clean filenames (alphanumeric, dots, hyphens, underscores)
203
231
  .sort
204
232
  end
205
233
  end
@@ -203,7 +203,7 @@ module RailsAiContext
203
203
  end
204
204
 
205
205
  def gather_pending_migrations
206
- RailsAiContext::PendingMigrations.live(File.join(rails_app.root, "db/migrate"))
206
+ RailsAiContext::PendingMigrations.live(RailsAiContext::PendingMigrations.migrate_dirs_for(rails_app.root))
207
207
  end
208
208
 
209
209
  def gather_index_usage(conn, adapter)
@@ -39,7 +39,8 @@ module RailsAiContext
39
39
  # nothing is reported.
40
40
  RailsAiContext::CodeReloader.with_app_code do
41
41
  begin
42
- append_note(super(**kwargs), invalid_detail_note(discarded))
42
+ # One call is one run: what it reads per controller or per route is read once.
43
+ append_note(RailsAiContext::RunCache.around { super(**kwargs) }, invalid_detail_note(discarded))
43
44
  rescue StandardError => e
44
45
  # A failed call must not leak its recorded params into the next
45
46
  # call's session entry.
@@ -98,29 +99,33 @@ module RailsAiContext
98
99
 
99
100
  # A backtrace frame is an absolute path, and on an installed gem that is
100
101
  # the machine's GEM_HOME. The answer leaves the machine, so the frame is
101
- # spelled the way every other path this gem reports is.
102
+ # spelled the way every other path this gem reports is, and one no
103
+ # portable form names keeps only its file name.
102
104
  def portable_origin(frame)
105
+ # Ruby 3.3+ names an eval without a file `(eval at FILE:LINE):LINE`.
106
+ if (eval_frame = frame.match(/\A\(eval at (.+)\)(:\d+(?::.*)?)?\z/m))
107
+ return "(eval at #{portable_origin(eval_frame[1])})#{eval_frame[2]}"
108
+ end
109
+
103
110
  path, rest = frame.split(":", 2)
104
- return frame if path.to_s.empty?
111
+ # A relative path (`(eval)`, `-e`) names nothing on this machine.
112
+ return frame unless File.absolute_path?(path.to_s)
105
113
 
106
- # No app, or one with no root: relativize still strips the gem prefix,
107
- # so an empty root is an answer.
114
+ # This runs inside the rescue that answers a tool failure, so a raise
115
+ # here would leave the net and reach the client as a protocol error.
116
+ # No app, or no root, still strips the gem prefix.
108
117
  root = begin
109
118
  rails_app.root.to_s
110
- rescue NameError
119
+ rescue StandardError
111
120
  ""
112
121
  end
113
- [ RailsAiContext::PortablePath.relativize(path, root), rest ].compact.join(":")
114
- rescue StandardError
115
- # This runs inside the rescue that answers a tool failure, so a raise
116
- # here leaves the net entirely and the client gets a protocol error.
117
- # An empty root is the same answer the no-app branch above gives, and
118
- # it still strips the prefix that names the machine.
119
- begin
120
- [ RailsAiContext::PortablePath.relativize(path, ""), rest ].compact.join(":")
122
+ relative = begin
123
+ RailsAiContext::PortablePath.relativize(path, root)
121
124
  rescue StandardError
122
- frame
125
+ path
123
126
  end
127
+ relative = File.basename(relative) if File.absolute_path?(relative)
128
+ [ relative, rest ].compact.join(":")
124
129
  end
125
130
 
126
131
  # The value the caller sent, when this tool takes a DetailLevel detail