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
|
@@ -4,6 +4,23 @@ module RailsAiContext
|
|
|
4
4
|
module Tools
|
|
5
5
|
class GetContext < BaseTool
|
|
6
6
|
tool_name "rails_get_context"
|
|
7
|
+
|
|
8
|
+
# Each include key and the call it bundles, run only when the app serves that tool.
|
|
9
|
+
INCLUDE_MAP = {
|
|
10
|
+
"stimulus" => [ "rails_get_stimulus", { detail: "standard" } ],
|
|
11
|
+
"turbo" => [ "rails_get_turbo_map", { detail: "standard" } ],
|
|
12
|
+
"services" => [ "rails_get_service_pattern", { detail: "standard" } ],
|
|
13
|
+
"jobs" => [ "rails_get_job_pattern", { detail: "standard" } ],
|
|
14
|
+
"conventions" => [ "rails_get_config", { section: "conventions" } ],
|
|
15
|
+
"helpers" => [ "rails_get_helper_methods", { detail: "standard" } ],
|
|
16
|
+
"env" => [ "rails_get_config", { section: "env", detail: "summary" } ],
|
|
17
|
+
"callbacks" => [ "rails_get_callbacks", { detail: "standard" } ],
|
|
18
|
+
"tests" => [ "rails_get_test_info", { detail: "full" } ],
|
|
19
|
+
"config" => [ "rails_get_config", { section: "app" } ],
|
|
20
|
+
"gems" => [ "rails_get_config", { section: "gems" } ],
|
|
21
|
+
"security" => [ "rails_security_scan", { detail: "summary" } ]
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
7
24
|
description "Get cross-layer context in a single call - combines schema, model, controller, routes, views, stimulus, and tests. " \
|
|
8
25
|
"Use when: you need full context for implementing a feature or modifying an action. " \
|
|
9
26
|
"Specify controller:\"PostsController\" action:\"create\" to get everything for that action in one call."
|
|
@@ -29,7 +46,7 @@ module RailsAiContext
|
|
|
29
46
|
include: {
|
|
30
47
|
type: "array",
|
|
31
48
|
items: { type: "string" },
|
|
32
|
-
description: "Additional context to bundle:
|
|
49
|
+
description: "Additional context to bundle: #{INCLUDE_MAP.keys.map { |key| "'#{key}'" }.join(', ')}. Appends these to any mode."
|
|
33
50
|
}
|
|
34
51
|
}
|
|
35
52
|
)
|
|
@@ -56,8 +73,7 @@ module RailsAiContext
|
|
|
56
73
|
return error_response("Provide at least one of: controller, model, or feature.")
|
|
57
74
|
end
|
|
58
75
|
|
|
59
|
-
|
|
60
|
-
base_text += append_includes(include) if include.is_a?(Array) && include.any?
|
|
76
|
+
base_text = sections(base_text, *include_sections(include)) if include.is_a?(Array) && include.any?
|
|
61
77
|
|
|
62
78
|
ctx = begin
|
|
63
79
|
cached_context
|
|
@@ -68,31 +84,26 @@ module RailsAiContext
|
|
|
68
84
|
text_response(base_text, suffix: introspection_warnings_note(ctx))
|
|
69
85
|
end
|
|
70
86
|
|
|
71
|
-
|
|
72
|
-
|
|
87
|
+
# The sections that have text, with a rule between them.
|
|
88
|
+
private_class_method def self.sections(*texts)
|
|
89
|
+
texts.compact.reject(&:empty?).join("\n\n---\n\n")
|
|
90
|
+
end
|
|
73
91
|
|
|
92
|
+
private_class_method def self.section_text(response)
|
|
93
|
+
response_text(response) unless empty?(response)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private_class_method def self.controller_action_context(controller_name, action_name)
|
|
74
97
|
# Controller + action source + private methods + instance vars
|
|
75
|
-
|
|
76
|
-
lines << response_text(ctrl_result)
|
|
98
|
+
ctrl_text = controller_text(compose(GetControllers, controller: controller_name, action: action_name), controller_name, action_name)
|
|
77
99
|
|
|
78
100
|
# Infer model from controller
|
|
79
101
|
snake = RailsAiContext::Payload.controller_route_key(cached_context, controller_name)
|
|
80
102
|
model_name = snake.split("/").last.singularize.camelize
|
|
81
103
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
lines << "" << "---" << "" << response_text(model_result)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Routes for this controller
|
|
89
|
-
route_result = GetRoutes.call(controller: snake)
|
|
90
|
-
unless empty?(route_result)
|
|
91
|
-
lines << "" << "---" << ""
|
|
92
|
-
lines << response_text(route_result)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
lines.concat(view_section_lines(snake))
|
|
104
|
+
text = sections(ctrl_text, section_text(compose(GetModelDetails, model: model_name)),
|
|
105
|
+
section_text(compose(GetRoutes, controller: snake)), view_section(snake))
|
|
106
|
+
extras = []
|
|
96
107
|
|
|
97
108
|
# Cross-reference: controller ivars vs view ivars. The action's own
|
|
98
109
|
# source, the methods it calls and the before filters that run for it
|
|
@@ -117,7 +128,7 @@ module RailsAiContext
|
|
|
117
128
|
if Payload.section(cached_context, :view_templates)
|
|
118
129
|
ivar_check = cross_reference_ivars(ctrl_ivars, view_ivars, rendered_templates: other_templates, api_only: api_only_app?,
|
|
119
130
|
origins: origins.except(*(action_body ? resolver.assigned_ivars(action_body) : [])))
|
|
120
|
-
|
|
131
|
+
extras << ivar_check if ivar_check
|
|
121
132
|
end
|
|
122
133
|
|
|
123
134
|
# Hydrate: inject schema hints for models referenced in controller + view ivars
|
|
@@ -126,14 +137,37 @@ module RailsAiContext
|
|
|
126
137
|
hydration = Hydrators::ViewHydrator.call(all_ivars, context: cached_context)
|
|
127
138
|
hydration_text = Hydrators::HydrationFormatter.format(hydration)
|
|
128
139
|
# Skip if get_controllers already injected schema hints
|
|
129
|
-
unless hydration_text.empty? ||
|
|
130
|
-
lines << "" << hydration_text
|
|
131
|
-
end
|
|
140
|
+
extras << hydration_text unless hydration_text.empty? || text.include?("## Schema Hints")
|
|
132
141
|
end
|
|
133
142
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
143
|
+
[ text, *extras ].reject(&:empty?).join("\n\n")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The controller section. With rails_get_controllers off it is empty, so a controller
|
|
147
|
+
# or an action the app does not have is answered here as that tool answers it.
|
|
148
|
+
private_class_method def self.controller_text(result, name, action = nil)
|
|
149
|
+
ctx = cached_context
|
|
150
|
+
return response_text(result) if own_served?(GetControllers)
|
|
151
|
+
|
|
152
|
+
key = Payload.find_controller(ctx, name)
|
|
153
|
+
known = key || name.to_s.strip.casecmp?("ApplicationController")
|
|
154
|
+
return response_text(not_found_response("Controller", name, Payload.app_controllers(ctx).keys.sort)) unless known
|
|
155
|
+
|
|
156
|
+
(action && key && missing_action_text(ctx, key, action)).to_s
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private_class_method def self.missing_action_text(ctx, name, action)
|
|
160
|
+
controllers = Payload.controllers(ctx)
|
|
161
|
+
info = controllers[name]
|
|
162
|
+
return nil unless info.is_a?(Hash)
|
|
163
|
+
|
|
164
|
+
name, info = info[:alias_of], controllers[info[:alias_of]] if controllers[info[:alias_of]].is_a?(Hash)
|
|
165
|
+
actions = Array(info[:actions]).map { |a| (a.is_a?(Hash) ? a[:name] : a).to_s }
|
|
166
|
+
return nil if actions.any? { |a| a.casecmp?(action.to_s) }
|
|
167
|
+
return nil if RailsAiContext::Introspectors::ActionSource.unlisted_action(rails_app.root, name, action.to_s,
|
|
168
|
+
file: Payload.controller_file(ctx, name), controllers: controllers)
|
|
169
|
+
|
|
170
|
+
GetControllers.missing_action_message(name, action, actions)
|
|
137
171
|
end
|
|
138
172
|
|
|
139
173
|
# The templates for a controller, read from the directory Rails would
|
|
@@ -143,23 +177,23 @@ module RailsAiContext
|
|
|
143
177
|
# convention - on a namespaced app they belong to another component
|
|
144
178
|
# entirely.
|
|
145
179
|
#
|
|
146
|
-
private_class_method def self.
|
|
180
|
+
private_class_method def self.view_section(snake)
|
|
147
181
|
view_result, view_note = controller_views(snake)
|
|
148
|
-
return
|
|
182
|
+
return nil if empty?(view_result)
|
|
149
183
|
|
|
150
|
-
[
|
|
184
|
+
[ view_note, response_text(view_result) ].compact.join("\n")
|
|
151
185
|
end
|
|
152
186
|
|
|
153
187
|
# @return [Array(MCP::Tool::Response, String, nil)] the view section and
|
|
154
188
|
# a note naming the directory it came from
|
|
155
189
|
private_class_method def self.controller_views(snake)
|
|
156
|
-
namespaced = GetView
|
|
190
|
+
namespaced = compose(GetView, controller: snake, detail: "standard")
|
|
157
191
|
return [ namespaced, "_Views from `app/views/#{snake}`._" ] unless empty?(namespaced)
|
|
158
192
|
|
|
159
193
|
basename = snake.to_s.split("/").last
|
|
160
194
|
return [ namespaced, nil ] if basename.nil? || basename == snake
|
|
161
195
|
|
|
162
|
-
flat = GetView
|
|
196
|
+
flat = compose(GetView, controller: basename, detail: "standard")
|
|
163
197
|
return [ namespaced, nil ] if empty?(flat)
|
|
164
198
|
|
|
165
199
|
[ flat, "_No templates under `app/views/#{snake}`; these are `app/views/#{basename}`, which Rails resolves for this controller only if it sets its own view path._" ]
|
|
@@ -264,29 +298,13 @@ module RailsAiContext
|
|
|
264
298
|
end
|
|
265
299
|
|
|
266
300
|
private_class_method def self.controller_context(controller_name)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
ctrl_result = GetControllers.call(controller: controller_name)
|
|
270
|
-
lines << response_text(ctrl_result)
|
|
271
|
-
|
|
301
|
+
ctrl_text = controller_text(compose(GetControllers, controller: controller_name), controller_name)
|
|
272
302
|
snake = RailsAiContext::Payload.controller_route_key(cached_context, controller_name)
|
|
273
303
|
|
|
274
|
-
|
|
275
|
-
route_result = GetRoutes.call(controller: snake)
|
|
276
|
-
unless empty?(route_result)
|
|
277
|
-
lines << "" << "---" << "" << response_text(route_result)
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
lines.concat(view_section_lines(snake))
|
|
281
|
-
|
|
282
|
-
lines.join("\n")
|
|
283
|
-
rescue => e
|
|
284
|
-
"Error assembling context: #{e.message}"
|
|
304
|
+
sections(ctrl_text, section_text(compose(GetRoutes, controller: snake)), view_section(snake))
|
|
285
305
|
end
|
|
286
306
|
|
|
287
307
|
private_class_method def self.model_context(model_name)
|
|
288
|
-
lines = []
|
|
289
|
-
|
|
290
308
|
# Normalize: try as-is, then singularized, then classified
|
|
291
309
|
ctx = cached_context
|
|
292
310
|
models = Payload.models(ctx)
|
|
@@ -294,64 +312,49 @@ module RailsAiContext
|
|
|
294
312
|
|
|
295
313
|
resolved_name = key || model_name
|
|
296
314
|
|
|
297
|
-
model_result = GetModelDetails
|
|
298
|
-
|
|
299
|
-
# If model not found, fail fast - don't leak partial results from sub-tools
|
|
300
|
-
return response_text(model_result) if empty?(model_result)
|
|
301
|
-
|
|
302
|
-
lines << response_text(model_result)
|
|
303
|
-
|
|
304
|
-
if key && models[key][:table_name]
|
|
305
|
-
schema_result = GetSchema.call(table: models[key][:table_name])
|
|
306
|
-
unless empty?(schema_result)
|
|
307
|
-
lines << "" << "---" << "" << response_text(schema_result)
|
|
308
|
-
end
|
|
309
|
-
end
|
|
315
|
+
model_result = compose(GetModelDetails, model: resolved_name)
|
|
310
316
|
|
|
311
|
-
#
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
317
|
+
# If model not found, fail fast - don't leak partial results from sub-tools.
|
|
318
|
+
# A model the app has still gets its schema and tests when model details is turned off.
|
|
319
|
+
served = own_served?(GetModelDetails)
|
|
320
|
+
return response_text(not_found_response("Model", model_name.to_s.strip, models.keys.sort)) if key.nil? && !served
|
|
321
|
+
return response_text(model_result) if empty?(model_result) && served
|
|
316
322
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
323
|
+
table = key && models[key][:table_name]
|
|
324
|
+
sections(response_text(model_result), (section_text(compose(GetSchema, table: table)) if table),
|
|
325
|
+
section_text(compose(GetTestInfo, model: resolved_name, detail: "standard")))
|
|
320
326
|
end
|
|
321
327
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
"env" => -> { GetEnv.call(detail: "summary") },
|
|
330
|
-
"callbacks" => -> { GetCallbacks.call(detail: "standard") },
|
|
331
|
-
"tests" => -> { GetTestInfo.call(detail: "full") },
|
|
332
|
-
"config" => -> { GetConfig.call },
|
|
333
|
-
"gems" => -> { GetGems.call },
|
|
334
|
-
"security" => -> { SecurityScan.call(detail: "summary") }
|
|
335
|
-
}.freeze
|
|
328
|
+
private_class_method def self.include_sections(includes)
|
|
329
|
+
includes.uniq { |key| key.to_s.downcase }.map do |key|
|
|
330
|
+
name, args = INCLUDE_MAP[key.to_s.downcase]
|
|
331
|
+
next "_`include` does not take '#{echo_input(key)}'. It takes: #{INCLUDE_MAP.keys.join(', ')}._" unless name
|
|
332
|
+
|
|
333
|
+
off = include_off_message(name, args)
|
|
334
|
+
next "_#{off}_" if off
|
|
336
335
|
|
|
337
|
-
private_class_method def self.append_includes(includes)
|
|
338
|
-
extra = +""
|
|
339
|
-
includes.each do |key|
|
|
340
|
-
handler = INCLUDE_MAP[key.to_s.downcase]
|
|
341
|
-
next unless handler
|
|
342
336
|
begin
|
|
343
|
-
|
|
337
|
+
response_text(tool_class(name).call(**args))
|
|
344
338
|
rescue => e
|
|
345
|
-
|
|
339
|
+
"_Error loading #{key}: #{e.message}_"
|
|
346
340
|
end
|
|
347
341
|
end
|
|
348
|
-
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Why the app does not serve an include's tool or config section, or nil when it does.
|
|
345
|
+
private_class_method def self.include_off_message(name, args)
|
|
346
|
+
section = GetConfig.section_named(args[:section]) if name == "rails_get_config"
|
|
347
|
+
return section.off_message if section && !section.on?
|
|
348
|
+
|
|
349
|
+
RailsAiContext::Server.skipped_tool_message(name) unless section ? section.served? : own_served?(tool_class(name))
|
|
349
350
|
end
|
|
350
351
|
|
|
351
352
|
private_class_method def self.feature_context(feature_name)
|
|
352
353
|
# Start with full-stack feature analysis
|
|
353
|
-
analyze_result = AnalyzeFeature
|
|
354
|
-
|
|
354
|
+
analyze_result = compose(AnalyzeFeature, feature: feature_name)
|
|
355
|
+
parts = [ response_text(analyze_result) ]
|
|
356
|
+
related = []
|
|
357
|
+
matched_models = []
|
|
355
358
|
|
|
356
359
|
# Enrich with schema columns for matching models
|
|
357
360
|
ctx = begin; cached_context; rescue; nil; end
|
|
@@ -361,13 +364,11 @@ module RailsAiContext
|
|
|
361
364
|
|
|
362
365
|
models.each_key do |model_name|
|
|
363
366
|
next unless model_name.downcase.include?(feature_name.downcase)
|
|
367
|
+
matched_models << model_name
|
|
364
368
|
table_name = models[model_name][:table_name]
|
|
365
369
|
next unless table_name
|
|
366
370
|
matched_tables << table_name
|
|
367
|
-
|
|
368
|
-
unless empty?(schema_result)
|
|
369
|
-
lines << "" << "---" << "" << response_text(schema_result)
|
|
370
|
-
end
|
|
371
|
+
parts << section_text(compose(GetSchema, table: table_name))
|
|
371
372
|
end
|
|
372
373
|
|
|
373
374
|
# Also include schema for related models (associated tables) if the
|
|
@@ -382,21 +383,26 @@ module RailsAiContext
|
|
|
382
383
|
c_name.downcase.include?(feature_name.pluralize.downcase)
|
|
383
384
|
end
|
|
384
385
|
if related_ctrls.any?
|
|
385
|
-
|
|
386
|
+
related << "## Related Controllers (by name)"
|
|
386
387
|
related_ctrls.each do |c_name, c|
|
|
387
388
|
actions = (c[:actions] || []).map { |a| a.is_a?(Hash) ? a[:name] : a }.compact
|
|
388
|
-
|
|
389
|
+
related << "- **#{c_name}** - #{actions.join(', ')}"
|
|
389
390
|
end
|
|
390
391
|
end
|
|
391
|
-
|
|
392
392
|
end
|
|
393
393
|
end
|
|
394
394
|
|
|
395
|
-
|
|
395
|
+
text = [ sections(*parts), related.join("\n") ].reject(&:empty?).join("\n\n")
|
|
396
|
+
return text unless text.empty? && !own_served?(AnalyzeFeature)
|
|
397
|
+
|
|
398
|
+
# With rails_analyze_feature off, only model and controller names were matched.
|
|
399
|
+
return "The model names that hold '#{echo_input(feature_name)}': #{matched_models.sort.join(', ')}." if matched_models.any?
|
|
400
|
+
|
|
401
|
+
"No matches found for '#{echo_input(feature_name)}'. No model or controller name holds this keyword.#{AnalyzeFeature.model_names_hint(ctx)}"
|
|
396
402
|
rescue => e
|
|
397
403
|
# Fall back to plain analyze_feature on error
|
|
398
404
|
RailsAiContext.debug_fail(e, label: "feature_context")
|
|
399
|
-
response_text(AnalyzeFeature
|
|
405
|
+
response_text(compose(AnalyzeFeature, feature: feature_name))
|
|
400
406
|
end
|
|
401
407
|
end
|
|
402
408
|
end
|
|
@@ -287,6 +287,10 @@ module RailsAiContext
|
|
|
287
287
|
end
|
|
288
288
|
end
|
|
289
289
|
|
|
290
|
+
def self.missing_action_message(controller_name, action_name, actions)
|
|
291
|
+
"Action '#{echo_input(action_name)}' not found in #{controller_name}. Available: #{actions.join(', ')}"
|
|
292
|
+
end
|
|
293
|
+
|
|
290
294
|
private_class_method def self.format_action_source(controller_name, info, action_name)
|
|
291
295
|
# An alias is the same class: its actions are read from the controller it names.
|
|
292
296
|
target = info[:alias_of] && RailsAiContext::Payload.controllers(cached_context)[info[:alias_of]]
|
|
@@ -304,7 +308,7 @@ module RailsAiContext
|
|
|
304
308
|
unlisted = RailsAiContext::Introspectors::ActionSource.unlisted_action(rails_app.root, controller_name, action_name,
|
|
305
309
|
file: carried, controllers: RailsAiContext::Payload.controllers(cached_context))
|
|
306
310
|
unless unlisted
|
|
307
|
-
return empty_response(
|
|
311
|
+
return empty_response(missing_action_message(controller_name, action_name, actions))
|
|
308
312
|
end
|
|
309
313
|
|
|
310
314
|
action_name = unlisted[:name]
|
|
@@ -681,12 +685,11 @@ module RailsAiContext
|
|
|
681
685
|
model_name = RailsAiContext::Introspectors::TableName.model_for(
|
|
682
686
|
ctrl_path.split("/").last.singularize.camelize, nil, RailsAiContext::Payload.models(ctx)
|
|
683
687
|
)
|
|
684
|
-
lines << ""
|
|
685
|
-
lines << "_Next: `rails_get_routes(controller:\"#{ctrl_path}\")` for routes"
|
|
686
|
-
lines << " | `rails_get_model_details(model:\"#{model_name}\")` for model" if model_name
|
|
687
688
|
# The full path, which is the directory Rails resolves templates in:
|
|
688
689
|
# the basename reads another component's views on a namespaced app.
|
|
689
|
-
lines
|
|
690
|
+
add_next_hint(lines, pointer("rails_get_routes", %(controller:"#{ctrl_path}"), "for routes"),
|
|
691
|
+
(pointer("rails_get_model_details", %(model:"#{model_name}"), "for model") if model_name),
|
|
692
|
+
pointer("rails_get_view", %(controller:"#{ctrl_path}"), "for views"), lines: true)
|
|
690
693
|
|
|
691
694
|
lines.join("\n")
|
|
692
695
|
end
|
|
@@ -4,18 +4,13 @@ module RailsAiContext
|
|
|
4
4
|
module Tools
|
|
5
5
|
class GetConventions < BaseTool
|
|
6
6
|
tool_name "rails_get_conventions"
|
|
7
|
+
section_renderer!
|
|
7
8
|
description "Detect app architecture and conventions: API-only vs Hotwire, design patterns, directory layout. " \
|
|
8
9
|
"Use when: starting work on an unfamiliar codebase, choosing implementation patterns, or checking what frameworks are in use. " \
|
|
9
10
|
"No parameters needed. Returns architecture style, detected patterns (STI, service objects), and notable config files."
|
|
10
11
|
|
|
11
12
|
input_schema(properties: {})
|
|
12
13
|
|
|
13
|
-
guide_row(
|
|
14
|
-
order: 23,
|
|
15
|
-
mcp: "rails_get_conventions",
|
|
16
|
-
summary: "App patterns: auth checks, flash messages, test patterns"
|
|
17
|
-
)
|
|
18
|
-
|
|
19
14
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
20
15
|
|
|
21
16
|
def self.call(server_context: nil)
|
|
@@ -4,17 +4,12 @@ module RailsAiContext
|
|
|
4
4
|
module Tools
|
|
5
5
|
class GetEngines < BaseTool
|
|
6
6
|
tool_name "rails_get_engines"
|
|
7
|
+
section_renderer!
|
|
7
8
|
description "Get what config/routes.rb mounts - engines and plain Rack apps alike, with known-engine descriptions - the app's own in-repo engines, plugins and modules, and every loaded Rails::Engine subclass (routes and model counts). " \
|
|
8
9
|
"Use when: checking what is mounted where, finding an admin dashboard's path, listing the repository's own engines, or understanding engine-provided routes."
|
|
9
10
|
|
|
10
11
|
input_schema(properties: {})
|
|
11
12
|
|
|
12
|
-
guide_row(
|
|
13
|
-
order: 42,
|
|
14
|
-
mcp: "rails_get_engines",
|
|
15
|
-
summary: "Mounted apps (engines and Rack apps) + in-repo engines + loaded engine classes with route/model counts"
|
|
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)
|
|
@@ -7,6 +7,7 @@ module RailsAiContext
|
|
|
7
7
|
module Tools
|
|
8
8
|
class GetEnv < BaseTool
|
|
9
9
|
tool_name "rails_get_env"
|
|
10
|
+
section_renderer!
|
|
10
11
|
description "Discover environment variables, external service dependencies, and credentials keys used by the app. " \
|
|
11
12
|
"Use when: setting up a development environment, debugging missing config, or auditing external dependencies. " \
|
|
12
13
|
"Scans .rb, .rake, ERB views and config YAML for ENV[], plus .env.example, Dockerfile, the env Kamal's config/deploy.yml sets, config gem settings and Anyway::Config keys, external HTTP calls, and credentials keys (never values)."
|
|
@@ -17,12 +18,6 @@ module RailsAiContext
|
|
|
17
18
|
}
|
|
18
19
|
)
|
|
19
20
|
|
|
20
|
-
guide_row(
|
|
21
|
-
order: 17,
|
|
22
|
-
mcp: "rails_get_env",
|
|
23
|
-
summary: "Environment variables + credentials keys (not values)"
|
|
24
|
-
)
|
|
25
|
-
|
|
26
21
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
27
22
|
|
|
28
23
|
# One wording for the same fact on both detail levels.
|
|
@@ -4,11 +4,12 @@ module RailsAiContext
|
|
|
4
4
|
module Tools
|
|
5
5
|
class GetEnvConfig < BaseTool
|
|
6
6
|
tool_name "rails_get_env_config"
|
|
7
|
+
section_renderer!
|
|
7
8
|
description "Get per-environment configuration from config/environments/*.rb: notable toggles (force_ssl, eager_load, caching, log level, queue adapter, mailer delivery) and every config key each environment sets, " \
|
|
8
9
|
"plus the keys config/application.rb sets for every environment (config.x included) and the keys each config_for YAML file gives. " \
|
|
9
10
|
"Use when: comparing development vs production behavior, debugging env-specific bugs, or checking what a custom environment changes. " \
|
|
10
11
|
"Filter with environment:\"production\". Omit for all environments. " \
|
|
11
|
-
"For environment variables and ENV[] usage, use
|
|
12
|
+
"For environment variables and ENV[] usage, use rails_get_config(section:\"env\") instead."
|
|
12
13
|
|
|
13
14
|
input_schema(
|
|
14
15
|
properties: {
|
|
@@ -27,13 +28,6 @@ module RailsAiContext
|
|
|
27
28
|
}
|
|
28
29
|
)
|
|
29
30
|
|
|
30
|
-
guide_row(
|
|
31
|
-
order: 45,
|
|
32
|
-
mcp: "rails_get_env_config(environment:\"production\")",
|
|
33
|
-
cli_args: "environment=production",
|
|
34
|
-
summary: "Per-environment config: notable toggles + config keys each env sets"
|
|
35
|
-
)
|
|
36
|
-
|
|
37
31
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
38
32
|
|
|
39
33
|
def self.call(environment: nil, offset: 0, limit: nil, server_context: nil)
|
|
@@ -4,6 +4,7 @@ module RailsAiContext
|
|
|
4
4
|
module Tools
|
|
5
5
|
class GetFrontendStack < BaseTool
|
|
6
6
|
tool_name "rails_get_frontend_stack"
|
|
7
|
+
section_renderer!
|
|
7
8
|
description "Returns the app's frontend stack: framework, build tool, state management, TypeScript config, " \
|
|
8
9
|
"component directories, and package manager. " \
|
|
9
10
|
"Use when: scaffolding new frontend features, choosing libraries, or understanding the JS/TS build pipeline. " \
|
|
@@ -15,12 +16,6 @@ module RailsAiContext
|
|
|
15
16
|
}
|
|
16
17
|
)
|
|
17
18
|
|
|
18
|
-
guide_row(
|
|
19
|
-
order: 29,
|
|
20
|
-
mcp: "rails_get_frontend_stack",
|
|
21
|
-
summary: "React/Vue/Svelte/Angular, Inertia, TypeScript, package manager"
|
|
22
|
-
)
|
|
23
|
-
|
|
24
19
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
25
20
|
|
|
26
21
|
def self.call(detail: "standard", server_context: nil)
|
|
@@ -4,6 +4,7 @@ module RailsAiContext
|
|
|
4
4
|
module Tools
|
|
5
5
|
class GetGems < BaseTool
|
|
6
6
|
tool_name "rails_get_gems"
|
|
7
|
+
section_renderer!
|
|
7
8
|
description "Get notable gems from Gemfile.lock grouped by category (auth, jobs, frontend, API, database, testing, deploy and more). " \
|
|
8
9
|
"Use when: checking what libraries are available before adding a dependency, or understanding the tech stack. " \
|
|
9
10
|
"Filter with category:\"auth\" or category:\"database\". Omit for all categories."
|
|
@@ -26,12 +27,6 @@ module RailsAiContext
|
|
|
26
27
|
}
|
|
27
28
|
)
|
|
28
29
|
|
|
29
|
-
guide_row(
|
|
30
|
-
order: 22,
|
|
31
|
-
mcp: "rails_get_gems",
|
|
32
|
-
summary: "Notable gems with versions, categories, config file locations"
|
|
33
|
-
)
|
|
34
|
-
|
|
35
30
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
36
31
|
|
|
37
32
|
# A `config/initializers/` hint is resolved through PathResolver, so the
|
|
@@ -55,7 +55,7 @@ module RailsAiContext
|
|
|
55
55
|
)
|
|
56
56
|
|
|
57
57
|
guide_row(
|
|
58
|
-
order:
|
|
58
|
+
order: 19,
|
|
59
59
|
mcp: "rails_get_helper_methods",
|
|
60
60
|
summary: "App + framework helpers with view cross-references"
|
|
61
61
|
)
|
|
@@ -208,11 +208,10 @@ module RailsAiContext
|
|
|
208
208
|
# named for, and only those the app has: ApplicationHelper has no
|
|
209
209
|
# controller of its own to send the reader to.
|
|
210
210
|
route_key = relative_under(file_path, helper_dirs).delete_suffix(".rb").delete_suffix("_helper")
|
|
211
|
-
|
|
212
|
-
hints << "`rails_get_view(controller:\"#{route_key}\")` for views" if RailsAiContext::PathResolver.view_dirs(root).any? { |dir| Dir.exist?(File.join(dir, route_key)) }
|
|
211
|
+
views = RailsAiContext::PathResolver.view_dirs(root).any? { |dir| Dir.exist?(File.join(dir, route_key)) }
|
|
213
212
|
controller = Payload.controller_for_route_key(cached_context, route_key)&.first
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
add_next_hint(lines, (pointer("rails_get_view", %(controller:"#{route_key}"), "for views") if views),
|
|
214
|
+
(pointer("rails_get_controllers", %(controller:"#{controller}"), "for controller") if controller))
|
|
216
215
|
|
|
217
216
|
text_response(lines.join("\n"))
|
|
218
217
|
end
|
|
@@ -4,6 +4,7 @@ module RailsAiContext
|
|
|
4
4
|
module Tools
|
|
5
5
|
class GetI18n < BaseTool
|
|
6
6
|
tool_name "rails_get_i18n"
|
|
7
|
+
section_renderer!
|
|
7
8
|
description "Get I18n setup: default/available locales, backend, locale files with key counts, per-locale coverage vs the default locale, and fallbacks. " \
|
|
8
9
|
"Use when: adding translations, checking locale coverage before shipping, or finding which file defines a locale. " \
|
|
9
10
|
"Filter with locale:\"fr\". Omit for the full picture."
|
|
@@ -25,13 +26,6 @@ module RailsAiContext
|
|
|
25
26
|
}
|
|
26
27
|
)
|
|
27
28
|
|
|
28
|
-
guide_row(
|
|
29
|
-
order: 40,
|
|
30
|
-
mcp: "rails_get_i18n(locale:\"fr\")",
|
|
31
|
-
cli_args: "locale=fr",
|
|
32
|
-
summary: "Locales, locale files with key counts, per-locale coverage, fallbacks"
|
|
33
|
-
)
|
|
34
|
-
|
|
35
29
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
36
30
|
|
|
37
31
|
def self.call(locale: nil, offset: 0, limit: nil, server_context: nil)
|
|
@@ -383,7 +383,7 @@ module RailsAiContext
|
|
|
383
383
|
end
|
|
384
384
|
|
|
385
385
|
# Cross-reference hints
|
|
386
|
-
lines
|
|
386
|
+
add_next_hint(lines, pointer("rails_search_code", %(pattern:"#{class_name}"), "for all references"))
|
|
387
387
|
|
|
388
388
|
text_response(lines.join("\n"))
|
|
389
389
|
end
|
|
@@ -217,8 +217,8 @@ module RailsAiContext
|
|
|
217
217
|
end
|
|
218
218
|
differ = others.reject { |_, other| other[:columns] == table_data[:columns] }.map(&:first)
|
|
219
219
|
if differ.any?
|
|
220
|
-
|
|
221
|
-
|
|
220
|
+
schema = pointer("rails_get_schema", %(table:"#{data[:table_name]}"), "lists each.")
|
|
221
|
+
lines << "" << "Columns of `#{data[:table_name]}` in #{source}; the table differs in #{differ.join(", ")}#{schema ? ": #{schema}" : "."}"
|
|
222
222
|
end
|
|
223
223
|
end
|
|
224
224
|
lines << ignored_columns_line(ignored) if ignored.any?
|
|
@@ -575,15 +575,12 @@ module RailsAiContext
|
|
|
575
575
|
end
|
|
576
576
|
end
|
|
577
577
|
|
|
578
|
-
# Cross-reference hints - guide AI to related tools
|
|
579
|
-
hints = []
|
|
580
|
-
hints << "`rails_get_schema(table:\"#{data[:table_name]}\")` for columns/indexes" if data[:table_name]
|
|
581
578
|
# Only a controller the app has: the conventional name is a guess, and
|
|
582
579
|
# a hint naming a class that does not exist sends the reader nowhere.
|
|
583
580
|
controller_name = Payload.controller_for_route_key(cached_context, name.underscore.pluralize)&.first
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
581
|
+
add_next_hint(lines, (pointer("rails_get_schema", %(table:"#{data[:table_name]}"), "for columns/indexes") if data[:table_name]),
|
|
582
|
+
(pointer("rails_get_controllers", %(controller:"#{controller_name}"), "for actions") if controller_name),
|
|
583
|
+
pointer("rails_analyze_feature", %(feature:"#{name}"), "for full-stack view"))
|
|
587
584
|
|
|
588
585
|
lines.join("\n")
|
|
589
586
|
end
|
|
@@ -22,7 +22,7 @@ module RailsAiContext
|
|
|
22
22
|
)
|
|
23
23
|
|
|
24
24
|
guide_row(
|
|
25
|
-
order:
|
|
25
|
+
order: 17,
|
|
26
26
|
mcp: "rails_get_partial_interface(partial:\"X\")",
|
|
27
27
|
cli_args: "partial=X",
|
|
28
28
|
summary: "Partial locals contract: what to pass + usage examples"
|
|
@@ -79,7 +79,7 @@ module RailsAiContext
|
|
|
79
79
|
unless located
|
|
80
80
|
available = view_dirs.flat_map { |dir| find_available_partials(dir, root) }.uniq.sort.first(30)
|
|
81
81
|
return not_found_response("Partial", partial, available,
|
|
82
|
-
recovery_tool: "
|
|
82
|
+
recovery_tool: call_pointer("rails_get_view", %(detail:"summary"), "to see all views and partials"), note: static_engine_views_note)
|
|
83
83
|
end
|
|
84
84
|
|
|
85
85
|
file_path = located.realpath
|
|
@@ -202,8 +202,7 @@ module RailsAiContext
|
|
|
202
202
|
lines << RailsAiContext::Redaction.redact_source(source, path: relative_path)
|
|
203
203
|
lines << "```"
|
|
204
204
|
else
|
|
205
|
-
lines
|
|
206
|
-
lines << "_Next: `rails_get_view(path:\"#{partial_name}\")` for full file content_"
|
|
205
|
+
add_next_hint(lines, pointer("rails_get_view", %(path:"#{partial_name}"), "for full file content"))
|
|
207
206
|
end
|
|
208
207
|
|
|
209
208
|
text_response(lines.join("\n"))
|