rails-ai-context 4.3.0 → 4.3.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 +58 -8
- data/CLAUDE.md +4 -4
- data/CONTRIBUTING.md +1 -1
- data/README.md +7 -7
- data/SECURITY.md +2 -1
- data/docs/GUIDE.md +3 -3
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -2
- data/lib/rails_ai_context/configuration.rb +4 -2
- data/lib/rails_ai_context/doctor.rb +6 -1
- data/lib/rails_ai_context/fingerprinter.rb +24 -0
- data/lib/rails_ai_context/introspectors/component_introspector.rb +122 -7
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +31 -26
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +183 -6
- data/lib/rails_ai_context/introspectors/view_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +61 -8
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +13 -22
- data/lib/rails_ai_context/serializers/claude_serializer.rb +15 -3
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +15 -3
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -5
- data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +3 -3
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +0 -1
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +15 -9
- data/lib/rails_ai_context/server.rb +8 -1
- data/lib/rails_ai_context/tools/analyze_feature.rb +24 -1
- data/lib/rails_ai_context/tools/base_tool.rb +78 -1
- data/lib/rails_ai_context/tools/dependency_graph.rb +4 -1
- data/lib/rails_ai_context/tools/diagnose.rb +135 -8
- data/lib/rails_ai_context/tools/generate_test.rb +87 -7
- data/lib/rails_ai_context/tools/get_callbacks.rb +27 -4
- data/lib/rails_ai_context/tools/get_component_catalog.rb +11 -2
- data/lib/rails_ai_context/tools/get_context.rb +71 -8
- data/lib/rails_ai_context/tools/get_conventions.rb +59 -0
- data/lib/rails_ai_context/tools/get_design_system.rb +45 -7
- data/lib/rails_ai_context/tools/get_edit_context.rb +3 -2
- data/lib/rails_ai_context/tools/get_env.rb +51 -24
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +100 -9
- data/lib/rails_ai_context/tools/get_model_details.rb +20 -0
- data/lib/rails_ai_context/tools/get_partial_interface.rb +12 -5
- data/lib/rails_ai_context/tools/get_schema.rb +1 -0
- data/lib/rails_ai_context/tools/get_stimulus.rb +13 -7
- data/lib/rails_ai_context/tools/get_turbo_map.rb +35 -2
- data/lib/rails_ai_context/tools/get_view.rb +65 -9
- data/lib/rails_ai_context/tools/migration_advisor.rb +10 -3
- data/lib/rails_ai_context/tools/onboard.rb +413 -27
- data/lib/rails_ai_context/tools/performance_check.rb +45 -28
- data/lib/rails_ai_context/tools/query.rb +28 -2
- data/lib/rails_ai_context/tools/read_logs.rb +4 -1
- data/lib/rails_ai_context/tools/review_changes.rb +27 -17
- data/lib/rails_ai_context/tools/runtime_info.rb +289 -0
- data/lib/rails_ai_context/tools/search_code.rb +23 -4
- data/lib/rails_ai_context/tools/security_scan.rb +7 -1
- data/lib/rails_ai_context/tools/session_context.rb +137 -0
- data/lib/rails_ai_context/tools/validate.rb +5 -0
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +6 -4
|
@@ -118,8 +118,25 @@ module RailsAiContext
|
|
|
118
118
|
concern_callbacks = find_concern_callbacks(name, data)
|
|
119
119
|
if concern_callbacks.any?
|
|
120
120
|
lines << "" << "## From Concerns"
|
|
121
|
-
|
|
122
|
-
|
|
121
|
+
if detail == "full"
|
|
122
|
+
concern_callbacks.each do |concern_name, info|
|
|
123
|
+
lines << "### #{concern_name}"
|
|
124
|
+
info[:callbacks].each do |cb|
|
|
125
|
+
source = extract_method_source(info[:path], cb[:method_name])
|
|
126
|
+
lines << "- #{cb[:declaration]}"
|
|
127
|
+
if source
|
|
128
|
+
lines << "```ruby"
|
|
129
|
+
lines << source[:code]
|
|
130
|
+
lines << "```"
|
|
131
|
+
lines << ""
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
else
|
|
136
|
+
concern_callbacks.each do |concern_name, info|
|
|
137
|
+
declarations = info[:callbacks].map { |cb| cb[:declaration] }
|
|
138
|
+
lines << "- **#{concern_name}:** #{declarations.join(', ')}"
|
|
139
|
+
end
|
|
123
140
|
end
|
|
124
141
|
end
|
|
125
142
|
|
|
@@ -210,6 +227,10 @@ module RailsAiContext
|
|
|
210
227
|
|
|
211
228
|
private_class_method def self.extract_callback_source(model_name, method_name)
|
|
212
229
|
path = Rails.root.join("app", "models", "#{model_name.underscore}.rb")
|
|
230
|
+
extract_method_source(path, method_name)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
private_class_method def self.extract_method_source(path, method_name)
|
|
213
234
|
return nil unless File.exist?(path)
|
|
214
235
|
return nil if File.size(path) > RailsAiContext.configuration.max_file_size
|
|
215
236
|
|
|
@@ -263,11 +284,13 @@ module RailsAiContext
|
|
|
263
284
|
|
|
264
285
|
source.each_line do |line|
|
|
265
286
|
if (match = line.match(/\A\s*(before_\w+|after_\w+|around_\w+)\s+[: ]*(\w+)/))
|
|
266
|
-
callbacks << "#{match[1]} :#{match[2]}"
|
|
287
|
+
callbacks << { declaration: "#{match[1]} :#{match[2]}", method_name: match[2] }
|
|
267
288
|
end
|
|
268
289
|
end
|
|
269
290
|
|
|
270
|
-
|
|
291
|
+
if callbacks.any?
|
|
292
|
+
concern_callbacks[concern_name] = { callbacks: callbacks, path: concern_path }
|
|
293
|
+
end
|
|
271
294
|
end
|
|
272
295
|
|
|
273
296
|
concern_callbacks
|
|
@@ -53,7 +53,15 @@ module RailsAiContext
|
|
|
53
53
|
|
|
54
54
|
text_response(render_single(found, detail))
|
|
55
55
|
else
|
|
56
|
-
|
|
56
|
+
if components.empty?
|
|
57
|
+
return text_response(
|
|
58
|
+
"No components found in app/components/.\n\n" \
|
|
59
|
+
"This app may use ERB partials instead of ViewComponent/Phlex. Try:\n" \
|
|
60
|
+
"- `rails_get_partial_interface(partial:\"shared/partial_name\")` — partial locals contract + usage\n" \
|
|
61
|
+
"- `rails_get_view(controller:\"name\")` — view templates with partial/Stimulus references\n" \
|
|
62
|
+
"- `rails_get_design_system` — UI component patterns extracted from views"
|
|
63
|
+
)
|
|
64
|
+
end
|
|
57
65
|
text_response(render_catalog(components, data[:summary], detail))
|
|
58
66
|
end
|
|
59
67
|
end
|
|
@@ -102,7 +110,8 @@ module RailsAiContext
|
|
|
102
110
|
lines << "**Props:**"
|
|
103
111
|
comp[:props].each do |prop|
|
|
104
112
|
default = prop[:default] ? " (default: #{prop[:default]})" : ""
|
|
105
|
-
|
|
113
|
+
values = prop[:values]&.any? ? " -- values: #{prop[:values].join(', ')}" : ""
|
|
114
|
+
lines << " - `#{prop[:name]}`#{default}#{values}"
|
|
106
115
|
end
|
|
107
116
|
end
|
|
108
117
|
|
|
@@ -37,6 +37,7 @@ module RailsAiContext
|
|
|
37
37
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
38
38
|
|
|
39
39
|
def self.call(controller: nil, action: nil, model: nil, feature: nil, include: nil, server_context: nil)
|
|
40
|
+
set_call_params(controller: controller, action: action, model: model, feature: feature)
|
|
40
41
|
result = if controller && action
|
|
41
42
|
controller_action_context(controller, action)
|
|
42
43
|
elsif controller
|
|
@@ -94,10 +95,18 @@ module RailsAiContext
|
|
|
94
95
|
lines << view_text
|
|
95
96
|
end
|
|
96
97
|
|
|
97
|
-
# Cross-reference: controller ivars vs
|
|
98
|
-
|
|
98
|
+
# Cross-reference: controller ivars vs view ivars
|
|
99
|
+
# Also check templates rendered by the action (e.g., create renders :new on failure)
|
|
100
|
+
ctrl_text = ctrl_result.content.first[:text]
|
|
101
|
+
ctrl_ivars = extract_ivars_from_text(ctrl_text)
|
|
99
102
|
view_ivars = extract_ivars_from_view_text(view_text, action: action_name)
|
|
100
|
-
|
|
103
|
+
# Detect "render :other_template" and include those templates' ivars too
|
|
104
|
+
rendered = ctrl_text.scan(/render\s+:(\w+)/).flatten.uniq
|
|
105
|
+
other_templates = rendered.reject { |t| t == action_name }
|
|
106
|
+
other_templates.each do |tmpl|
|
|
107
|
+
view_ivars.merge(extract_ivars_from_view_text(view_text, action: tmpl))
|
|
108
|
+
end
|
|
109
|
+
ivar_check = cross_reference_ivars(ctrl_ivars, view_ivars, rendered_templates: other_templates)
|
|
101
110
|
lines << "" << ivar_check if ivar_check
|
|
102
111
|
|
|
103
112
|
text_response(lines.join("\n"))
|
|
@@ -140,13 +149,13 @@ module RailsAiContext
|
|
|
140
149
|
ivars
|
|
141
150
|
end
|
|
142
151
|
|
|
143
|
-
private_class_method def self.cross_reference_ivars(ctrl_ivars, view_ivars)
|
|
152
|
+
private_class_method def self.cross_reference_ivars(ctrl_ivars, view_ivars, rendered_templates: [])
|
|
144
153
|
return nil if ctrl_ivars.empty? && view_ivars.empty?
|
|
145
154
|
|
|
146
155
|
lines = [ "## Instance Variable Cross-Check" ]
|
|
147
156
|
all = (ctrl_ivars | view_ivars).sort
|
|
148
157
|
|
|
149
|
-
|
|
158
|
+
missing_ivars = []
|
|
150
159
|
all.each do |ivar|
|
|
151
160
|
in_ctrl = ctrl_ivars.include?(ivar)
|
|
152
161
|
in_view = view_ivars.include?(ivar)
|
|
@@ -154,13 +163,21 @@ module RailsAiContext
|
|
|
154
163
|
lines << "- \u2713 @#{ivar} — set in controller, used in view"
|
|
155
164
|
elsif in_view && !in_ctrl
|
|
156
165
|
lines << "- \u2717 @#{ivar} — used in view but NOT set in controller"
|
|
157
|
-
|
|
166
|
+
missing_ivars << ivar
|
|
158
167
|
elsif in_ctrl && !in_view
|
|
159
168
|
lines << "- \u26A0 @#{ivar} — set in controller but not used in view"
|
|
160
169
|
end
|
|
161
170
|
end
|
|
162
171
|
|
|
163
|
-
|
|
172
|
+
# If there are missing ivars AND this action renders another template,
|
|
173
|
+
# add a note explaining why — the other action likely sets them
|
|
174
|
+
if missing_ivars.any? && rendered_templates.any?
|
|
175
|
+
templates = rendered_templates.map { |t| "`#{t}`" }.join(", ")
|
|
176
|
+
lines << ""
|
|
177
|
+
lines << "_Note: This action renders #{templates} on failure — those ivars are likely set in the corresponding action(s)._"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
(missing_ivars.any? || all.any?) ? lines.join("\n") : nil
|
|
164
181
|
end
|
|
165
182
|
|
|
166
183
|
private_class_method def self.controller_context(controller_name)
|
|
@@ -215,7 +232,11 @@ module RailsAiContext
|
|
|
215
232
|
|
|
216
233
|
if key && models[key][:table_name]
|
|
217
234
|
schema_result = GetSchema.call(table: models[key][:table_name])
|
|
218
|
-
|
|
235
|
+
schema_text = schema_result.content.first[:text]
|
|
236
|
+
# Only append schema if it actually has useful data (not "not found")
|
|
237
|
+
unless schema_text.include?("not found") || schema_text.include?("Available:")
|
|
238
|
+
lines << "" << "---" << "" << schema_text
|
|
239
|
+
end
|
|
219
240
|
end
|
|
220
241
|
|
|
221
242
|
# Tests for this model
|
|
@@ -271,16 +292,58 @@ module RailsAiContext
|
|
|
271
292
|
ctx = begin; cached_context; rescue; nil; end
|
|
272
293
|
if ctx
|
|
273
294
|
models = ctx[:models] || {}
|
|
295
|
+
matched_tables = Set.new
|
|
296
|
+
|
|
274
297
|
models.each_key do |model_name|
|
|
275
298
|
next unless model_name.downcase.include?(feature_name.downcase)
|
|
276
299
|
table_name = models[model_name][:table_name]
|
|
277
300
|
next unless table_name
|
|
301
|
+
matched_tables << table_name
|
|
278
302
|
schema_result = GetSchema.call(table: table_name)
|
|
279
303
|
schema_text = schema_result.content.first[:text]
|
|
280
304
|
unless schema_text.include?("not found")
|
|
281
305
|
lines << "" << "---" << "" << schema_text
|
|
282
306
|
end
|
|
283
307
|
end
|
|
308
|
+
|
|
309
|
+
# Also include schema for related models (associated tables) if the
|
|
310
|
+
# primary model was found but the feature analysis missed controllers/services
|
|
311
|
+
analyze_text = analyze_result.content.first[:text]
|
|
312
|
+
has_controllers = analyze_text.include?("## Controllers")
|
|
313
|
+
unless has_controllers
|
|
314
|
+
# Check if any controllers or services reference this feature by name
|
|
315
|
+
controllers = ctx[:controllers]
|
|
316
|
+
if controllers.is_a?(Hash) && !controllers[:error]
|
|
317
|
+
related_ctrls = (controllers[:controllers] || []).select do |c|
|
|
318
|
+
c_name = c[:name] || ""
|
|
319
|
+
c_name.downcase.include?(feature_name.downcase) ||
|
|
320
|
+
c_name.downcase.include?(feature_name.singularize.downcase) ||
|
|
321
|
+
c_name.downcase.include?(feature_name.pluralize.downcase)
|
|
322
|
+
end
|
|
323
|
+
if related_ctrls.any?
|
|
324
|
+
lines << "" << "## Related Controllers (by name)"
|
|
325
|
+
related_ctrls.each do |c|
|
|
326
|
+
actions = (c[:actions] || []).map { |a| a.is_a?(Hash) ? a[:name] : a }.compact
|
|
327
|
+
lines << "- **#{c[:name]}** — #{actions.join(', ')}"
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Check services
|
|
333
|
+
services = ctx[:services] || ctx[:service_objects]
|
|
334
|
+
if services.is_a?(Hash) && !services[:error]
|
|
335
|
+
service_list = services[:services] || []
|
|
336
|
+
related_svcs = service_list.select do |s|
|
|
337
|
+
s_name = (s[:name] || s[:file] || "").to_s
|
|
338
|
+
s_name.downcase.include?(feature_name.downcase) ||
|
|
339
|
+
s_name.downcase.include?(feature_name.singularize.downcase)
|
|
340
|
+
end
|
|
341
|
+
if related_svcs.any?
|
|
342
|
+
lines << "" << "## Related Services (by name)"
|
|
343
|
+
related_svcs.each { |s| lines << "- `#{s[:file] || s[:name]}`" }
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
284
347
|
end
|
|
285
348
|
|
|
286
349
|
text_response(lines.join("\n"))
|
|
@@ -74,6 +74,13 @@ module RailsAiContext
|
|
|
74
74
|
end
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
# I18n / Locale info
|
|
78
|
+
locale_info = detect_locale_info
|
|
79
|
+
if locale_info.any?
|
|
80
|
+
lines << "" << "## I18n"
|
|
81
|
+
locale_info.each { |l| lines << "- #{l}" }
|
|
82
|
+
end
|
|
83
|
+
|
|
77
84
|
# Convention Fingerprint — one-paragraph summary of the app's detected conventions
|
|
78
85
|
fingerprint_parts = []
|
|
79
86
|
fingerprint_parts << conventions[:architecture].map { |a| humanize_arch(a) }.join(", ") if conventions[:architecture]&.any?
|
|
@@ -319,6 +326,58 @@ module RailsAiContext
|
|
|
319
326
|
[] # Graceful degradation — never break the tool
|
|
320
327
|
end
|
|
321
328
|
|
|
329
|
+
private_class_method def self.detect_locale_info
|
|
330
|
+
info = []
|
|
331
|
+
locales_dir = Rails.root.join("config", "locales")
|
|
332
|
+
return info unless Dir.exist?(locales_dir)
|
|
333
|
+
|
|
334
|
+
locale_files = Dir.glob(File.join(locales_dir, "**", "*.{yml,yaml,rb}"))
|
|
335
|
+
locales = locale_files.map { |f| File.basename(f, ".*").split(".").first }.uniq.sort
|
|
336
|
+
|
|
337
|
+
return info if locales.empty?
|
|
338
|
+
|
|
339
|
+
info << "**Locales:** #{locales.join(', ')} (#{locales.size} total)"
|
|
340
|
+
|
|
341
|
+
# Detect default locale from config
|
|
342
|
+
app_config = Rails.root.join("config", "application.rb")
|
|
343
|
+
if File.exist?(app_config)
|
|
344
|
+
content = File.read(app_config, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue ""
|
|
345
|
+
if (match = content.match(/config\.i18n\.default_locale\s*=\s*[:"'](\w+)/))
|
|
346
|
+
info << "**Default locale:** #{match[1]}"
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Detect primary UI language by sampling flash messages from controllers
|
|
351
|
+
controllers_dir = Rails.root.join("app", "controllers")
|
|
352
|
+
if Dir.exist?(controllers_dir)
|
|
353
|
+
samples = []
|
|
354
|
+
Dir.glob(File.join(controllers_dir, "**", "*.rb")).first(10).each do |path|
|
|
355
|
+
content = File.read(path, encoding: "UTF-8", invalid: :replace, undef: :replace) rescue next
|
|
356
|
+
content.scan(/(?:notice|alert|flash\[:\w+\]):\s*"([^"]+)"/).each { |m| samples << m[0] }
|
|
357
|
+
end
|
|
358
|
+
if samples.any?
|
|
359
|
+
# Check if majority contains non-ASCII (CJK, Korean, etc.)
|
|
360
|
+
non_ascii = samples.count { |s| s.match?(/[^\x00-\x7F]/) }
|
|
361
|
+
ratio = non_ascii.to_f / samples.size
|
|
362
|
+
if ratio > 0.5
|
|
363
|
+
if samples.any? { |s| s.match?(/[\uAC00-\uD7AF]/) }
|
|
364
|
+
info << "**Primary UI language:** Korean (detected from flash messages)"
|
|
365
|
+
elsif samples.any? { |s| s.match?(/[\u4E00-\u9FFF]/) }
|
|
366
|
+
info << "**Primary UI language:** Chinese (detected from flash messages)"
|
|
367
|
+
elsif samples.any? { |s| s.match?(/[\u3040-\u309F\u30A0-\u30FF]/) }
|
|
368
|
+
info << "**Primary UI language:** Japanese (detected from flash messages)"
|
|
369
|
+
else
|
|
370
|
+
info << "**Primary UI language:** non-English (detected from flash messages)"
|
|
371
|
+
end
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
info
|
|
377
|
+
rescue
|
|
378
|
+
[]
|
|
379
|
+
end
|
|
380
|
+
|
|
322
381
|
private_class_method def self.detect_test_pattern
|
|
323
382
|
sections = []
|
|
324
383
|
test_dir = Rails.root.join("test", "controllers")
|
|
@@ -89,16 +89,27 @@ module RailsAiContext
|
|
|
89
89
|
end
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
# ViewComponents
|
|
92
|
+
# ViewComponents — grouped by category
|
|
93
93
|
if cached_context[:components].is_a?(Hash) && !cached_context[:components][:error] && cached_context[:components][:components]&.any?
|
|
94
94
|
comp_data = cached_context[:components][:components]
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
|
|
96
|
+
# Group components by inferred category from file path or name
|
|
97
|
+
grouped = comp_data.group_by { |c| categorize_component(c) }
|
|
98
|
+
lines << "## Components (#{comp_data.size})" << ""
|
|
99
|
+
|
|
100
|
+
grouped.sort_by { |cat, _| cat }.each do |category, comps|
|
|
101
|
+
lines << "### #{category}"
|
|
102
|
+
comps.each do |c|
|
|
103
|
+
slots = c[:slots]&.size || 0
|
|
104
|
+
props = c[:props]&.size || 0
|
|
105
|
+
meta = []
|
|
106
|
+
meta << "#{slots} slots" if slots > 0
|
|
107
|
+
meta << "#{props} props" if props > 0
|
|
108
|
+
meta_str = meta.any? ? " (#{meta.join(', ')})" : ""
|
|
109
|
+
lines << "- **#{c[:name]}**#{meta_str}"
|
|
110
|
+
end
|
|
111
|
+
lines << ""
|
|
100
112
|
end
|
|
101
|
-
lines << ""
|
|
102
113
|
end
|
|
103
114
|
|
|
104
115
|
# Accessibility
|
|
@@ -494,6 +505,33 @@ module RailsAiContext
|
|
|
494
505
|
lines
|
|
495
506
|
end
|
|
496
507
|
|
|
508
|
+
def categorize_component(component)
|
|
509
|
+
file = (component[:file] || "").downcase
|
|
510
|
+
name = (component[:name] || "").downcase
|
|
511
|
+
|
|
512
|
+
# Categorize by directory structure first
|
|
513
|
+
if file.include?("/ruby_ui/") || file.include?("/ui/")
|
|
514
|
+
# Sub-categorize UI primitives
|
|
515
|
+
return "UI / Form" if name.match?(/input|select|textarea|form|radio|checkbox/)
|
|
516
|
+
return "UI / Feedback" if name.match?(/alert|toast|dialog|modal/)
|
|
517
|
+
return "UI / Navigation" if name.match?(/breadcrumb|pagination|nav|link|menu|tabs/)
|
|
518
|
+
return "UI / Layout" if name.match?(/card|separator|heading|text|badge/)
|
|
519
|
+
return "UI / Media" if name.match?(/avatar|carousel|image/)
|
|
520
|
+
return "UI / Overlay" if name.match?(/dialog|popover|tooltip|dropdown/)
|
|
521
|
+
return "UI / Primitives"
|
|
522
|
+
end
|
|
523
|
+
|
|
524
|
+
return "Domain / Articles" if name.match?(/article/)
|
|
525
|
+
return "Domain / Comments" if name.match?(/comment/)
|
|
526
|
+
return "Domain / Posts" if name.match?(/post/)
|
|
527
|
+
return "Domain / Users" if name.match?(/user|profile/)
|
|
528
|
+
return "Domain / Likes" if name.match?(/like/)
|
|
529
|
+
return "Domain / Tags" if name.match?(/tag/)
|
|
530
|
+
return "Layout" if name.match?(/layout|flash|pagination|sidebar|login/)
|
|
531
|
+
|
|
532
|
+
"Other"
|
|
533
|
+
end
|
|
534
|
+
|
|
497
535
|
# Safe key extraction — handles both Hash and Array
|
|
498
536
|
def safe_keys(value, limit = nil)
|
|
499
537
|
names = value.is_a?(Hash) ? value.keys : Array(value)
|
|
@@ -146,9 +146,10 @@ module RailsAiContext
|
|
|
146
146
|
private_class_method def self.sensitive_file?(relative_path)
|
|
147
147
|
patterns = RailsAiContext.configuration.sensitive_patterns
|
|
148
148
|
basename = File.basename(relative_path)
|
|
149
|
+
flags = File::FNM_DOTMATCH | File::FNM_CASEFOLD
|
|
149
150
|
patterns.any? do |pattern|
|
|
150
|
-
File.fnmatch(pattern, relative_path,
|
|
151
|
-
File.fnmatch(pattern, basename,
|
|
151
|
+
File.fnmatch(pattern, relative_path, flags) ||
|
|
152
|
+
File.fnmatch(pattern, basename, flags)
|
|
152
153
|
end
|
|
153
154
|
end
|
|
154
155
|
|
|
@@ -141,16 +141,42 @@ module RailsAiContext
|
|
|
141
141
|
private_class_method def self.format_full(env_vars, env_example, dockerfile_vars, external_services, credentials_keys, encrypted_columns, root)
|
|
142
142
|
lines = [ "# Environment Configuration (Full Detail)", "" ]
|
|
143
143
|
|
|
144
|
-
# ENV vars with
|
|
144
|
+
# ENV vars grouped by category with file annotations
|
|
145
145
|
if env_vars.any?
|
|
146
|
-
lines << "## Environment Variables by
|
|
146
|
+
lines << "## Environment Variables by Category"
|
|
147
|
+
|
|
148
|
+
# Build a map: var_name -> { details from all files }
|
|
149
|
+
var_details = {}
|
|
147
150
|
env_vars.sort_by { |file, _| file }.each do |file, vars|
|
|
148
151
|
relative = file.sub("#{root}/", "")
|
|
149
|
-
|
|
152
|
+
vars.each do |v|
|
|
153
|
+
var_details[v[:name]] ||= { files: [], default: nil }
|
|
154
|
+
var_details[v[:name]][:files] << { file: relative, line: v[:line] }
|
|
155
|
+
var_details[v[:name]][:default] ||= v[:default]
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Group by category
|
|
160
|
+
categorized = Hash.new { |h, k| h[k] = [] }
|
|
161
|
+
var_details.each do |name, details|
|
|
162
|
+
category = categorize_env_var(name)
|
|
163
|
+
categorized[category] << { name: name, **details }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
category_order = [
|
|
167
|
+
"API Keys & Secrets", "Mail", "Database", "Infrastructure",
|
|
168
|
+
"Monitoring", "Push Notifications", "Other"
|
|
169
|
+
]
|
|
170
|
+
sorted_categories = categorized.keys.sort_by { |k| category_order.index(k) || 99 }
|
|
171
|
+
|
|
172
|
+
sorted_categories.each do |category|
|
|
173
|
+
vars = categorized[category]
|
|
174
|
+
lines << "" << "### #{category}"
|
|
150
175
|
vars.sort_by { |v| v[:name] }.each do |v|
|
|
176
|
+
file_locations = v[:files].map { |f| f[:line] ? "#{f[:file]}:#{f[:line]}" : f[:file] }.uniq
|
|
151
177
|
entry = "- `#{v[:name]}`"
|
|
152
|
-
entry += " (
|
|
153
|
-
entry += "
|
|
178
|
+
entry += " (default: `#{v[:default]}`)" if v[:default]
|
|
179
|
+
entry += " (#{file_locations.join(', ')})"
|
|
154
180
|
lines << entry
|
|
155
181
|
end
|
|
156
182
|
end
|
|
@@ -539,30 +565,30 @@ module RailsAiContext
|
|
|
539
565
|
encrypted
|
|
540
566
|
end
|
|
541
567
|
|
|
568
|
+
private_class_method def self.categorize_env_var(name)
|
|
569
|
+
case name
|
|
570
|
+
when /API_KEY|SECRET|TOKEN/i then "API Keys & Secrets"
|
|
571
|
+
when /MAIL|IMAP|SMTP/i then "Mail"
|
|
572
|
+
when /DATABASE|DB_|REDIS/i then "Database"
|
|
573
|
+
when /OTEL|SENTRY|DATADOG|NEWRELIC|APPSIGNAL/i then "Monitoring"
|
|
574
|
+
when /PUSH|VAPID|FCM/i then "Push Notifications"
|
|
575
|
+
when /PORT|CONCURRENCY|THREADS|WORKERS|TIMEOUT|QUEUE|PIDFILE/i then "Infrastructure"
|
|
576
|
+
else "Other"
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
542
580
|
private_class_method def self.group_env_vars(var_names)
|
|
543
581
|
groups = Hash.new { |h, k| h[k] = [] }
|
|
544
582
|
|
|
545
583
|
var_names.each do |name|
|
|
546
|
-
|
|
547
|
-
when /\ADATABASE_|_DB_|_DATABASE/ then "Database"
|
|
548
|
-
when /\AREDIS_|_REDIS/ then "Redis"
|
|
549
|
-
when /\AAWS_|_S3_|_SES_/ then "AWS"
|
|
550
|
-
when /\ASTRIPE_/ then "Payments (Stripe)"
|
|
551
|
-
when /\ATWILIO_/ then "Twilio"
|
|
552
|
-
when /\ASENDGRID_|_SMTP_|_MAILER_|_MAIL_/ then "Email"
|
|
553
|
-
when /\ASENTRY_|_BUGSNAG_|_ROLLBAR_|_NEWRELIC_|_DD_/ then "Monitoring"
|
|
554
|
-
when /\AOAUTH_|_CLIENT_ID|_CLIENT_SECRET|_API_KEY/ then "API Keys & Auth"
|
|
555
|
-
when /\ARAILS_|_ENV\z|_HOST|_PORT|_URL\z/ then "App Configuration"
|
|
556
|
-
when /\ARECAPTCHA_/ then "Security"
|
|
557
|
-
when /\ASECRET_|_TOKEN|_KEY\z/ then "Secrets"
|
|
558
|
-
else "Other"
|
|
559
|
-
end
|
|
560
|
-
|
|
561
|
-
groups[group] << name
|
|
584
|
+
groups[categorize_env_var(name)] << name
|
|
562
585
|
end
|
|
563
586
|
|
|
564
587
|
# Sort groups: important ones first
|
|
565
|
-
priority =
|
|
588
|
+
priority = [
|
|
589
|
+
"API Keys & Secrets", "Mail", "Database", "Infrastructure",
|
|
590
|
+
"Monitoring", "Push Notifications", "Other"
|
|
591
|
+
]
|
|
566
592
|
groups.sort_by { |k, _| priority.index(k) || 99 }
|
|
567
593
|
end
|
|
568
594
|
|
|
@@ -603,10 +629,11 @@ module RailsAiContext
|
|
|
603
629
|
relative = file.sub("#{root}/", "")
|
|
604
630
|
basename = File.basename(relative)
|
|
605
631
|
patterns = RailsAiContext.configuration.sensitive_patterns
|
|
632
|
+
flags = File::FNM_DOTMATCH | File::FNM_CASEFOLD
|
|
606
633
|
|
|
607
634
|
patterns.any? do |pattern|
|
|
608
|
-
File.fnmatch(pattern, relative,
|
|
609
|
-
File.fnmatch(pattern, basename,
|
|
635
|
+
File.fnmatch(pattern, relative, flags) ||
|
|
636
|
+
File.fnmatch(pattern, basename, flags)
|
|
610
637
|
end
|
|
611
638
|
end
|
|
612
639
|
|
|
@@ -49,22 +49,61 @@ module RailsAiContext
|
|
|
49
49
|
|
|
50
50
|
def build_summary(data)
|
|
51
51
|
parts = []
|
|
52
|
-
parts << "#{data[:framework]}#{version_suffix(data[:version])}" if data[:framework]
|
|
53
|
-
parts << data[:mounting_strategy] if data[:mounting_strategy]
|
|
54
|
-
parts << data[:build_tool] if data[:build_tool]
|
|
52
|
+
parts << "#{data[:framework]}#{version_suffix(data[:version])}" if data[:framework].present?
|
|
53
|
+
parts << data[:mounting_strategy] if data[:mounting_strategy].present?
|
|
54
|
+
parts << data[:build_tool] if data[:build_tool].present?
|
|
55
55
|
|
|
56
56
|
if data[:typescript].is_a?(Hash) && data[:typescript][:enabled]
|
|
57
57
|
parts << "TypeScript"
|
|
58
58
|
end
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
if data[:state_management].is_a?(String) && data[:state_management].present?
|
|
61
|
+
parts << data[:state_management]
|
|
62
|
+
elsif data[:state_management].is_a?(Array) && data[:state_management].any?
|
|
63
|
+
parts << data[:state_management].join(", ")
|
|
64
|
+
end
|
|
61
65
|
|
|
62
66
|
total = total_component_count(data)
|
|
63
67
|
parts << "(#{total} components)" if total > 0
|
|
64
68
|
|
|
69
|
+
# If no JS framework data, try building a Hotwire summary from cached context
|
|
70
|
+
if parts.empty?
|
|
71
|
+
hotwire = build_hotwire_summary
|
|
72
|
+
return hotwire if hotwire
|
|
73
|
+
end
|
|
74
|
+
|
|
65
75
|
parts.any? ? parts.join(" + ") : "No frontend framework detected."
|
|
66
76
|
end
|
|
67
77
|
|
|
78
|
+
def build_hotwire_summary
|
|
79
|
+
stimulus = cached_context[:stimulus]
|
|
80
|
+
gems = cached_context[:gems]
|
|
81
|
+
|
|
82
|
+
notable = gems.is_a?(Hash) && !gems[:error] ? (gems[:notable_gems] || []) : []
|
|
83
|
+
has_turbo = notable.any? { |g| g[:name] == "turbo-rails" }
|
|
84
|
+
has_stimulus = notable.any? { |g| g[:name] == "stimulus-rails" }
|
|
85
|
+
has_importmap = notable.any? { |g| g[:name] == "importmap-rails" }
|
|
86
|
+
has_tailwind = notable.any? { |g| g[:name] == "tailwindcss-rails" }
|
|
87
|
+
|
|
88
|
+
return nil unless has_turbo || has_stimulus
|
|
89
|
+
|
|
90
|
+
parts = []
|
|
91
|
+
parts << "Hotwire (Turbo + Stimulus)" if has_turbo && has_stimulus
|
|
92
|
+
parts << "Hotwire (Turbo)" if has_turbo && !has_stimulus
|
|
93
|
+
parts << "Hotwire (Stimulus)" if !has_turbo && has_stimulus
|
|
94
|
+
|
|
95
|
+
parts << "with importmap-rails" if has_importmap
|
|
96
|
+
|
|
97
|
+
if stimulus.is_a?(Hash) && !stimulus[:error]
|
|
98
|
+
count = stimulus[:total_controllers] || stimulus[:controllers]&.size || 0
|
|
99
|
+
parts << "#{count} Stimulus controllers" if count > 0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
parts << "Tailwind CSS" if has_tailwind
|
|
103
|
+
|
|
104
|
+
parts.join(", ")
|
|
105
|
+
end
|
|
106
|
+
|
|
68
107
|
def build_standard(data)
|
|
69
108
|
lines = [ "# Frontend Stack", "" ]
|
|
70
109
|
|
|
@@ -90,12 +129,25 @@ module RailsAiContext
|
|
|
90
129
|
lines << "- **Testing:** #{data[:testing_frameworks].join(', ')}"
|
|
91
130
|
end
|
|
92
131
|
|
|
93
|
-
#
|
|
132
|
+
# Hotwire stack — enrich with Stimulus/Turbo data for importmap apps
|
|
133
|
+
enrich_with_hotwire(lines)
|
|
134
|
+
|
|
135
|
+
# Frontend roots with component counts — skip "0 components" for Hotwire apps
|
|
136
|
+
# where Stimulus controllers ARE the components
|
|
137
|
+
has_hotwire = lines.any? { |l| l.include?("Hotwire Stack") }
|
|
94
138
|
if data[:frontend_roots].is_a?(Array) && data[:frontend_roots].any?
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
139
|
+
roots_with_components = data[:frontend_roots].select { |r| (r[:component_count] || 0) > 0 }
|
|
140
|
+
if roots_with_components.any?
|
|
141
|
+
lines << "" << "## Frontend Roots" << ""
|
|
142
|
+
roots_with_components.each do |root|
|
|
143
|
+
lines << "- `#{root[:path]}` — #{root[:component_count]} components"
|
|
144
|
+
end
|
|
145
|
+
elsif !has_hotwire
|
|
146
|
+
# Only show "0 components" for non-Hotwire apps where it's meaningful
|
|
147
|
+
lines << "" << "## Frontend Roots" << ""
|
|
148
|
+
data[:frontend_roots].each do |root|
|
|
149
|
+
lines << "- `#{root[:path]}` — 0 components"
|
|
150
|
+
end
|
|
99
151
|
end
|
|
100
152
|
end
|
|
101
153
|
|
|
@@ -154,6 +206,45 @@ module RailsAiContext
|
|
|
154
206
|
0
|
|
155
207
|
end
|
|
156
208
|
end
|
|
209
|
+
|
|
210
|
+
# For Hotwire/importmap apps, pull Stimulus and Turbo data from context
|
|
211
|
+
def enrich_with_hotwire(lines)
|
|
212
|
+
stimulus = cached_context[:stimulus]
|
|
213
|
+
turbo = cached_context[:turbo]
|
|
214
|
+
gems = cached_context[:gems]
|
|
215
|
+
|
|
216
|
+
# Check if this is a Hotwire app (has turbo-rails or stimulus-rails)
|
|
217
|
+
notable = gems.is_a?(Hash) && !gems[:error] ? (gems[:notable_gems] || []) : []
|
|
218
|
+
has_turbo = notable.any? { |g| g[:name] == "turbo-rails" }
|
|
219
|
+
has_stimulus = notable.any? { |g| g[:name] == "stimulus-rails" }
|
|
220
|
+
has_importmap = notable.any? { |g| g[:name] == "importmap-rails" }
|
|
221
|
+
|
|
222
|
+
return unless has_turbo || has_stimulus
|
|
223
|
+
|
|
224
|
+
lines << ""
|
|
225
|
+
lines << "## Hotwire Stack"
|
|
226
|
+
lines << ""
|
|
227
|
+
lines << "- **Turbo:** turbo-rails (Drive, Frames, Streams)" if has_turbo
|
|
228
|
+
lines << "- **Stimulus:** stimulus-rails" if has_stimulus
|
|
229
|
+
lines << "- **Asset delivery:** importmap-rails (no JS bundler)" if has_importmap
|
|
230
|
+
|
|
231
|
+
if stimulus.is_a?(Hash) && !stimulus[:error]
|
|
232
|
+
count = stimulus[:total_controllers] || stimulus[:controllers]&.size || 0
|
|
233
|
+
if count > 0
|
|
234
|
+
names = (stimulus[:controllers] || []).map { |c| c[:name] || c[:file]&.gsub("_controller.js", "") }.compact.sort
|
|
235
|
+
lines << "- **Stimulus controllers:** #{count} (#{names.first(8).join(', ')}#{count > 8 ? ', ...' : ''})"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
if turbo.is_a?(Hash) && !turbo[:error]
|
|
240
|
+
broadcasts = turbo[:broadcasts]&.size || turbo[:explicit_broadcasts]&.size || 0
|
|
241
|
+
frames = turbo[:frames]&.size || 0
|
|
242
|
+
parts = []
|
|
243
|
+
parts << "#{broadcasts} broadcasts" if broadcasts > 0
|
|
244
|
+
parts << "#{frames} frames" if frames > 0
|
|
245
|
+
lines << "- **Turbo wiring:** #{parts.join(', ')}" if parts.any?
|
|
246
|
+
end
|
|
247
|
+
end
|
|
157
248
|
end
|
|
158
249
|
end
|
|
159
250
|
end
|
|
@@ -33,6 +33,7 @@ module RailsAiContext
|
|
|
33
33
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
34
34
|
|
|
35
35
|
def self.call(model: nil, detail: "standard", limit: nil, offset: 0, server_context: nil)
|
|
36
|
+
set_call_params(model: model, detail: detail)
|
|
36
37
|
models = cached_context[:models]
|
|
37
38
|
return text_response("Model introspection not available. Add :models to introspectors.") unless models
|
|
38
39
|
return text_response("Model introspection failed: #{models[:error]}") if models[:error]
|
|
@@ -123,6 +124,25 @@ module RailsAiContext
|
|
|
123
124
|
lines << "**Structure:** #{map}"
|
|
124
125
|
end
|
|
125
126
|
|
|
127
|
+
# Schema columns — inline from schema introspection
|
|
128
|
+
if data[:table_name]
|
|
129
|
+
schema = cached_context[:schema]
|
|
130
|
+
if schema.is_a?(Hash) && !schema[:error] && schema[:tables]&.key?(data[:table_name])
|
|
131
|
+
table_data = schema[:tables][data[:table_name]]
|
|
132
|
+
cols = table_data[:columns] || []
|
|
133
|
+
if cols.any?
|
|
134
|
+
lines << "" << "## Columns"
|
|
135
|
+
cols.each do |c|
|
|
136
|
+
parts = [ "**#{c[:name]}**", c[:type] ]
|
|
137
|
+
parts << "NOT NULL" if c[:null] == false
|
|
138
|
+
parts << "default: #{c[:default]}" if c[:default] && !c[:default].to_s.empty?
|
|
139
|
+
parts << "array" if c[:array]
|
|
140
|
+
lines << "- #{parts.join(' | ')}"
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
126
146
|
# Associations
|
|
127
147
|
if data[:associations]&.any?
|
|
128
148
|
lines << "" << "## Associations"
|