rails-ai-context 5.35.0 → 5.35.2
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 +1024 -0
- data/CONTEXT.md +37 -7
- data/CONTRIBUTING.md +30 -1
- data/README.md +9 -9
- data/SECURITY.md +2 -2
- data/docs/ARCHITECTURE.md +12 -4
- data/docs/CLI.md +44 -15
- data/docs/COMPATIBILITY.md +34 -8
- data/docs/CONFIGURATION.md +28 -5
- data/docs/CUSTOM_TOOLS.md +8 -2
- data/docs/FAQ.md +5 -5
- data/docs/GUIDE.md +33 -65
- data/docs/INTROSPECTORS.md +4 -4
- data/docs/QUICKSTART.md +1 -1
- data/docs/RECIPES.md +5 -5
- data/docs/RELEASING.md +3 -1
- data/docs/SECURITY.md +255 -74
- data/docs/SETUP.md +1 -1
- data/docs/STANDALONE.md +2 -2
- data/docs/TOOLS.md +151 -90
- data/docs/TROUBLESHOOTING.md +123 -8
- data/docs/_config.yml +1 -1
- data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
- data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
- data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
- data/docs/index.md +2 -2
- data/exe/rails-ai-context +24 -9
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
- data/lib/rails_ai_context/app_databases.rb +377 -0
- data/lib/rails_ai_context/app_kind.rb +2 -0
- data/lib/rails_ai_context/cli/app_root.rb +1 -0
- data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
- data/lib/rails_ai_context/configuration.rb +15 -8
- data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
- data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
- data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
- data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
- data/lib/rails_ai_context/database_family.rb +282 -0
- data/lib/rails_ai_context/database_file.rb +61 -7
- data/lib/rails_ai_context/database_yml.rb +7 -29
- data/lib/rails_ai_context/doctor.rb +15 -16
- data/lib/rails_ai_context/instrumentation.rb +1 -1
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
- data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
- data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
- data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
- data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
- data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
- data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
- data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
- data/lib/rails_ai_context/migration_status.rb +18 -46
- data/lib/rails_ai_context/payload.rb +48 -1
- data/lib/rails_ai_context/pending_migrations.rb +3 -4
- data/lib/rails_ai_context/run_cache.rb +5 -0
- data/lib/rails_ai_context/schema_adapter.rb +13 -18
- data/lib/rails_ai_context/secret_columns.rb +506 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
- data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
- data/lib/rails_ai_context/server.rb +143 -29
- data/lib/rails_ai_context/sql_gate.rb +1007 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
- data/lib/rails_ai_context/test_helper.rb +3 -1
- data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
- data/lib/rails_ai_context/tools/base_tool.rb +71 -48
- data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
- data/lib/rails_ai_context/tools/diagnose.rb +105 -42
- data/lib/rails_ai_context/tools/generate_test.rb +4 -6
- data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
- data/lib/rails_ai_context/tools/get_api.rb +1 -6
- data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
- data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
- data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
- data/lib/rails_ai_context/tools/get_concern.rb +6 -6
- data/lib/rails_ai_context/tools/get_config.rb +189 -12
- data/lib/rails_ai_context/tools/get_context.rb +111 -105
- data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
- data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
- data/lib/rails_ai_context/tools/get_engines.rb +1 -6
- data/lib/rails_ai_context/tools/get_env.rb +1 -6
- data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
- data/lib/rails_ai_context/tools/get_gems.rb +1 -6
- data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
- data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
- data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
- data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
- data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
- data/lib/rails_ai_context/tools/get_schema.rb +10 -8
- data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
- data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
- data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
- data/lib/rails_ai_context/tools/onboard.rb +24 -24
- data/lib/rails_ai_context/tools/performance_check.rb +15 -2
- data/lib/rails_ai_context/tools/query.rb +142 -1356
- data/lib/rails_ai_context/tools/read_logs.rb +1 -1
- data/lib/rails_ai_context/tools/review_changes.rb +6 -7
- data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
- data/lib/rails_ai_context/tools/safe_call.rb +70 -28
- data/lib/rails_ai_context/tools/search_code.rb +0 -4
- data/lib/rails_ai_context/tools/search_docs.rb +3 -1
- data/lib/rails_ai_context/tools/security_scan.rb +9 -5
- data/lib/rails_ai_context/tools/session_context.rb +18 -12
- data/lib/rails_ai_context/tools/validate.rb +1 -0
- data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context.rb +4 -7
- data/server.json +2 -2
- metadata +14 -3
|
@@ -235,8 +235,7 @@ module RailsAiContext::RakeTasks
|
|
|
235
235
|
json_mode = ENV["JSON"] == "1"
|
|
236
236
|
|
|
237
237
|
if params.delete(:help) || ARGV.include?("--help")
|
|
238
|
-
|
|
239
|
-
puts RailsAiContext::CLI::ToolRunner.tool_help(runner.tool_class)
|
|
238
|
+
puts RailsAiContext::CLI::ToolRunner.new(name, {}).help
|
|
240
239
|
next
|
|
241
240
|
end
|
|
242
241
|
|
|
@@ -97,12 +97,14 @@ module RailsAiContext
|
|
|
97
97
|
return name_or_class if name_or_class.is_a?(Class) && name_or_class < MCP::Tool
|
|
98
98
|
|
|
99
99
|
name = name_or_class.to_s
|
|
100
|
-
all_tools.find do |t|
|
|
100
|
+
found = all_tools.find do |t|
|
|
101
101
|
t.tool_name == name ||
|
|
102
102
|
t.tool_name == "rails_#{name}" ||
|
|
103
103
|
t.tool_name == "rails_get_#{name}" ||
|
|
104
104
|
t.tool_name == "get_#{name}"
|
|
105
105
|
end
|
|
106
|
+
found || RailsAiContext::CLI::ToolRunner.candidate_names(name)
|
|
107
|
+
.filter_map { |candidate| RailsAiContext::Tools::GetConfig.retired_section(candidate) }.first&.renderer
|
|
106
108
|
end
|
|
107
109
|
|
|
108
110
|
def all_tools
|
|
@@ -96,18 +96,22 @@ module RailsAiContext
|
|
|
96
96
|
# If nothing was discovered, return a clean "no match" with real suggestions
|
|
97
97
|
has_content = lines.any? { |l| l.start_with?("## ") || l.start_with?("### ") }
|
|
98
98
|
unless has_content
|
|
99
|
-
|
|
100
|
-
hint = if model_names.any?
|
|
101
|
-
"\n\nTry one of your model names: #{model_names.join(', ')}"
|
|
102
|
-
else
|
|
103
|
-
"\n\nThis app has no models yet, so there are no feature names to suggest."
|
|
104
|
-
end
|
|
105
|
-
return text_response("No matches found for '#{echo_input(feature)}'. No models, controllers, routes, services, or views match this keyword.#{hint}")
|
|
99
|
+
return text_response("No matches found for '#{echo_input(feature)}'. No models, controllers, routes, services, or views match this keyword.#{model_names_hint(ctx)}")
|
|
106
100
|
end
|
|
107
101
|
|
|
108
102
|
text_response(lines.join("\n"))
|
|
109
103
|
end
|
|
110
104
|
|
|
105
|
+
# The names a keyword that matched nothing could have been.
|
|
106
|
+
def self.model_names_hint(ctx)
|
|
107
|
+
model_names = Payload.models(ctx).keys.map(&:to_s).sort.first(10)
|
|
108
|
+
if model_names.any?
|
|
109
|
+
"\n\nTry one of your model names: #{model_names.join(', ')}"
|
|
110
|
+
else
|
|
111
|
+
"\n\nThis app has no models yet, so there are no feature names to suggest."
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
111
115
|
class << self
|
|
112
116
|
private
|
|
113
117
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "time"
|
|
3
4
|
require "did_you_mean"
|
|
4
5
|
|
|
5
6
|
require "mcp"
|
|
@@ -48,6 +49,24 @@ module RailsAiContext
|
|
|
48
49
|
@abstract == true
|
|
49
50
|
end
|
|
50
51
|
|
|
52
|
+
# A class that renders one rails_get_config section: called through
|
|
53
|
+
# that tool, and so never registered under its own name.
|
|
54
|
+
def section_renderer!
|
|
55
|
+
abstract!
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The class in Tools a tool name spells, or nil.
|
|
59
|
+
def tool_class(name)
|
|
60
|
+
const = name.to_s.delete_prefix("rails_").split("_").map(&:capitalize).join
|
|
61
|
+
Tools.const_get(const, false) if const.match?(/\A[A-Z]\w*\z/) && Tools.const_defined?(const, false)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Whether the app serves this built-in itself. A custom tool under its
|
|
65
|
+
# name takes other parameters, so no answer runs it or points at it.
|
|
66
|
+
def own_served?(tool)
|
|
67
|
+
!tool.nil? && RailsAiContext::Server.served_tool(tool.tool_name).equal?(tool)
|
|
68
|
+
end
|
|
69
|
+
|
|
51
70
|
# Sorted so load order never reaches tool --list or tools/list.
|
|
52
71
|
def registered_tools
|
|
53
72
|
eager_load!
|
|
@@ -70,6 +89,9 @@ module RailsAiContext
|
|
|
70
89
|
# for thread safety in multi-threaded servers (e.g., Puma).
|
|
71
90
|
SHARED_CACHE = { mutex: Mutex.new }
|
|
72
91
|
|
|
92
|
+
# Per thread, one list per running tool call of the composed tools it left out.
|
|
93
|
+
LEFT_OUT = :rails_ai_context_left_out
|
|
94
|
+
|
|
73
95
|
# What a server last saw of its app's files (a Fingerprinter::Snapshot),
|
|
74
96
|
# when the check now running began, and when the last finished one
|
|
75
97
|
# did. The snapshot is nil until a server starts, so a process that is
|
|
@@ -240,7 +262,9 @@ module RailsAiContext
|
|
|
240
262
|
# 30 at 10,000 files. Only a check that began after a call did can
|
|
241
263
|
# stand in for that call's own, so two calls one after the other each
|
|
242
264
|
# check, and concurrent calls wait for one check together. A tool
|
|
243
|
-
# another tool calls is part of the first call and asks
|
|
265
|
+
# another tool calls is part of the first call, and SafeCall asks
|
|
266
|
+
# nothing for it. What a run already open read is dropped with the
|
|
267
|
+
# check, since it may be from before the edit.
|
|
244
268
|
#
|
|
245
269
|
# Outside SHARED_CACHE's mutex, unlike the TTL walk: a code reload
|
|
246
270
|
# waits for running calls to finish, and they may be waiting on it.
|
|
@@ -251,10 +275,10 @@ module RailsAiContext
|
|
|
251
275
|
# notes the files whose reload Bootsnap answers with their code from
|
|
252
276
|
# before the last write.
|
|
253
277
|
def refresh_if_files_changed!
|
|
254
|
-
return
|
|
278
|
+
return unless FILE_CHECK[:snapshot]
|
|
255
279
|
|
|
256
280
|
snapshot = await_check(Process.clock_gettime(Process::CLOCK_MONOTONIC))
|
|
257
|
-
return unless snapshot
|
|
281
|
+
return RunCache.clear unless snapshot
|
|
258
282
|
|
|
259
283
|
begin
|
|
260
284
|
changed = snapshot.changed?
|
|
@@ -266,6 +290,7 @@ module RailsAiContext
|
|
|
266
290
|
FILE_CHECK[:running] = nil
|
|
267
291
|
FILE_CHECK[:done].broadcast
|
|
268
292
|
end
|
|
293
|
+
RunCache.clear
|
|
269
294
|
end
|
|
270
295
|
rescue StandardError => e
|
|
271
296
|
RailsAiContext.debug_fail(e, nil, label: "refresh_if_files_changed!")
|
|
@@ -980,8 +1005,7 @@ module RailsAiContext
|
|
|
980
1005
|
|
|
981
1006
|
# Whether this tool's `detail` is DetailLevel's, read off the schema it
|
|
982
1007
|
# already publishes rather than a second list someone has to keep in
|
|
983
|
-
# step.
|
|
984
|
-
# is deliberately not covered.
|
|
1008
|
+
# step. A custom tool that spells its own levels is left alone.
|
|
985
1009
|
def detail_param?
|
|
986
1010
|
return @detail_param if defined?(@detail_param)
|
|
987
1011
|
|
|
@@ -1026,7 +1050,6 @@ module RailsAiContext
|
|
|
1026
1050
|
def text_response(text, suffix: nil)
|
|
1027
1051
|
suffix = [ suffix, static_tier_banner, stale_code_banner ].compact.join
|
|
1028
1052
|
suffix = nil if suffix.empty?
|
|
1029
|
-
text = served_hints(text)
|
|
1030
1053
|
|
|
1031
1054
|
record_call(text)
|
|
1032
1055
|
|
|
@@ -1049,53 +1072,53 @@ module RailsAiContext
|
|
|
1049
1072
|
MCP_CALL = /\b(rails_\w+)\(([^()]*)\)/
|
|
1050
1073
|
PARAM_HINT = /`((?:\w+:(?:"[^"`]*"|[\w.]+)(?:,\s*)?)+)`/
|
|
1051
1074
|
|
|
1052
|
-
# A
|
|
1053
|
-
#
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
return
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1075
|
+
# A call to another tool as an answer writes it, then what it is for; nil when the app does not
|
|
1076
|
+
# serve the gem's own tool or config section, so the hint is left out. A section goes by its retired name.
|
|
1077
|
+
def pointer(name, args = nil, purpose = nil)
|
|
1078
|
+
section = GetConfig.retired_section(name)
|
|
1079
|
+
args = [ %(section:"#{section.name}"), args ].compact.reject(&:empty?).join(", ") if section
|
|
1080
|
+
section ||= GetConfig.section_named("app") if name == "rails_get_config"
|
|
1081
|
+
return nil unless section ? section.served? : own_served?(tool_class(name))
|
|
1082
|
+
|
|
1083
|
+
[ "`#{section ? 'rails_get_config' : name}(#{args})`", purpose ].compact.join(" ")
|
|
1084
|
+
end
|
|
1085
|
+
|
|
1086
|
+
# A section of this answer that another tool builds. A tool the app does not
|
|
1087
|
+
# serve gives an empty one, and SafeCall names it once at the end of this answer.
|
|
1088
|
+
def compose(tool, **args)
|
|
1089
|
+
return tool.call(**args) if own_served?(tool)
|
|
1090
|
+
|
|
1091
|
+
Thread.current[BaseTool::LEFT_OUT]&.last&.push(tool.tool_name)
|
|
1092
|
+
MCP::Tool::Response.new([ { type: "text", text: "" } ], meta: { empty: true })
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
def left_out_note(names)
|
|
1096
|
+
return nil if names.empty?
|
|
1097
|
+
|
|
1098
|
+
many = names.size > 1
|
|
1099
|
+
"\n\n_#{names.map { |n| "`#{n}`" }.join(', ')} #{many ? 'are' : 'is'} turned off in this app " \
|
|
1100
|
+
"(skip_tools lists #{many ? 'them' : 'it'}), so #{many ? 'their sections are' : 'its section is'} left out._"
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
# "Call `<pointer>`" for a not-found answer's recovery line; nil when the tool is not served.
|
|
1104
|
+
def call_pointer(name, args = nil, purpose = nil)
|
|
1105
|
+
pointer(name, args, purpose)&.then { |call| "Call #{call}" }
|
|
1106
|
+
end
|
|
1107
|
+
|
|
1108
|
+
# The lines, with a blank line and the next-step hint after them when a pointer is served.
|
|
1109
|
+
def add_next_hint(lines, *pointers, **layout)
|
|
1110
|
+
hint = next_hint(*pointers, **layout)
|
|
1111
|
+
hint ? lines << "" << hint : lines
|
|
1084
1112
|
end
|
|
1085
1113
|
|
|
1086
|
-
#
|
|
1087
|
-
# when none is
|
|
1088
|
-
def
|
|
1089
|
-
|
|
1090
|
-
closing = body[/\.?_\z/].to_s
|
|
1091
|
-
pointers = body.delete_suffix(closing).split(/\n? \| /)
|
|
1092
|
-
served = pointers.reject { |pointer| skipped.include?(pointer[/\brails_\w+/]) }
|
|
1093
|
-
return block if served.size == pointers.size
|
|
1114
|
+
# `_Next:` with the pointers that are served, on one line or one per
|
|
1115
|
+
# line; nil when none is.
|
|
1116
|
+
def next_hint(*pointers, lines: false)
|
|
1117
|
+
served = pointers.compact
|
|
1094
1118
|
return nil if served.empty?
|
|
1095
1119
|
|
|
1096
|
-
"_Next: #{served.join(
|
|
1120
|
+
"_Next: #{served.join(lines ? "\n | " : " | ")}_"
|
|
1097
1121
|
end
|
|
1098
|
-
private :served_hint
|
|
1099
1122
|
|
|
1100
1123
|
def cli_form(text)
|
|
1101
1124
|
return text unless RailsAiContext.configuration.tool_mode == :cli && text.is_a?(String)
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "did_you_mean"
|
|
3
4
|
require "open3"
|
|
4
5
|
|
|
5
6
|
module RailsAiContext
|
|
@@ -34,7 +35,7 @@ module RailsAiContext
|
|
|
34
35
|
)
|
|
35
36
|
|
|
36
37
|
guide_row(
|
|
37
|
-
order:
|
|
38
|
+
order: 30,
|
|
38
39
|
mcp: "rails_diagnose(error:\"X\")",
|
|
39
40
|
cli_args: "error=\"X\"",
|
|
40
41
|
summary: "One-call error diagnosis: context + git changes + logs + fix suggestions"
|
|
@@ -60,63 +61,110 @@ module RailsAiContext
|
|
|
60
61
|
/NameError.*uninitialized constant/ => {
|
|
61
62
|
type: :name_error,
|
|
62
63
|
likely: "A class or module constant could not be found. This usually means a typo in the class/module name, a missing require, or an autoload issue.",
|
|
63
|
-
fix:
|
|
64
|
+
fix: [
|
|
65
|
+
"Check for typo in class/module name, missing require, or autoload issue",
|
|
66
|
+
"Verify the file is in the correct autoload path (e.g. app/models, app/services)",
|
|
67
|
+
{ tool: "rails_search_code", args: %(pattern:"ClassName", match_type:"trace"), text: "Run %s to find where the constant is defined" }
|
|
68
|
+
]
|
|
64
69
|
},
|
|
65
70
|
/NoMethodError/ => {
|
|
66
71
|
type: :nil_reference,
|
|
67
72
|
likely: "A method was called on nil or an undefined name was referenced. Check for: missing association, unfetched record, typo in method name.",
|
|
68
|
-
fix:
|
|
73
|
+
fix: [
|
|
74
|
+
"Check the variable/object is not nil before calling the method",
|
|
75
|
+
"Verify the association or attribute exists on the model",
|
|
76
|
+
"Use `&.` safe navigation if the value can legitimately be nil"
|
|
77
|
+
]
|
|
69
78
|
},
|
|
70
79
|
/NameError/ => {
|
|
71
80
|
type: :name_error,
|
|
72
81
|
likely: "An undefined name was referenced. This could be a typo in a variable, method, or constant name, or a missing require/autoload.",
|
|
73
|
-
fix:
|
|
82
|
+
fix: [
|
|
83
|
+
"Check for typo in class/module name, missing require, or autoload issue",
|
|
84
|
+
"Verify the name is defined and accessible in the current scope",
|
|
85
|
+
{ tool: "rails_search_code", args: %(pattern:"name", match_type:"trace"), text: "Use %s to find where it is defined" }
|
|
86
|
+
]
|
|
74
87
|
},
|
|
75
88
|
/RecordNotFound/ => {
|
|
76
89
|
type: :record_not_found,
|
|
77
90
|
likely: "A `.find()` or `.find_by!()` call failed because the record doesn't exist. Common causes: stale URL, deleted record, wrong ID parameter.",
|
|
78
|
-
fix:
|
|
91
|
+
fix: [
|
|
92
|
+
"Use `.find_by` (returns nil) instead of `.find` (raises)",
|
|
93
|
+
"Add a rescue handler or `before_action :set_record` with proper error handling",
|
|
94
|
+
"Check the route parameter name matches what the controller expects"
|
|
95
|
+
]
|
|
79
96
|
},
|
|
80
97
|
/RecordInvalid|RecordNotSaved/ => {
|
|
81
98
|
type: :validation_failure,
|
|
82
99
|
likely: "A `.save!`, `.create!`, or `.update!` call failed validation. The model has validations that the submitted data doesn't satisfy.",
|
|
83
|
-
fix:
|
|
100
|
+
fix: [
|
|
101
|
+
"Use `.save` (returns false) instead of `.save!` (raises) for user-facing flows",
|
|
102
|
+
"Check model validations against the params being submitted",
|
|
103
|
+
"Verify required associations exist before saving"
|
|
104
|
+
]
|
|
84
105
|
},
|
|
85
106
|
/RoutingError/ => {
|
|
86
107
|
type: :routing,
|
|
87
108
|
likely: "No route matches the request. The URL or HTTP verb doesn't match any defined route.",
|
|
88
|
-
fix:
|
|
109
|
+
fix: [
|
|
110
|
+
{ tool: "rails_get_routes", args: nil, text: "Run %s to see all defined routes" },
|
|
111
|
+
"Check the HTTP verb (GET vs POST vs PATCH) matches",
|
|
112
|
+
"Verify the route is not restricted by constraints"
|
|
113
|
+
]
|
|
89
114
|
},
|
|
90
115
|
/ParameterMissing/ => {
|
|
91
116
|
type: :strong_params,
|
|
92
117
|
likely: "A required parameter is missing from the request. The `params.require(:key)` call didn't find the expected key.",
|
|
93
|
-
fix:
|
|
118
|
+
fix: [
|
|
119
|
+
"Check the form field names match what strong params expects",
|
|
120
|
+
"Verify the parameter nesting (e.g., `params[:user][:name]` vs `params[:name]`)",
|
|
121
|
+
"Check the controller's `*_params` method"
|
|
122
|
+
]
|
|
94
123
|
},
|
|
95
124
|
/StatementInvalid|UndefinedColumn|UndefinedTable/ => {
|
|
96
125
|
type: :schema_mismatch,
|
|
97
126
|
likely: "A database query references a column or table that doesn't exist. Common after migrations or in stale schema.",
|
|
98
|
-
fix:
|
|
127
|
+
fix: [
|
|
128
|
+
"Run `rails db:migrate` to apply pending migrations",
|
|
129
|
+
{ tool: "rails_get_schema", args: %(table:"..."), text: "Check %s for actual column names" },
|
|
130
|
+
"Verify the migration was generated correctly"
|
|
131
|
+
]
|
|
99
132
|
},
|
|
100
133
|
/Template::Error|ActionView/ => {
|
|
101
134
|
type: :view_error,
|
|
102
135
|
likely: "An error occurred while rendering a view template. The underlying error is usually a NoMethodError or missing local variable.",
|
|
103
|
-
fix:
|
|
136
|
+
fix: [
|
|
137
|
+
"Check the instance variables set in the controller action",
|
|
138
|
+
"Verify partial locals are passed correctly",
|
|
139
|
+
{ tool: "rails_get_view", args: %(controller:"..."), text: "Use %s to see template structure" }
|
|
140
|
+
]
|
|
104
141
|
},
|
|
105
142
|
/ArgumentError/ => {
|
|
106
143
|
type: :argument_error,
|
|
107
144
|
likely: "A method received wrong number or type of arguments.",
|
|
108
|
-
fix:
|
|
145
|
+
fix: [
|
|
146
|
+
"Check the method signature matches the call site",
|
|
147
|
+
{ tool: "rails_search_code", args: %(pattern:"method_name", match_type:"trace"), text: "Use %s to see definition and callers" }
|
|
148
|
+
]
|
|
109
149
|
},
|
|
110
150
|
# The authorization gems' two failures, by the words their class names share.
|
|
111
151
|
/AuthorizationNotPerformed|PolicyScopingNotPerformed/ => {
|
|
112
152
|
type: :authorization_not_performed,
|
|
113
153
|
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:
|
|
154
|
+
fix: [
|
|
155
|
+
"Call `authorize record` (or `policy_scope(Model)`) in the action",
|
|
156
|
+
"If the action is meant to be open, leave it out of the check: `skip_after_action :verify_authorized, only: :action_name`",
|
|
157
|
+
{ tool: "rails_get_controllers", args: %(controller:"...", action:"..."), text: "Use %s to see the filters that run on the action" }
|
|
158
|
+
]
|
|
115
159
|
},
|
|
116
160
|
/NotAuthorized|AccessDenied/ => {
|
|
117
161
|
type: :authorization_denied,
|
|
118
162
|
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:
|
|
163
|
+
fix: [
|
|
164
|
+
"Read the policy method or ability rule the message names",
|
|
165
|
+
"Check which user and record the request carried",
|
|
166
|
+
"If refusing is expected, rescue it (`rescue_from`) and answer 403 instead of 500"
|
|
167
|
+
]
|
|
120
168
|
}
|
|
121
169
|
}.freeze
|
|
122
170
|
|
|
@@ -144,7 +192,7 @@ module RailsAiContext
|
|
|
144
192
|
|
|
145
193
|
# Suggested fix
|
|
146
194
|
lines << "## Suggested Fix"
|
|
147
|
-
lines << classification[:fix]
|
|
195
|
+
lines << fix_steps(classification[:fix])
|
|
148
196
|
lines << ""
|
|
149
197
|
|
|
150
198
|
# Gather context based on parameters and error type
|
|
@@ -171,19 +219,16 @@ module RailsAiContext
|
|
|
171
219
|
lines.concat(log_section) if log_section.any?
|
|
172
220
|
|
|
173
221
|
# Next steps
|
|
174
|
-
next_steps = [
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if parsed[:method_name]
|
|
179
|
-
next_steps << "_Use `rails_search_code(pattern:\"#{parsed[:method_name]}\", match_type:\"trace\")` to trace the method._"
|
|
180
|
-
end
|
|
222
|
+
next_steps = [
|
|
223
|
+
(pointer("rails_get_edit_context", %(file:"#{file}", near:"#{parsed[:method_name] || line || parsed[:exception_class]}"), "to see the code.") if file),
|
|
224
|
+
(pointer("rails_search_code", %(pattern:"#{parsed[:method_name]}", match_type:"trace"), "to trace the method.") if parsed[:method_name])
|
|
225
|
+
].compact.map { |step| "_Use #{step}_" }
|
|
181
226
|
if next_steps.any?
|
|
182
227
|
lines << "## Next Steps"
|
|
183
228
|
lines.concat(next_steps)
|
|
184
229
|
end
|
|
185
230
|
|
|
186
|
-
output = lines.join("\n")
|
|
231
|
+
output = lines.join("\n").rstrip
|
|
187
232
|
|
|
188
233
|
# Final safety cap: if total output still exceeds limit, hard-truncate
|
|
189
234
|
if output.length > MAX_TOTAL_OUTPUT
|
|
@@ -192,8 +237,6 @@ module RailsAiContext
|
|
|
192
237
|
|
|
193
238
|
# The diagnosis still answers the error, and the result is an error as every refusal is.
|
|
194
239
|
refusal ? error_response(output) : text_response(output)
|
|
195
|
-
rescue => e
|
|
196
|
-
text_response("Diagnosis error: #{e.message}")
|
|
197
240
|
end
|
|
198
241
|
|
|
199
242
|
# A name an error message gives its exception: a class ending the way
|
|
@@ -203,6 +246,18 @@ module RailsAiContext
|
|
|
203
246
|
class << self
|
|
204
247
|
private
|
|
205
248
|
|
|
249
|
+
# Numbered steps; one whose text holds a tool call (%s) is left out
|
|
250
|
+
# when the app does not serve that tool.
|
|
251
|
+
def fix_steps(steps)
|
|
252
|
+
kept = steps.filter_map do |step|
|
|
253
|
+
next step unless step.is_a?(Hash)
|
|
254
|
+
|
|
255
|
+
call = pointer(step[:tool], step[:args])
|
|
256
|
+
step[:text].sub("%s") { call } if call
|
|
257
|
+
end
|
|
258
|
+
kept.each_with_index.map { |step, index| "#{index + 1}. #{step}" }.join("\n")
|
|
259
|
+
end
|
|
260
|
+
|
|
206
261
|
def parse_error(error_string)
|
|
207
262
|
result = { exception_class: nil, message: error_string.strip, file_refs: [], method_name: nil }
|
|
208
263
|
|
|
@@ -297,9 +352,11 @@ module RailsAiContext
|
|
|
297
352
|
{
|
|
298
353
|
type: :unknown,
|
|
299
354
|
likely: likely,
|
|
300
|
-
fix:
|
|
301
|
-
|
|
302
|
-
|
|
355
|
+
fix: [
|
|
356
|
+
"Check the error message for clues about what went wrong",
|
|
357
|
+
{ tool: "rails_search_code", args: %(pattern:"#{exception_class&.split("::")&.last || "ErrorName"}"), text: "Use %s to find where the app raises or rescues it" },
|
|
358
|
+
{ tool: "rails_read_logs", args: %(level:"ERROR"), text: "Use %s for the backtrace" }
|
|
359
|
+
]
|
|
303
360
|
}
|
|
304
361
|
end
|
|
305
362
|
|
|
@@ -347,8 +404,10 @@ module RailsAiContext
|
|
|
347
404
|
return {
|
|
348
405
|
type: :non_public_method,
|
|
349
406
|
likely: "`#{m[2]}` is #{m[1]} on #{receiver ? "`#{receiver}`" : "the receiver"}, so it cannot be called from outside the object.",
|
|
350
|
-
fix:
|
|
351
|
-
|
|
407
|
+
fix: [
|
|
408
|
+
"Call it from inside the class, or make it public if it is part of the interface",
|
|
409
|
+
{ tool: "rails_search_code", args: %(pattern:"#{m[2]}", match_type:"definition"), text: "Use %s to see where it is defined and under which visibility" }
|
|
410
|
+
]
|
|
352
411
|
}
|
|
353
412
|
end
|
|
354
413
|
|
|
@@ -367,9 +426,11 @@ module RailsAiContext
|
|
|
367
426
|
{
|
|
368
427
|
type: :undefined_method,
|
|
369
428
|
likely: likely,
|
|
370
|
-
fix:
|
|
371
|
-
|
|
372
|
-
|
|
429
|
+
fix: [
|
|
430
|
+
suggestion ? "Call `#{suggestion}`, the method #{subject} has" : "Check the name against the methods #{subject} defines",
|
|
431
|
+
"Check the call goes to the object you think: an association or a presenter can hand back another class",
|
|
432
|
+
{ tool: "rails_search_code", args: %(pattern:"#{name}", match_type:"trace"), text: "Use %s to find its definition and its callers" }
|
|
433
|
+
]
|
|
373
434
|
}
|
|
374
435
|
end
|
|
375
436
|
|
|
@@ -448,9 +509,11 @@ module RailsAiContext
|
|
|
448
509
|
{
|
|
449
510
|
type: :undefined_method_on_model,
|
|
450
511
|
likely: likely,
|
|
451
|
-
fix:
|
|
452
|
-
|
|
453
|
-
|
|
512
|
+
fix: [
|
|
513
|
+
{ tool: "rails_get_model_details", args: %(model:"#{receiver}"), text: "Run %s to see #{receiver}'s real associations and columns" },
|
|
514
|
+
"Check for a typo in the method name or a missing association declaration (has_many/belongs_to)",
|
|
515
|
+
"If the data lives on another model, add the association or a delegate"
|
|
516
|
+
]
|
|
454
517
|
}
|
|
455
518
|
end
|
|
456
519
|
|
|
@@ -506,29 +569,29 @@ module RailsAiContext
|
|
|
506
569
|
if ctrl && act
|
|
507
570
|
add_section(lines, "Controller Context", report_error: true) do
|
|
508
571
|
ctrl_class = ctrl.end_with?("Controller") ? ctrl : "#{ctrl.camelize}Controller"
|
|
509
|
-
GetControllers
|
|
572
|
+
compose(GetControllers, controller: ctrl_class, action: act)
|
|
510
573
|
end
|
|
511
574
|
end
|
|
512
575
|
|
|
513
576
|
if file && line
|
|
514
577
|
add_section(lines, "Code Context", report_error: true) do
|
|
515
|
-
GetEditContext
|
|
578
|
+
compose(GetEditContext, file: file, near: parsed[:method_name] || line.to_s)
|
|
516
579
|
end
|
|
517
580
|
end
|
|
518
581
|
|
|
519
582
|
if classification[:type] == :schema_mismatch
|
|
520
583
|
table = parsed[:message].match(/(?:table|relation)\s+["']?(\w+)["']?/i)&.[](1)
|
|
521
|
-
add_section(lines, "Schema Context") { GetSchema
|
|
584
|
+
add_section(lines, "Schema Context") { compose(GetSchema, table: table) } if table
|
|
522
585
|
end
|
|
523
586
|
|
|
524
587
|
if classification[:type] == :validation_failure
|
|
525
588
|
model_name = file&.match(%r{app/models/(.+)\.rb})&.[](1)&.camelize
|
|
526
|
-
add_section(lines, "Model Context") { GetModelDetails
|
|
589
|
+
add_section(lines, "Model Context") { compose(GetModelDetails, model: model_name) } if model_name
|
|
527
590
|
end
|
|
528
591
|
|
|
529
592
|
if parsed[:method_name] && lines.none? { |l| l.include?("Code Context") }
|
|
530
593
|
add_section(lines, "Method Trace") do
|
|
531
|
-
result = SearchCode
|
|
594
|
+
result = compose(SearchCode, pattern: parsed[:method_name], match_type: "trace")
|
|
532
595
|
# A trace that found callers but no `def` is still not the
|
|
533
596
|
# method's definition, which is what this section promises.
|
|
534
597
|
definition_missing?(result) ? nil : result
|
|
@@ -566,7 +629,7 @@ module RailsAiContext
|
|
|
566
629
|
# Check if calling on an association
|
|
567
630
|
if method && file
|
|
568
631
|
begin
|
|
569
|
-
ctx = GetEditContext
|
|
632
|
+
ctx = compose(GetEditContext, file: file, near: method)
|
|
570
633
|
code = response_text(ctx)
|
|
571
634
|
# Find the receiver: something.method_name
|
|
572
635
|
receiver_match = code.match(/(\w+)\.#{Regexp.escape(method)}/)
|
|
@@ -586,7 +649,7 @@ module RailsAiContext
|
|
|
586
649
|
if ctrl && act
|
|
587
650
|
begin
|
|
588
651
|
ctrl_class = ctrl.end_with?("Controller") ? ctrl : "#{ctrl.camelize}Controller"
|
|
589
|
-
result = GetControllers
|
|
652
|
+
result = compose(GetControllers, controller: ctrl_class, action: act)
|
|
590
653
|
text = response_text(result)
|
|
591
654
|
if text.include?("set_") && text.include?("find")
|
|
592
655
|
return "The `set_*` before_action uses `.find` which raises RecordNotFound. " \
|
|
@@ -31,7 +31,7 @@ module RailsAiContext
|
|
|
31
31
|
)
|
|
32
32
|
|
|
33
33
|
guide_row(
|
|
34
|
-
order:
|
|
34
|
+
order: 29,
|
|
35
35
|
mcp: "rails_generate_test(model:\"X\")",
|
|
36
36
|
cli_args: "model=X",
|
|
37
37
|
summary: "Generate test scaffolding matching project patterns (framework, factories, style)"
|
|
@@ -61,8 +61,6 @@ module RailsAiContext
|
|
|
61
61
|
elsif file
|
|
62
62
|
generate_file_test(file.strip, framework, tests_data, type)
|
|
63
63
|
end
|
|
64
|
-
rescue => e
|
|
65
|
-
text_response("Generate test error: #{e.message}")
|
|
66
64
|
end
|
|
67
65
|
|
|
68
66
|
class << self
|
|
@@ -106,7 +104,7 @@ module RailsAiContext
|
|
|
106
104
|
key = fuzzy_find_key(models.keys, model_name)
|
|
107
105
|
unless key
|
|
108
106
|
return not_found_response("Model", model_name, models.keys.sort,
|
|
109
|
-
recovery_tool: "
|
|
107
|
+
recovery_tool: call_pointer("rails_get_model_details", %(detail:"summary"), "to see all models"))
|
|
110
108
|
end
|
|
111
109
|
|
|
112
110
|
data = models[key]
|
|
@@ -512,7 +510,7 @@ module RailsAiContext
|
|
|
512
510
|
ctrl_class = RailsAiContext::Payload.find_controller(cached_context, ctrl_name)
|
|
513
511
|
if known.any? && ctrl_class.nil?
|
|
514
512
|
return not_found_response("Controller", ctrl_name, known.keys.sort,
|
|
515
|
-
recovery_tool: "
|
|
513
|
+
recovery_tool: call_pointer("rails_get_controllers", %(detail:"summary"), "to see all controllers"))
|
|
516
514
|
end
|
|
517
515
|
|
|
518
516
|
ctrl_class ||= ctrl_name.end_with?("Controller") ? ctrl_name : "#{ctrl_name.camelize}Controller"
|
|
@@ -1673,7 +1671,7 @@ module RailsAiContext
|
|
|
1673
1671
|
key = fuzzy_find_key(models.keys, model_name)
|
|
1674
1672
|
unless key
|
|
1675
1673
|
return not_found_response("Model", model_name, models.keys.sort,
|
|
1676
|
-
recovery_tool: "
|
|
1674
|
+
recovery_tool: call_pointer("rails_get_model_details", %(detail:"summary"), "to see all models"))
|
|
1677
1675
|
end
|
|
1678
1676
|
RailsAiContext::Payload.controller_for_route_key(cached_context, key.to_s.underscore.pluralize)&.first
|
|
1679
1677
|
end
|
|
@@ -4,17 +4,12 @@ module RailsAiContext
|
|
|
4
4
|
module Tools
|
|
5
5
|
class GetActiveSupport < BaseTool
|
|
6
6
|
tool_name "rails_get_active_support"
|
|
7
|
+
section_renderer!
|
|
7
8
|
description "Get ActiveSupport surface: concern modules across app/**/concerns, registered deprecators, MessageVerifier/MessageEncryptor usage, the ActiveSupport::Notifications events the app subscribes to and where, tagged logging, subscribed on_load hooks, and cache store. " \
|
|
8
9
|
"Use when: checking framework-level wiring, finding where crypto helpers are used, or understanding boot-time hooks."
|
|
9
10
|
|
|
10
11
|
input_schema(properties: {})
|
|
11
12
|
|
|
12
|
-
guide_row(
|
|
13
|
-
order: 44,
|
|
14
|
-
mcp: "rails_get_active_support",
|
|
15
|
-
summary: "Concerns registry, deprecators, MessageVerifier usage, notification subscriptions, on_load hooks, cache store"
|
|
16
|
-
)
|
|
17
|
-
|
|
18
13
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
19
14
|
|
|
20
15
|
def self.call(server_context: nil)
|