rails-ai-context 5.32.2 → 5.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1491 -0
- data/CONTEXT.md +27 -3
- data/README.md +16 -12
- data/Rakefile +6 -0
- data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
- data/config/routes.rb +2 -3
- data/docs/ARCHITECTURE.md +7 -5
- data/docs/CLI.md +79 -6
- data/docs/COMPATIBILITY.md +75 -1
- data/docs/CONFIGURATION.md +30 -11
- data/docs/CUSTOM_TOOLS.md +2 -1
- data/docs/FAQ.md +16 -6
- data/docs/GUIDE.md +117 -71
- data/docs/INTROSPECTORS.md +10 -8
- data/docs/QUICKSTART.md +3 -3
- data/docs/RECIPES.md +1 -1
- data/docs/SECURITY.md +109 -25
- data/docs/SETUP.md +30 -6
- data/docs/STANDALONE.md +10 -3
- data/docs/TOOLS.md +152 -34
- data/docs/TROUBLESHOOTING.md +90 -12
- data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
- data/docs/adr/0005-workspace-mcp-entries.md +39 -0
- data/exe/rails-ai-context +733 -104
- data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
- data/lib/rails_ai_context/app_kind.rb +18 -5
- data/lib/rails_ai_context/ast_cache.rb +7 -0
- data/lib/rails_ai_context/base_mixins.rb +8 -8
- data/lib/rails_ai_context/boot_manager.rb +22 -3
- data/lib/rails_ai_context/change_watch.rb +14 -11
- data/lib/rails_ai_context/cli/app_root.rb +401 -0
- data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
- data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
- data/lib/rails_ai_context/code_reloader.rb +115 -1
- data/lib/rails_ai_context/concern_membership.rb +3 -1
- data/lib/rails_ai_context/concern_paths.rb +8 -3
- data/lib/rails_ai_context/configuration.rb +41 -9
- data/lib/rails_ai_context/context_file_report.rb +19 -2
- data/lib/rails_ai_context/database_file.rb +26 -0
- data/lib/rails_ai_context/doctor.rb +1122 -184
- data/lib/rails_ai_context/engine.rb +18 -1
- data/lib/rails_ai_context/erb_source.rb +19 -0
- data/lib/rails_ai_context/executable.rb +21 -0
- data/lib/rails_ai_context/facts_formatter.rb +5 -2
- data/lib/rails_ai_context/file_walk.rb +61 -10
- data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
- data/lib/rails_ai_context/fingerprinter.rb +40 -12
- data/lib/rails_ai_context/gem_lock.rb +186 -27
- data/lib/rails_ai_context/git_ignore.rb +22 -0
- data/lib/rails_ai_context/install/ai_tool.rb +31 -5
- data/lib/rails_ai_context/install/cleanup.rb +21 -0
- data/lib/rails_ai_context/install/program.rb +209 -35
- data/lib/rails_ai_context/install/selection_record.rb +258 -97
- data/lib/rails_ai_context/install/surface.rb +6 -1
- data/lib/rails_ai_context/install/validation_hook.rb +282 -0
- data/lib/rails_ai_context/install/workspace.rb +166 -0
- data/lib/rails_ai_context/install_mode.rb +71 -13
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
- data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
- data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
- data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
- data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
- data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
- data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
- data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
- data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
- data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
- data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
- data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
- data/lib/rails_ai_context/live_reload.rb +20 -20
- data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
- data/lib/rails_ai_context/mcp_edge.rb +182 -32
- data/lib/rails_ai_context/middleware.rb +5 -1
- data/lib/rails_ai_context/migration_status.rb +88 -25
- data/lib/rails_ai_context/output_guard.rb +48 -2
- data/lib/rails_ai_context/package_json.rb +1 -1
- data/lib/rails_ai_context/path_resolver.rb +46 -13
- data/lib/rails_ai_context/payload.rb +92 -8
- data/lib/rails_ai_context/pending_migrations.rb +5 -1
- data/lib/rails_ai_context/portable_path.rb +3 -2
- data/lib/rails_ai_context/presets.rb +15 -11
- data/lib/rails_ai_context/redaction.rb +262 -51
- data/lib/rails_ai_context/resources.rb +55 -20
- data/lib/rails_ai_context/route_coverage.rb +8 -0
- data/lib/rails_ai_context/safe_file.rb +14 -1
- data/lib/rails_ai_context/safe_path.rb +101 -4
- data/lib/rails_ai_context/schema_adapter.rb +8 -0
- data/lib/rails_ai_context/schema_version.rb +9 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
- data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
- data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
- data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
- data/lib/rails_ai_context/server.rb +304 -71
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
- data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
- data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
- data/lib/rails_ai_context/tools/base_tool.rb +367 -46
- data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
- data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
- data/lib/rails_ai_context/tools/diagnose.rb +218 -11
- data/lib/rails_ai_context/tools/generate_test.rb +339 -39
- data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
- data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
- data/lib/rails_ai_context/tools/get_concern.rb +91 -27
- data/lib/rails_ai_context/tools/get_config.rb +13 -4
- data/lib/rails_ai_context/tools/get_context.rb +71 -14
- data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
- data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
- data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
- data/lib/rails_ai_context/tools/get_env.rb +18 -9
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
- data/lib/rails_ai_context/tools/get_gems.rb +1 -1
- data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
- data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
- data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
- data/lib/rails_ai_context/tools/get_routes.rb +29 -8
- data/lib/rails_ai_context/tools/get_schema.rb +52 -19
- data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
- data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
- data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
- data/lib/rails_ai_context/tools/get_view.rb +14 -14
- data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
- data/lib/rails_ai_context/tools/onboard.rb +52 -8
- data/lib/rails_ai_context/tools/performance_check.rb +5 -3
- data/lib/rails_ai_context/tools/query.rb +914 -86
- data/lib/rails_ai_context/tools/read_logs.rb +69 -15
- data/lib/rails_ai_context/tools/review_changes.rb +215 -53
- data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
- data/lib/rails_ai_context/tools/safe_call.rb +35 -14
- data/lib/rails_ai_context/tools/search_code.rb +243 -67
- data/lib/rails_ai_context/tools/search_docs.rb +4 -4
- data/lib/rails_ai_context/tools/security_scan.rb +143 -33
- data/lib/rails_ai_context/tools/session_context.rb +39 -5
- data/lib/rails_ai_context/tools/validate.rb +259 -40
- data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +67 -40
- data/lib/rails_ai_context/view_file.rb +41 -3
- data/lib/rails_ai_context/watcher.rb +51 -13
- data/lib/rails_ai_context.rb +21 -0
- data/server.json +1 -1
- metadata +14 -1
|
@@ -83,8 +83,8 @@ module RailsAiContext
|
|
|
83
83
|
unresolved = unresolved.select { |name, _| subgraph.key?(name) }
|
|
84
84
|
subgraph = subgraph.first(MAX_NODES).to_h if subgraph.size > MAX_NODES
|
|
85
85
|
|
|
86
|
-
# Optional analyses
|
|
87
|
-
cycles = show_cycles ? detect_cycles(graph) :
|
|
86
|
+
# Optional analyses. nil when not asked, so a search that found none can say so.
|
|
87
|
+
cycles = show_cycles ? detect_cycles(graph) : nil
|
|
88
88
|
sti_groups = show_sti ? extract_sti_groups(models_data) : []
|
|
89
89
|
skipped = models_data.select { |_, data| data.is_a?(Hash) && data[:error] }.keys.map(&:to_s)
|
|
90
90
|
|
|
@@ -116,19 +116,19 @@ module RailsAiContext
|
|
|
116
116
|
end
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
-
# Second pass:
|
|
119
|
+
# Second pass: a model implements an interface by declaring `as:` on
|
|
120
|
+
# it. The key's name says nothing: Tag's `has_many :products,
|
|
121
|
+
# through: :taggings, source: :taggable` reads the key taggable_id
|
|
122
|
+
# and implements nothing.
|
|
120
123
|
models_data.each do |model_name, data|
|
|
121
124
|
next unless data.is_a?(Hash) && !data[:error]
|
|
122
125
|
(data[:associations] || []).each do |assoc|
|
|
123
126
|
type = (assoc[:macro] || assoc[:type]).to_s
|
|
124
127
|
next unless type == "has_many" || type == "has_one"
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
interface
|
|
129
|
-
if polymorphic_interfaces.key?(interface)
|
|
130
|
-
polymorphic_interfaces[interface] << model_name.to_s
|
|
131
|
-
end
|
|
128
|
+
next if assoc[:through]
|
|
129
|
+
|
|
130
|
+
interface = polymorphic_as(assoc)
|
|
131
|
+
polymorphic_interfaces[interface] |= [ model_name.to_s ] if polymorphic_interfaces.key?(interface)
|
|
132
132
|
end
|
|
133
133
|
end
|
|
134
134
|
|
|
@@ -195,6 +195,15 @@ module RailsAiContext
|
|
|
195
195
|
[ graph, unresolved ]
|
|
196
196
|
end
|
|
197
197
|
|
|
198
|
+
# `as: :commentable` as each tier records it: the option itself
|
|
199
|
+
# statically, and its display text (":commentable") wherever the
|
|
200
|
+
# declaration was read.
|
|
201
|
+
def polymorphic_as(assoc)
|
|
202
|
+
options, declared = assoc.values_at(:options, :declared_options)
|
|
203
|
+
value = (options[:as] if options.is_a?(Hash)) || (declared["as"] if declared.is_a?(Hash))
|
|
204
|
+
value&.to_s&.delete_prefix(":")
|
|
205
|
+
end
|
|
206
|
+
|
|
198
207
|
# How many of them the note spells out before it counts the rest.
|
|
199
208
|
UNRESOLVED_NAMED = 10
|
|
200
209
|
|
|
@@ -291,8 +300,33 @@ module RailsAiContext
|
|
|
291
300
|
subgraph
|
|
292
301
|
end
|
|
293
302
|
|
|
294
|
-
#
|
|
303
|
+
# A cycle is drawn over foreign keys, one edge per key, from the model
|
|
304
|
+
# whose table holds it to the model it points at. A belongs_to and the
|
|
305
|
+
# has_many on its other end are one key, so a pair of models joined
|
|
306
|
+
# both ways is not a cycle; a key to its own model is a tree, not one
|
|
307
|
+
# either. A through association and a join table hold no key of
|
|
308
|
+
# their own.
|
|
309
|
+
def foreign_key_edges(graph)
|
|
310
|
+
edges = Hash.new { |hash, key| hash[key] = Set.new }
|
|
311
|
+
graph.each do |model, model_edges|
|
|
312
|
+
model_edges.each do |edge|
|
|
313
|
+
next if edge[:through]
|
|
314
|
+
|
|
315
|
+
case edge[:type].to_s
|
|
316
|
+
when "belongs_to"
|
|
317
|
+
targets = edge[:polymorphic] ? Array(edge[:polymorphic_targets]) : [ edge[:target] ]
|
|
318
|
+
targets.each { |target| edges[model] << target unless target == model }
|
|
319
|
+
when "has_many", "has_one"
|
|
320
|
+
edges[edge[:target]] << model unless edge[:target] == model
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
edges
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# DFS-based cycle detection over the foreign keys. Returns array of cycle paths.
|
|
295
328
|
def detect_cycles(graph)
|
|
329
|
+
keys = foreign_key_edges(graph)
|
|
296
330
|
cycles = []
|
|
297
331
|
visited = Set.new
|
|
298
332
|
in_stack = Set.new
|
|
@@ -304,8 +338,7 @@ module RailsAiContext
|
|
|
304
338
|
in_stack.add(node)
|
|
305
339
|
path.push(node)
|
|
306
340
|
|
|
307
|
-
|
|
308
|
-
target = edge[:target]
|
|
341
|
+
keys[node].sort.each do |target|
|
|
309
342
|
if in_stack.include?(target)
|
|
310
343
|
# Found cycle: extract from target's position in path
|
|
311
344
|
cycle_start = path.index(target)
|
|
@@ -346,7 +379,18 @@ module RailsAiContext
|
|
|
346
379
|
groups
|
|
347
380
|
end
|
|
348
381
|
|
|
349
|
-
|
|
382
|
+
CYCLES_NOTE = "_Chains of foreign keys that lead back to the model they start from, through another: a belongs_to, or the " \
|
|
383
|
+
"has_many or has_one at its other end. An association and its inverse are one key, and a key to the model's own " \
|
|
384
|
+
"table is a tree, so neither is a cycle._"
|
|
385
|
+
|
|
386
|
+
def cycle_lines(cycles)
|
|
387
|
+
return [] if cycles.nil?
|
|
388
|
+
return [ "## Circular Dependencies", "None. #{CYCLES_NOTE}" ] if cycles.empty?
|
|
389
|
+
|
|
390
|
+
[ "## Circular Dependencies", CYCLES_NOTE, *cycles.map { |c| "- #{c.join(" → ")} → #{c.first}" } ]
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def render_mermaid(graph, center, cycles: nil, sti_groups: [], total_nodes: nil, total_edges: nil, skipped: [], unresolved: {})
|
|
350
394
|
lines = [ "# Dependency Graph", "" ]
|
|
351
395
|
lines << "```mermaid"
|
|
352
396
|
lines << "graph LR"
|
|
@@ -423,18 +467,13 @@ module RailsAiContext
|
|
|
423
467
|
|
|
424
468
|
stats = [ "**Models:** #{total_nodes || graph.keys.size}",
|
|
425
469
|
"**Associations:** #{total_edges || graph.values.sum(&:size)}" ]
|
|
426
|
-
stats << "**Cycles:** #{cycles.size}" if cycles
|
|
470
|
+
stats << "**Cycles:** #{cycles.size}" if cycles
|
|
427
471
|
stats << "**STI hierarchies:** #{sti_groups.size}" if sti_groups.any?
|
|
428
472
|
lines << stats.join(" | ")
|
|
429
473
|
lines.concat(truncation_notes(graph, total_nodes, skipped, total_edges, unresolved, drawn: arrows, focused: !center.nil?))
|
|
430
474
|
|
|
431
475
|
# Cycles section
|
|
432
|
-
if cycles
|
|
433
|
-
lines << ""
|
|
434
|
-
lines << "## Circular Dependencies"
|
|
435
|
-
cycles.each { |c| lines << "- #{c.join(" → ")} → #{c.first}" }
|
|
436
|
-
end
|
|
437
|
-
|
|
476
|
+
lines.push("", *cycle_lines(cycles)) if cycles
|
|
438
477
|
lines.join("\n")
|
|
439
478
|
end
|
|
440
479
|
|
|
@@ -444,7 +483,7 @@ module RailsAiContext
|
|
|
444
483
|
.group_by { |e| [ e[:type].to_s, e[:target] ] }.select { |_, list| list.size > 1 }.keys
|
|
445
484
|
end
|
|
446
485
|
|
|
447
|
-
def render_text(graph, center, cycles:
|
|
486
|
+
def render_text(graph, center, cycles: nil, sti_groups: [], total_nodes: nil, total_edges: nil, skipped: [], unresolved: {})
|
|
448
487
|
lines = [ "# Dependency Graph", "" ]
|
|
449
488
|
rows = 0
|
|
450
489
|
|
|
@@ -489,15 +528,11 @@ module RailsAiContext
|
|
|
489
528
|
end
|
|
490
529
|
|
|
491
530
|
# Cycles section
|
|
492
|
-
if cycles
|
|
493
|
-
lines << "## Circular Dependencies"
|
|
494
|
-
cycles.each { |c| lines << "- #{c.join(" → ")} → #{c.first}" }
|
|
495
|
-
lines << ""
|
|
496
|
-
end
|
|
531
|
+
lines.concat(cycle_lines(cycles)) << "" if cycles
|
|
497
532
|
|
|
498
533
|
stats = [ "**Models:** #{total_nodes || graph.keys.size}",
|
|
499
534
|
"**Associations:** #{total_edges || graph.values.sum(&:size)}" ]
|
|
500
|
-
stats << "**Cycles:** #{cycles.size}" if cycles
|
|
535
|
+
stats << "**Cycles:** #{cycles.size}" if cycles
|
|
501
536
|
stats << "**STI hierarchies:** #{sti_groups.size}" if sti_groups.any?
|
|
502
537
|
lines << stats.join(" | ")
|
|
503
538
|
lines.concat(truncation_notes(graph, total_nodes, skipped, total_edges, unresolved, drawn: rows, focused: !center.nil?))
|
|
@@ -106,6 +106,17 @@ module RailsAiContext
|
|
|
106
106
|
type: :argument_error,
|
|
107
107
|
likely: "A method received wrong number or type of arguments.",
|
|
108
108
|
fix: "1. Check the method signature matches the call site\n2. Use `rails_search_code(pattern:\"method_name\", match_type:\"trace\")` to see definition and callers"
|
|
109
|
+
},
|
|
110
|
+
# The authorization gems' two failures, by the words their class names share.
|
|
111
|
+
/AuthorizationNotPerformed|PolicyScopingNotPerformed/ => {
|
|
112
|
+
type: :authorization_not_performed,
|
|
113
|
+
likely: "The action finished without authorizing, and a check that every action does raised: Pundit's `after_action :verify_authorized` (or `verify_policy_scoped`). The message names the controller.",
|
|
114
|
+
fix: "1. Call `authorize record` (or `policy_scope(Model)`) in the action\n2. If the action is meant to be open, leave it out of the check: `skip_after_action :verify_authorized, only: :action_name`\n3. Use `rails_get_controllers(controller:\"...\", action:\"...\")` to see the filters that run on the action"
|
|
115
|
+
},
|
|
116
|
+
/NotAuthorized|AccessDenied/ => {
|
|
117
|
+
type: :authorization_denied,
|
|
118
|
+
likely: "An authorization rule refused the current user this action on this record. Pundit's message names the policy method that returned false; CanCanCan's the action and subject.",
|
|
119
|
+
fix: "1. Read the policy method or ability rule the message names\n2. Check which user and record the request carried\n3. If refusing is expected, rescue it (`rescue_from`) and answer 403 instead of 500"
|
|
109
120
|
}
|
|
110
121
|
}.freeze
|
|
111
122
|
|
|
@@ -144,7 +155,7 @@ module RailsAiContext
|
|
|
144
155
|
git_section = gather_git_context(file, parsed[:file_refs])
|
|
145
156
|
|
|
146
157
|
# Recent error logs
|
|
147
|
-
log_section = gather_log_context(parsed
|
|
158
|
+
log_section = gather_log_context(parsed)
|
|
148
159
|
|
|
149
160
|
# Truncate large sections before assembling final output
|
|
150
161
|
context_sections = truncate_section(context_sections, "Controller Context", MAX_SECTION_CHARS[:controller_context])
|
|
@@ -179,11 +190,16 @@ module RailsAiContext
|
|
|
179
190
|
output = output[0, MAX_TOTAL_OUTPUT] + "\n\n_... output truncated (#{output.length} chars exceeded #{MAX_TOTAL_OUTPUT} limit)._"
|
|
180
191
|
end
|
|
181
192
|
|
|
182
|
-
|
|
193
|
+
# The diagnosis still answers the error, and the result is an error as every refusal is.
|
|
194
|
+
refusal ? error_response(output) : text_response(output)
|
|
183
195
|
rescue => e
|
|
184
196
|
text_response("Diagnosis error: #{e.message}")
|
|
185
197
|
end
|
|
186
198
|
|
|
199
|
+
# A name an error message gives its exception: a class ending the way
|
|
200
|
+
# exceptions are named, or any namespaced constant.
|
|
201
|
+
EXCEPTION_CLASS = /[A-Z][\w:]*(?:Error|Exception|Invalid|NotFound|NotSaved|Missing)|[A-Z]\w*(?:::[A-Z]\w*)+/
|
|
202
|
+
|
|
187
203
|
class << self
|
|
188
204
|
private
|
|
189
205
|
|
|
@@ -191,12 +207,19 @@ module RailsAiContext
|
|
|
191
207
|
result = { exception_class: nil, message: error_string.strip, file_refs: [], method_name: nil }
|
|
192
208
|
|
|
193
209
|
# Extract exception class: "NoMethodError: ..." or "ActiveRecord::RecordNotFound ..."
|
|
210
|
+
first_line = error_string.strip.lines.first.to_s.strip
|
|
194
211
|
if (m = error_string.match(/\A([\w:]+(?:Error|Exception|Invalid|NotFound|NotSaved|Missing))\s*[:—]\s*(.*)/m))
|
|
195
212
|
result[:exception_class] = m[1]
|
|
196
213
|
result[:message] = m[2].strip
|
|
197
214
|
elsif (m = error_string.match(/\A([\w:]+::\w+)\s*[:—]\s*(.*)/m))
|
|
198
215
|
result[:exception_class] = m[1]
|
|
199
216
|
result[:message] = m[2].strip
|
|
217
|
+
elsif (m = first_line.match(/\A(#{EXCEPTION_CLASS}) \((.*)\):?\z/o))
|
|
218
|
+
# As a Rails log writes it: "Pundit::NotAuthorizedError (not allowed to ...):".
|
|
219
|
+
result[:exception_class] = m[1]
|
|
220
|
+
result[:message] = m[2].strip
|
|
221
|
+
elsif first_line.match?(/\A#{EXCEPTION_CLASS}\z/o)
|
|
222
|
+
result[:exception_class] = first_line
|
|
200
223
|
end
|
|
201
224
|
|
|
202
225
|
# Extract file:line references
|
|
@@ -249,27 +272,134 @@ module RailsAiContext
|
|
|
249
272
|
model_specific = classify_undefined_method_on_model(parsed)
|
|
250
273
|
return model_specific if model_specific
|
|
251
274
|
|
|
275
|
+
on_receiver = classify_method_on_receiver(parsed)
|
|
276
|
+
return on_receiver if on_receiver
|
|
277
|
+
|
|
252
278
|
error_str = "#{parsed[:exception_class]} #{parsed[:message]}"
|
|
253
279
|
|
|
254
280
|
ERROR_CLASSIFICATIONS.each do |pattern, info|
|
|
255
281
|
return info if error_str.match?(pattern)
|
|
256
282
|
end
|
|
257
283
|
|
|
284
|
+
unclassified(parsed[:exception_class])
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# No rule here covers the class, so the answer says what it is and
|
|
288
|
+
# where it comes from, which is where reading starts.
|
|
289
|
+
def unclassified(exception_class)
|
|
290
|
+
origin = exception_origin(exception_class)
|
|
291
|
+
likely = if origin
|
|
292
|
+
"No rule here covers `#{exception_class}`, which #{origin}. Its message says what failed; " \
|
|
293
|
+
"what raises it, and why, is in that code."
|
|
294
|
+
else
|
|
295
|
+
"Unable to automatically classify this error. Review the full error message and stack trace."
|
|
296
|
+
end
|
|
258
297
|
{
|
|
259
298
|
type: :unknown,
|
|
260
|
-
likely:
|
|
261
|
-
fix: "1. Check the error message for clues about what went wrong\
|
|
299
|
+
likely: likely,
|
|
300
|
+
fix: "1. Check the error message for clues about what went wrong\n" \
|
|
301
|
+
"2. Use `rails_search_code(pattern:\"#{exception_class&.split("::")&.last || "ErrorName"}\")` to find where the app raises or rescues it\n" \
|
|
302
|
+
"3. Use `rails_read_logs(level:\"ERROR\")` for the backtrace"
|
|
303
|
+
}
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# The Rails namespaces that ship in a gem named for none of them.
|
|
307
|
+
ACTIONPACK_NAMESPACES = %w[action_controller action_dispatch abstract_controller].to_h { |top| [ top, "actionpack" ] }.freeze
|
|
308
|
+
|
|
309
|
+
# Booted, Ruby knows the file that defines the class; statically, or
|
|
310
|
+
# for a class nothing has loaded yet, a locked gem named after the
|
|
311
|
+
# class's top namespace is the likely one.
|
|
312
|
+
def exception_origin(name)
|
|
313
|
+
return nil if name.to_s.empty?
|
|
314
|
+
|
|
315
|
+
unless RailsAiContext.static_tier?
|
|
316
|
+
# Resolved first: a constant still waiting on its autoload has no
|
|
317
|
+
# location yet. An app file that does not compile is never
|
|
318
|
+
# required for it (see EagerLoad).
|
|
319
|
+
path = Introspectors::EagerLoad.constant(name) && (Object.const_source_location(name) rescue nil)&.first
|
|
320
|
+
if path
|
|
321
|
+
spec = Gem.loaded_specs.values.find { |s| path.start_with?("#{s.full_gem_path}/") }
|
|
322
|
+
return "the #{spec.name} gem (#{spec.version}) defines" if spec
|
|
323
|
+
|
|
324
|
+
root = "#{rails_app.root}/"
|
|
325
|
+
return "this app defines in `#{path.delete_prefix(root)}`" if path.start_with?(root)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
top = name.split("::").first.to_s.underscore
|
|
330
|
+
lock = RailsAiContext::GemLock.for(rails_app.root.to_s)
|
|
331
|
+
gem = [ ACTIONPACK_NAMESPACES[top], top, top.delete("_"), top.dasherize ].compact.uniq.find { |candidate| lock.present?(candidate) }
|
|
332
|
+
"is likely the #{gem} gem's (the app's bundle locks #{gem} #{lock.version(gem)})" if gem
|
|
333
|
+
rescue StandardError, ScriptError => e
|
|
334
|
+
RailsAiContext.debug_fail(e, nil, label: "exception_origin")
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Only a nil receiver makes a NoMethodError a nil reference. On any
|
|
338
|
+
# other object the method is missing from it - a typo, or the wrong
|
|
339
|
+
# object - and `&.` would hide the error rather than fix it.
|
|
340
|
+
NIL_RECEIVER = /\bfor nil\b/
|
|
341
|
+
VISIBILITY = /\A(private|protected) method [`'](\w+[?!=]?)['`] called for/
|
|
342
|
+
|
|
343
|
+
def classify_method_on_receiver(parsed)
|
|
344
|
+
message = parsed[:message].to_s
|
|
345
|
+
receiver = extract_receiver_class(message)
|
|
346
|
+
if (m = message.match(VISIBILITY))
|
|
347
|
+
return {
|
|
348
|
+
type: :non_public_method,
|
|
349
|
+
likely: "`#{m[2]}` is #{m[1]} on #{receiver ? "`#{receiver}`" : "the receiver"}, so it cannot be called from outside the object.",
|
|
350
|
+
fix: "1. Call it from inside the class, or make it public if it is part of the interface\n" \
|
|
351
|
+
"2. Use `rails_search_code(pattern:\"#{m[2]}\", match_type:\"definition\")` to see where it is defined and under which visibility"
|
|
352
|
+
}
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
name = parsed[:method_name]
|
|
356
|
+
return nil unless name && message.match?(/undefined method/) && message.match?(/ for /) && !message.match?(NIL_RECEIVER)
|
|
357
|
+
|
|
358
|
+
defined, suggestion = receiver_methods(receiver, name, class_receiver: message.match?(/for class |:Class\b/))
|
|
359
|
+
subject = receiver ? "`#{receiver}`" : "The receiver"
|
|
360
|
+
likely = if defined
|
|
361
|
+
"#{subject} defines `#{name}` in this process, so the object that raised was not the one you expect, " \
|
|
362
|
+
"or it ran on code loaded before the method was added: restart a long-running server."
|
|
363
|
+
else
|
|
364
|
+
"#{subject} has no method `#{name}`. The receiver is not nil, so this is no nil reference: " \
|
|
365
|
+
"the name is misspelled, or the method belongs to another object.#{" Did you mean `#{suggestion}`?" if suggestion}"
|
|
366
|
+
end
|
|
367
|
+
{
|
|
368
|
+
type: :undefined_method,
|
|
369
|
+
likely: likely,
|
|
370
|
+
fix: "1. #{suggestion ? "Call `#{suggestion}`, the method #{subject} has" : "Check the name against the methods #{subject} defines"}\n" \
|
|
371
|
+
"2. Check the call goes to the object you think: an association or a presenter can hand back another class\n" \
|
|
372
|
+
"3. Use `rails_search_code(pattern:\"#{name}\", match_type:\"trace\")` to find its definition and its callers"
|
|
262
373
|
}
|
|
263
374
|
end
|
|
264
375
|
|
|
376
|
+
# Booted, the loaded class answers whether it has the method, and the
|
|
377
|
+
# name it has that is closest to the one called. Statically there is
|
|
378
|
+
# no class to ask, and neither is there for a class whose file does
|
|
379
|
+
# not compile, which is never required to ask it (see EagerLoad).
|
|
380
|
+
def receiver_methods(receiver, name, class_receiver:)
|
|
381
|
+
return [ false, nil ] if receiver.nil? || RailsAiContext.static_tier?
|
|
382
|
+
|
|
383
|
+
klass = Introspectors::EagerLoad.constant(receiver)
|
|
384
|
+
return [ false, nil ] unless klass.is_a?(Module)
|
|
385
|
+
|
|
386
|
+
names = (class_receiver ? klass.public_methods : klass.public_instance_methods).map(&:to_s)
|
|
387
|
+
return [ true, nil ] if names.include?(name)
|
|
388
|
+
|
|
389
|
+
[ false, ::DidYouMean::SpellChecker.new(dictionary: names).correct(name).first ]
|
|
390
|
+
rescue StandardError, ScriptError => e
|
|
391
|
+
RailsAiContext.debug_fail(e, [ false, nil ], label: "receiver_methods")
|
|
392
|
+
end
|
|
393
|
+
|
|
265
394
|
# Patterns that name the receiver of a NoMethodError:
|
|
266
395
|
# "undefined method 'x' for an instance of Article" (Ruby 3.3+)
|
|
267
396
|
# "undefined method `x' for #<Article id: 1>" (Ruby <= 3.2)
|
|
397
|
+
# "undefined method `x' for #<struct Summary ...>" (a Struct, <= 3.2)
|
|
268
398
|
# "undefined method 'x' for class Article" (class receiver, 3.3+)
|
|
269
399
|
# "undefined method `x' for Article:Class" (class receiver, <= 3.2)
|
|
270
400
|
RECEIVER_PATTERNS = [
|
|
271
401
|
/for an instance of ([A-Z]\w*(?:::\w+)*)/,
|
|
272
|
-
/for #<([A-Z]\w*(?:::\w+)*)/,
|
|
402
|
+
/for #<(?:struct )?([A-Z]\w*(?:::\w+)*)/,
|
|
273
403
|
/for class ([A-Z]\w*(?:::\w+)*)/,
|
|
274
404
|
/for ([A-Z]\w*(?:::\w+)*):Class/
|
|
275
405
|
].freeze
|
|
@@ -508,19 +638,96 @@ module RailsAiContext
|
|
|
508
638
|
status.success?
|
|
509
639
|
end
|
|
510
640
|
|
|
511
|
-
|
|
641
|
+
# The window rails_read_logs reads: the log's last megabyte. Fifteen
|
|
642
|
+
# lines, as this read before, rarely reached back to the error at all.
|
|
643
|
+
LOG_WINDOW = ReadLogs::MAX_READ_BYTES
|
|
644
|
+
# How far either side of the error line its request is looked for.
|
|
645
|
+
ENTRY_SPAN = 400
|
|
646
|
+
FRAMES_SHOWN = 5
|
|
647
|
+
LOG_TAG = /\A(\[[^\]]+\]) /
|
|
648
|
+
ANSI = /\e\[[\d;]*m/
|
|
649
|
+
REQUEST_LINE = /\A\s*(?:Started [A-Z]+ "|Processing by |Parameters: |Completed \d{3} )/
|
|
650
|
+
BACKTRACE_FRAME = /\S:\d+:in /
|
|
651
|
+
|
|
652
|
+
# The request that raised it: the log entry carrying the error, with
|
|
653
|
+
# the request's own lines (path, action, parameters, status) and the
|
|
654
|
+
# first frames of its backtrace, redacted.
|
|
655
|
+
def gather_log_context(parsed)
|
|
656
|
+
exception_class = parsed[:exception_class]
|
|
512
657
|
return [] unless exception_class
|
|
513
658
|
|
|
659
|
+
located = RailsAiContext::SafePath.locate(File.join("log", "#{rails_env_name}.log"), under: rails_app.root.to_s, max_size: Float::INFINITY)
|
|
660
|
+
return [] unless located.ok?
|
|
661
|
+
|
|
662
|
+
lines = log_tail(located.realpath)
|
|
663
|
+
hit = lines.rindex { |line| log_match?(line, parsed) }
|
|
664
|
+
where = "the last #{count_phrase(lines.size, "line")} of `#{located.relative}`"
|
|
665
|
+
return [ "## Recent Error Logs", "_No entry for `#{exception_class}` in #{where}._", "" ] unless hit
|
|
666
|
+
|
|
667
|
+
tag, entry = log_entry(lines, hit)
|
|
668
|
+
request = tag ? ", request `#{tag[1..-2]}`" : ""
|
|
514
669
|
begin
|
|
515
|
-
|
|
516
|
-
|
|
670
|
+
redacted = RailsAiContext::Redaction.redact_log_lines(entry)
|
|
671
|
+
rescue StandardError => e
|
|
672
|
+
# Never shown unredacted: the entry is named, not printed.
|
|
673
|
+
return [ "## Recent Error Logs", "_An entry for it#{request} is in #{where}, not shown: redacting it failed (#{e.class})._", "" ]
|
|
674
|
+
end
|
|
675
|
+
[ "## Recent Error Logs", "_The latest entry for it in #{where}#{request}:_", "```", *redacted, "```", "" ]
|
|
676
|
+
rescue => e
|
|
677
|
+
RailsAiContext.debug_fail(e, [], label: "gather_log_context")
|
|
678
|
+
end
|
|
517
679
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
680
|
+
def log_tail(path)
|
|
681
|
+
File.open(path, "rb") do |file|
|
|
682
|
+
cut = file.size > LOG_WINDOW
|
|
683
|
+
file.seek(-LOG_WINDOW, IO::SEEK_END) if cut
|
|
684
|
+
lines = file.read.force_encoding("UTF-8").scrub("?").split("\n")
|
|
685
|
+
# The window's first line starts mid-line.
|
|
686
|
+
cut ? lines.drop(1) : lines
|
|
521
687
|
end
|
|
522
688
|
end
|
|
523
689
|
|
|
690
|
+
# A line naming the class, and the method or the message too when the
|
|
691
|
+
# error gives one, whichever quotes the Ruby that wrote it used.
|
|
692
|
+
def log_match?(line, parsed)
|
|
693
|
+
return false unless line.include?(parsed[:exception_class])
|
|
694
|
+
return line.match?(/[`']#{Regexp.escape(parsed[:method_name])}'/) if parsed[:method_name]
|
|
695
|
+
|
|
696
|
+
key = loose(parsed[:message])[0, 60]
|
|
697
|
+
key == parsed[:exception_class] || loose(line).include?(key)
|
|
698
|
+
end
|
|
699
|
+
|
|
700
|
+
def loose(text)
|
|
701
|
+
text.to_s.tr("`", "'").gsub(/\s+/, " ").strip
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# The error line's request: its request-id tag when the log writes one,
|
|
705
|
+
# else the nearest "Started" line above it.
|
|
706
|
+
def log_entry(lines, index)
|
|
707
|
+
tag = lines[index][LOG_TAG, 1]
|
|
708
|
+
own = ->(line) { tag.nil? || line.start_with?(tag) }
|
|
709
|
+
text = ->(line) { (tag && line.start_with?(tag) ? line.delete_prefix(tag).delete_prefix(" ") : line).gsub(ANSI, "") }
|
|
710
|
+
|
|
711
|
+
from = [ index - ENTRY_SPAN, 0 ].max
|
|
712
|
+
started = (from...index).reverse_each.find { |i| own.call(lines[i]) && text.call(lines[i]).match?(/\AStarted [A-Z]+ "/) }
|
|
713
|
+
request = started ? (started...index).select { |i| own.call(lines[i]) && text.call(lines[i]).match?(REQUEST_LINE) } : []
|
|
714
|
+
|
|
715
|
+
frames = []
|
|
716
|
+
((index + 1)...[ index + ENTRY_SPAN, lines.size ].min).each do |i|
|
|
717
|
+
# A logger tags a message's first line only, so an untagged line
|
|
718
|
+
# continues the one above it.
|
|
719
|
+
next unless own.call(lines[i]) || !lines[i].match?(LOG_TAG)
|
|
720
|
+
|
|
721
|
+
line = text.call(lines[i])
|
|
722
|
+
next if line.strip.empty?
|
|
723
|
+
break unless line.match?(BACKTRACE_FRAME) && frames.size < FRAMES_SHOWN
|
|
724
|
+
|
|
725
|
+
frames << i
|
|
726
|
+
end
|
|
727
|
+
|
|
728
|
+
[ tag, (request + [ index ] + frames).map { |i| text.call(lines[i]).rstrip } ]
|
|
729
|
+
end
|
|
730
|
+
|
|
524
731
|
# Truncate the content of a named section (identified by "## heading") within a lines array.
|
|
525
732
|
# Returns a new array with the section's content lines truncated if they exceed max_chars.
|
|
526
733
|
def truncate_section(lines, heading, max_chars)
|