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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +868 -0
- data/CONTEXT.md +7 -3
- data/README.md +1 -1
- data/docs/ARCHITECTURE.md +3 -3
- data/docs/CLI.md +1 -1
- data/docs/COMPATIBILITY.md +50 -14
- data/docs/CONFIGURATION.md +4 -1
- data/docs/FAQ.md +1 -1
- data/docs/GUIDE.md +14 -8
- data/docs/INTROSPECTORS.md +50 -23
- data/docs/SECURITY.md +56 -1
- data/docs/TOOLS.md +58 -12
- data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
- data/lib/rails_ai_context/action_filters.rb +121 -48
- data/lib/rails_ai_context/app_commands.rb +2 -2
- data/lib/rails_ai_context/app_kind.rb +65 -8
- data/lib/rails_ai_context/ast_cache.rb +53 -18
- data/lib/rails_ai_context/base_mixins.rb +46 -16
- data/lib/rails_ai_context/boot_manager.rb +5 -1
- data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
- data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
- data/lib/rails_ai_context/concern_macros.rb +791 -111
- data/lib/rails_ai_context/concern_membership.rb +25 -5
- data/lib/rails_ai_context/concern_paths.rb +63 -17
- data/lib/rails_ai_context/config_yaml.rb +40 -0
- data/lib/rails_ai_context/configuration.rb +3 -3
- data/lib/rails_ai_context/data/docs/index.json +324 -0
- data/lib/rails_ai_context/database_yml.rb +136 -0
- data/lib/rails_ai_context/doctor.rb +30 -37
- data/lib/rails_ai_context/erb_source.rb +40 -0
- data/lib/rails_ai_context/fingerprinter.rb +48 -22
- data/lib/rails_ai_context/fixture_keys.rb +42 -1
- data/lib/rails_ai_context/gem_lock.rb +275 -47
- data/lib/rails_ai_context/git_ignore.rb +13 -11
- data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
- data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
- data/lib/rails_ai_context/introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
- data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
- data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
- data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
- data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
- data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
- data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
- data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
- data/lib/rails_ai_context/introspectors/base.rb +38 -0
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
- data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
- data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
- data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
- data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
- data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
- data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
- data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
- data/lib/rails_ai_context/introspectors/includers.rb +6 -1
- data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
- data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
- data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
- data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
- data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
- data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
- data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
- data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
- data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
- data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
- data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
- data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
- data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
- data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
- data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
- data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
- data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
- data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
- data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
- data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
- data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
- data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
- data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
- data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
- data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
- data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
- data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
- data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
- data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
- data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
- data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
- data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
- data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
- data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
- data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
- data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
- data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
- data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
- data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
- data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
- data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
- data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
- data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
- data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
- data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
- data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
- data/lib/rails_ai_context/method_name.rb +31 -0
- data/lib/rails_ai_context/migration_status.rb +5 -5
- data/lib/rails_ai_context/output_guard.rb +6 -4
- data/lib/rails_ai_context/package_json.rb +96 -5
- data/lib/rails_ai_context/path_resolver.rb +170 -15
- data/lib/rails_ai_context/payload.rb +48 -0
- data/lib/rails_ai_context/pending_migrations.rb +15 -18
- data/lib/rails_ai_context/portable_path.rb +76 -4
- data/lib/rails_ai_context/redaction.rb +8 -2
- data/lib/rails_ai_context/route_coverage.rb +5 -1
- data/lib/rails_ai_context/safe_file.rb +4 -1
- data/lib/rails_ai_context/safe_path.rb +61 -10
- data/lib/rails_ai_context/schema_hint.rb +8 -2
- data/lib/rails_ai_context/schema_version.rb +7 -9
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
- data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
- data/lib/rails_ai_context/server.rb +26 -7
- data/lib/rails_ai_context/test_framework.rb +32 -5
- data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
- data/lib/rails_ai_context/tools/base_tool.rb +69 -30
- data/lib/rails_ai_context/tools/diagnose.rb +1 -1
- data/lib/rails_ai_context/tools/generate_test.rb +8 -9
- data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
- data/lib/rails_ai_context/tools/get_api.rb +17 -11
- data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
- data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
- data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
- data/lib/rails_ai_context/tools/get_concern.rb +31 -18
- data/lib/rails_ai_context/tools/get_config.rb +52 -18
- data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
- data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
- data/lib/rails_ai_context/tools/get_engines.rb +2 -0
- data/lib/rails_ai_context/tools/get_env.rb +209 -52
- data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
- data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
- data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
- data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
- data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
- data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
- data/lib/rails_ai_context/tools/get_routes.rb +35 -9
- data/lib/rails_ai_context/tools/get_schema.rb +160 -61
- data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
- data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
- data/lib/rails_ai_context/tools/get_view.rb +37 -20
- data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
- data/lib/rails_ai_context/tools/onboard.rb +174 -88
- data/lib/rails_ai_context/tools/performance_check.rb +5 -13
- data/lib/rails_ai_context/tools/query.rb +122 -28
- data/lib/rails_ai_context/tools/read_logs.rb +45 -17
- data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
- data/lib/rails_ai_context/tools/safe_call.rb +20 -15
- data/lib/rails_ai_context/tools/search_code.rb +114 -40
- data/lib/rails_ai_context/tools/search_docs.rb +22 -11
- data/lib/rails_ai_context/tools/security_scan.rb +51 -4
- data/lib/rails_ai_context/tools/validate.rb +21 -4
- data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +2 -2
- data/lib/rails_ai_context/view_file.rb +53 -3
- data/lib/rails_ai_context.rb +10 -2
- data/server.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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|
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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",
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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[
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
111
|
+
# A relative path (`(eval)`, `-e`) names nothing on this machine.
|
|
112
|
+
return frame unless File.absolute_path?(path.to_s)
|
|
105
113
|
|
|
106
|
-
#
|
|
107
|
-
#
|
|
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
|
|
119
|
+
rescue StandardError
|
|
111
120
|
""
|
|
112
121
|
end
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|