rails-ai-context 4.2.3 → 4.3.1
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 +54 -0
- 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 +18 -10
- 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 +10 -19
- data/lib/rails_ai_context/serializers/claude_serializer.rb +42 -11
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +14 -3
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/design_system_helper.rb +8 -1
- data/lib/rails_ai_context/serializers/opencode_serializer.rb +5 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +165 -64
- data/lib/rails_ai_context/server.rb +12 -1
- data/lib/rails_ai_context/tools/base_tool.rb +63 -1
- data/lib/rails_ai_context/tools/diagnose.rb +436 -0
- data/lib/rails_ai_context/tools/generate_test.rb +571 -0
- 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 +70 -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 +19 -0
- data/lib/rails_ai_context/tools/get_partial_interface.rb +1 -1
- 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 +4 -0
- data/lib/rails_ai_context/tools/onboard.rb +755 -0
- data/lib/rails_ai_context/tools/query.rb +4 -2
- data/lib/rails_ai_context/tools/read_logs.rb +4 -1
- data/lib/rails_ai_context/tools/review_changes.rb +299 -0
- 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 +132 -0
- data/lib/rails_ai_context/version.rb +1 -1
- metadata +10 -4
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module Tools
|
|
5
|
+
class Onboard < BaseTool
|
|
6
|
+
tool_name "rails_onboard"
|
|
7
|
+
description "Get a narrative walkthrough of the Rails application — stack, data model, authentication, key flows, " \
|
|
8
|
+
"background jobs, frontend, testing, and getting started instructions. " \
|
|
9
|
+
"Use when: first encountering a project, onboarding a new developer, or orienting an AI agent. " \
|
|
10
|
+
"Key params: detail (quick/standard/full)."
|
|
11
|
+
|
|
12
|
+
input_schema(
|
|
13
|
+
properties: {
|
|
14
|
+
detail: {
|
|
15
|
+
type: "string",
|
|
16
|
+
enum: %w[quick standard full],
|
|
17
|
+
description: "Detail level. quick: 1-paragraph overview. standard: structured walkthrough (default). full: comprehensive with all subsystems."
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
23
|
+
|
|
24
|
+
def self.call(detail: "standard", server_context: nil)
|
|
25
|
+
ctx = cached_context
|
|
26
|
+
|
|
27
|
+
case detail
|
|
28
|
+
when "quick"
|
|
29
|
+
text_response(compose_quick(ctx))
|
|
30
|
+
when "full"
|
|
31
|
+
text_response(compose_full(ctx))
|
|
32
|
+
else
|
|
33
|
+
text_response(compose_standard(ctx))
|
|
34
|
+
end
|
|
35
|
+
rescue => e
|
|
36
|
+
text_response("Onboard error: #{e.message}")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# ── Quick: single paragraph ──────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
def compose_quick(ctx)
|
|
45
|
+
app = ctx[:app_name] || "This Rails app"
|
|
46
|
+
purpose = infer_app_purpose(ctx)
|
|
47
|
+
|
|
48
|
+
parts = [ "**#{app}** is a Rails #{ctx[:rails_version]} / Ruby #{ctx[:ruby_version]}" ]
|
|
49
|
+
parts << purpose if purpose
|
|
50
|
+
|
|
51
|
+
# Stats: tables, models, jobs
|
|
52
|
+
stats = []
|
|
53
|
+
schema = ctx[:schema]
|
|
54
|
+
if schema.is_a?(Hash) && !schema[:error]
|
|
55
|
+
table_count = schema[:total_tables] || 0
|
|
56
|
+
stats << "#{table_count} tables" if table_count > 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
models = ctx[:models]
|
|
60
|
+
if models.is_a?(Hash) && !models[:error] && models.any?
|
|
61
|
+
stats << "#{models.size} models"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
jobs = ctx[:jobs]
|
|
65
|
+
if jobs.is_a?(Hash) && !jobs[:error]
|
|
66
|
+
job_count = (jobs[:jobs] || []).size
|
|
67
|
+
stats << "#{job_count} jobs" if job_count > 0
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
parts << "— #{stats.join(', ')}" if stats.any?
|
|
71
|
+
|
|
72
|
+
# Frontend and testing
|
|
73
|
+
frontend_desc = quick_frontend_summary(ctx)
|
|
74
|
+
parts << "— #{frontend_desc}" if frontend_desc
|
|
75
|
+
|
|
76
|
+
tests = ctx[:tests]
|
|
77
|
+
if tests.is_a?(Hash) && !tests[:error]
|
|
78
|
+
parts << "tested with #{tests[:framework] || 'unknown framework'}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
parts.join(" ") + "."
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# ── Standard: structured walkthrough ─────────────────────────────
|
|
85
|
+
|
|
86
|
+
def compose_standard(ctx) # rubocop:disable Metrics
|
|
87
|
+
lines = [ "# Welcome to #{ctx[:app_name] || 'This Rails App'}", "" ]
|
|
88
|
+
lines.concat(section_stack(ctx))
|
|
89
|
+
lines.concat(section_data_model(ctx))
|
|
90
|
+
lines.concat(section_auth(ctx))
|
|
91
|
+
lines.concat(section_key_flows(ctx))
|
|
92
|
+
lines.concat(section_jobs(ctx))
|
|
93
|
+
lines.concat(section_frontend(ctx))
|
|
94
|
+
lines.concat(section_testing(ctx))
|
|
95
|
+
lines.concat(section_getting_started(ctx))
|
|
96
|
+
lines.join("\n")
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# ── Full: standard + all subsystems ──────────────────────────────
|
|
100
|
+
|
|
101
|
+
def compose_full(ctx) # rubocop:disable Metrics
|
|
102
|
+
lines = [ "# Welcome to #{ctx[:app_name] || 'This Rails App'} (Full Walkthrough)", "" ]
|
|
103
|
+
lines.concat(section_stack(ctx))
|
|
104
|
+
lines.concat(section_data_model(ctx))
|
|
105
|
+
lines.concat(section_auth(ctx))
|
|
106
|
+
lines.concat(section_key_flows(ctx))
|
|
107
|
+
lines.concat(section_jobs(ctx))
|
|
108
|
+
lines.concat(section_frontend(ctx))
|
|
109
|
+
lines.concat(section_payments(ctx))
|
|
110
|
+
lines.concat(section_realtime(ctx))
|
|
111
|
+
lines.concat(section_storage(ctx))
|
|
112
|
+
lines.concat(section_api(ctx))
|
|
113
|
+
lines.concat(section_devops(ctx))
|
|
114
|
+
lines.concat(section_i18n(ctx))
|
|
115
|
+
lines.concat(section_engines(ctx))
|
|
116
|
+
lines.concat(section_env(ctx))
|
|
117
|
+
lines.concat(section_testing(ctx))
|
|
118
|
+
lines.concat(section_getting_started(ctx))
|
|
119
|
+
lines.join("\n")
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# ── Section builders ─────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
def section_stack(ctx)
|
|
125
|
+
lines = [ "## Stack", "" ]
|
|
126
|
+
schema = ctx[:schema]
|
|
127
|
+
if schema.is_a?(Hash) && !schema[:error]
|
|
128
|
+
# Prefer live adapter from config over static_parse from schema introspector
|
|
129
|
+
adapter = resolve_db_adapter(ctx, schema)
|
|
130
|
+
db = "#{adapter} (#{schema[:total_tables]} tables)"
|
|
131
|
+
else
|
|
132
|
+
db = "unknown"
|
|
133
|
+
end
|
|
134
|
+
lines << "#{ctx[:app_name]} is a Rails #{ctx[:rails_version]} application running Ruby #{ctx[:ruby_version]} on #{db}."
|
|
135
|
+
|
|
136
|
+
gems = ctx[:gems]
|
|
137
|
+
if gems.is_a?(Hash) && !gems[:error]
|
|
138
|
+
notable = gems[:notable_gems] || []
|
|
139
|
+
if notable.any?
|
|
140
|
+
by_cat = notable.group_by { |g| g[:category]&.to_s || "other" }
|
|
141
|
+
gem_parts = by_cat.first(5).map { |cat, list| "#{cat}: #{list.map { |g| g[:name] }.join(', ')}" }
|
|
142
|
+
lines << "Notable gems — #{gem_parts.join('; ')}."
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
conv = ctx[:conventions]
|
|
147
|
+
if conv.is_a?(Hash) && !conv[:error]
|
|
148
|
+
arch = conv[:architecture] || []
|
|
149
|
+
lines << "Architecture: #{arch.join(', ')}." if arch.any?
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
lines << ""
|
|
153
|
+
lines
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def section_data_model(ctx)
|
|
157
|
+
models = ctx[:models]
|
|
158
|
+
return [] unless models.is_a?(Hash) && !models[:error] && models.any?
|
|
159
|
+
|
|
160
|
+
lines = [ "## Data Model", "" ]
|
|
161
|
+
top = central_models(models, 7)
|
|
162
|
+
lines << "The app has #{models.size} models. The central ones are:"
|
|
163
|
+
lines << ""
|
|
164
|
+
|
|
165
|
+
top.each do |name|
|
|
166
|
+
data = models[name]
|
|
167
|
+
next unless data.is_a?(Hash) && !data[:error]
|
|
168
|
+
assocs = (data[:associations] || []).map { |a| "#{a[:type]} :#{a[:name]}" }
|
|
169
|
+
val_count = (data[:validations] || []).size
|
|
170
|
+
desc = "**#{name}**"
|
|
171
|
+
desc += " (table: `#{data[:table_name]}`)" if data[:table_name]
|
|
172
|
+
desc += " — #{assocs.first(4).join(', ')}" if assocs.any?
|
|
173
|
+
desc += ", +#{assocs.size - 4} more" if assocs.size > 4
|
|
174
|
+
desc += ". #{val_count} validations." if val_count > 0
|
|
175
|
+
lines << "- #{desc}"
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
remaining = models.size - top.size
|
|
179
|
+
lines << "- _...and #{remaining} more models._" if remaining > 0
|
|
180
|
+
lines << ""
|
|
181
|
+
lines
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def section_auth(ctx)
|
|
185
|
+
auth = ctx[:auth]
|
|
186
|
+
return [] unless auth.is_a?(Hash) && !auth[:error]
|
|
187
|
+
|
|
188
|
+
authentication = auth[:authentication] || {}
|
|
189
|
+
authorization = auth[:authorization] || {}
|
|
190
|
+
return [] if authentication.empty? && authorization.empty?
|
|
191
|
+
|
|
192
|
+
lines = [ "## Authentication & Authorization", "" ]
|
|
193
|
+
if authentication[:method]
|
|
194
|
+
lines << "Authentication is handled by #{authentication[:method]}."
|
|
195
|
+
end
|
|
196
|
+
if authentication[:model]
|
|
197
|
+
lines << "The #{authentication[:model]} model handles user accounts."
|
|
198
|
+
end
|
|
199
|
+
if authorization[:method]
|
|
200
|
+
lines << "Authorization uses #{authorization[:method]}."
|
|
201
|
+
end
|
|
202
|
+
lines << ""
|
|
203
|
+
lines
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def section_key_flows(ctx)
|
|
207
|
+
routes = ctx[:routes]
|
|
208
|
+
controllers = ctx[:controllers]
|
|
209
|
+
return [] unless routes.is_a?(Hash) && !routes[:error]
|
|
210
|
+
|
|
211
|
+
lines = [ "## Key Flows", "" ]
|
|
212
|
+
by_ctrl = routes[:by_controller] || {}
|
|
213
|
+
|
|
214
|
+
# Find controllers with most actions (most important flows)
|
|
215
|
+
internal_prefixes = %w[action_mailbox/ active_storage/ rails/ conductor/ devise/ turbo/]
|
|
216
|
+
app_ctrls = by_ctrl.reject { |k, _| internal_prefixes.any? { |p| k.downcase.start_with?(p) } }
|
|
217
|
+
top_ctrls = app_ctrls.sort_by { |_, routes_list| -routes_list.size }.first(5)
|
|
218
|
+
|
|
219
|
+
top_ctrls.each do |ctrl, ctrl_routes|
|
|
220
|
+
actions = ctrl_routes.map { |r| r[:action] }.compact.uniq
|
|
221
|
+
verbs = ctrl_routes.map { |r| "#{r[:verb]} #{r[:path]}" }.first(3)
|
|
222
|
+
lines << "- **#{ctrl}** — #{actions.join(', ')} (#{verbs.join(', ')})"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
lines << ""
|
|
226
|
+
lines << "Total: #{routes[:total_routes]} routes across #{app_ctrls.size} controllers."
|
|
227
|
+
lines << ""
|
|
228
|
+
lines
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def section_jobs(ctx)
|
|
232
|
+
jobs = ctx[:jobs]
|
|
233
|
+
return [] unless jobs.is_a?(Hash) && !jobs[:error]
|
|
234
|
+
|
|
235
|
+
job_list = jobs[:jobs] || []
|
|
236
|
+
mailers = jobs[:mailers] || []
|
|
237
|
+
channels = jobs[:channels] || []
|
|
238
|
+
return [] if job_list.empty? && mailers.empty? && channels.empty?
|
|
239
|
+
|
|
240
|
+
lines = [ "## Background Jobs & Async", "" ]
|
|
241
|
+
if job_list.any?
|
|
242
|
+
names = job_list.map { |j| j[:name] || j[:class_name] }.compact.first(8)
|
|
243
|
+
lines << "#{job_list.size} background jobs: #{names.join(', ')}#{job_list.size > 8 ? ', ...' : ''}."
|
|
244
|
+
end
|
|
245
|
+
lines << "#{mailers.size} mailers." if mailers.any?
|
|
246
|
+
lines << "#{channels.size} Action Cable channels." if channels.any?
|
|
247
|
+
lines << ""
|
|
248
|
+
lines
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def section_frontend(ctx)
|
|
252
|
+
frontend = ctx[:frontend_frameworks]
|
|
253
|
+
stimulus = ctx[:stimulus]
|
|
254
|
+
turbo = ctx[:turbo]
|
|
255
|
+
|
|
256
|
+
lines = []
|
|
257
|
+
has_content = false
|
|
258
|
+
|
|
259
|
+
if frontend.is_a?(Hash) && !frontend[:error] && frontend[:frameworks]&.any?
|
|
260
|
+
lines << "## Frontend" << ""
|
|
261
|
+
frameworks = frontend[:frameworks]
|
|
262
|
+
if frameworks.is_a?(Hash)
|
|
263
|
+
frameworks.each { |name, version| lines << "- #{name} #{version}".strip }
|
|
264
|
+
elsif frameworks.is_a?(Array)
|
|
265
|
+
frameworks.each { |fw| lines << "- #{fw.is_a?(Hash) ? "#{fw[:name]} #{fw[:version]}" : fw}".strip }
|
|
266
|
+
end
|
|
267
|
+
has_content = true
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
if stimulus.is_a?(Hash) && !stimulus[:error]
|
|
271
|
+
count = stimulus[:total_controllers] || stimulus[:controllers]&.size || 0
|
|
272
|
+
if count > 0
|
|
273
|
+
lines << "## Frontend" << "" unless has_content
|
|
274
|
+
lines << "Stimulus: #{count} controllers for interactive behavior."
|
|
275
|
+
has_content = true
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
if turbo.is_a?(Hash) && !turbo[:error]
|
|
280
|
+
frames = turbo[:frames]&.size || 0
|
|
281
|
+
streams = turbo[:streams]&.size || 0
|
|
282
|
+
if frames > 0 || streams > 0
|
|
283
|
+
lines << "## Frontend" << "" unless has_content
|
|
284
|
+
parts = []
|
|
285
|
+
parts << "#{frames} Turbo Frames" if frames > 0
|
|
286
|
+
parts << "#{streams} Turbo Streams" if streams > 0
|
|
287
|
+
lines << "Hotwire: #{parts.join(', ')}."
|
|
288
|
+
has_content = true
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
lines << "" if has_content
|
|
293
|
+
lines
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def section_testing(ctx)
|
|
297
|
+
tests = ctx[:tests]
|
|
298
|
+
return [] unless tests.is_a?(Hash) && !tests[:error]
|
|
299
|
+
|
|
300
|
+
lines = [ "## Testing", "" ]
|
|
301
|
+
framework = tests[:framework] || "unknown"
|
|
302
|
+
lines << "Framework: #{framework}."
|
|
303
|
+
|
|
304
|
+
factories = tests[:factories]
|
|
305
|
+
fixtures = tests[:fixtures]
|
|
306
|
+
lines << "Data setup: #{factories ? "FactoryBot (#{factories[:count]} factories)" : fixtures ? "fixtures (#{fixtures[:count]} files)" : "inline"}."
|
|
307
|
+
|
|
308
|
+
ci = tests[:ci_config]
|
|
309
|
+
lines << "CI: #{ci.join(', ')}." if ci&.any?
|
|
310
|
+
|
|
311
|
+
coverage = tests[:coverage]
|
|
312
|
+
lines << "Coverage: #{coverage}." if coverage
|
|
313
|
+
|
|
314
|
+
test_cmd = framework == "rspec" ? "bundle exec rspec" : "rails test"
|
|
315
|
+
lines << "" << "Run tests: `#{test_cmd}`"
|
|
316
|
+
lines << ""
|
|
317
|
+
lines
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
def section_getting_started(ctx)
|
|
321
|
+
test_cmd = (ctx[:tests].is_a?(Hash) && ctx[:tests][:framework] == "rspec") ? "bundle exec rspec" : "rails test"
|
|
322
|
+
[
|
|
323
|
+
"## Getting Started", "",
|
|
324
|
+
"```bash",
|
|
325
|
+
"git clone <repo-url>",
|
|
326
|
+
"cd #{ctx[:app_name]&.underscore || 'app'}",
|
|
327
|
+
"bundle install",
|
|
328
|
+
"rails db:setup",
|
|
329
|
+
"bin/dev # or rails server",
|
|
330
|
+
"#{test_cmd} # verify everything works",
|
|
331
|
+
"```", ""
|
|
332
|
+
]
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
# ── Full-only sections ───────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
def section_payments(ctx)
|
|
338
|
+
gems = ctx[:gems]
|
|
339
|
+
models = ctx[:models]
|
|
340
|
+
return [] unless gems.is_a?(Hash) && !gems[:error] && models.is_a?(Hash)
|
|
341
|
+
|
|
342
|
+
payment_gems = %w[stripe pay braintree paddle_pay]
|
|
343
|
+
notable = gems[:notable_gems] || []
|
|
344
|
+
found = notable.select { |g| payment_gems.include?(g[:name]) }
|
|
345
|
+
payment_models = models.keys.select { |m| m.downcase.match?(/payment|subscription|charge|invoice|plan|billing/) }
|
|
346
|
+
return [] if found.empty? && payment_models.empty?
|
|
347
|
+
|
|
348
|
+
lines = [ "## Payments", "" ]
|
|
349
|
+
lines << "Payment gems: #{found.map { |g| g[:name] }.join(', ')}." if found.any?
|
|
350
|
+
lines << "Payment-related models: #{payment_models.join(', ')}." if payment_models.any?
|
|
351
|
+
lines << ""
|
|
352
|
+
lines
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def section_realtime(ctx)
|
|
356
|
+
turbo = ctx[:turbo]
|
|
357
|
+
jobs = ctx[:jobs]
|
|
358
|
+
channels = (jobs.is_a?(Hash) ? jobs[:channels] : nil) || []
|
|
359
|
+
return [] unless (turbo.is_a?(Hash) && !turbo[:error]) || channels.any?
|
|
360
|
+
|
|
361
|
+
lines = [ "## Real-Time Features", "" ]
|
|
362
|
+
if channels.any?
|
|
363
|
+
names = channels.map { |c| c[:name] || c[:class_name] }.compact
|
|
364
|
+
lines << "Action Cable channels: #{names.join(', ')}."
|
|
365
|
+
end
|
|
366
|
+
if turbo.is_a?(Hash) && !turbo[:error] && turbo[:broadcasts]&.any?
|
|
367
|
+
lines << "Turbo Stream broadcasts: #{turbo[:broadcasts].size} broadcast points."
|
|
368
|
+
end
|
|
369
|
+
lines << ""
|
|
370
|
+
lines
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def section_storage(ctx)
|
|
374
|
+
storage = ctx[:active_storage]
|
|
375
|
+
text = ctx[:action_text]
|
|
376
|
+
return [] unless (storage.is_a?(Hash) && !storage[:error]) || (text.is_a?(Hash) && !text[:error])
|
|
377
|
+
|
|
378
|
+
lines = [ "## File Storage & Rich Text", "" ]
|
|
379
|
+
if storage.is_a?(Hash) && !storage[:error] && storage[:attachments]&.any?
|
|
380
|
+
lines << "Active Storage: #{storage[:attachments].size} attachment(s) across models."
|
|
381
|
+
end
|
|
382
|
+
if text.is_a?(Hash) && !text[:error] && text[:models]&.any?
|
|
383
|
+
lines << "Action Text: #{text[:models].size} model(s) with rich text fields."
|
|
384
|
+
end
|
|
385
|
+
lines << ""
|
|
386
|
+
lines
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def section_api(ctx)
|
|
390
|
+
api = ctx[:api]
|
|
391
|
+
return [] unless api.is_a?(Hash) && !api[:error]
|
|
392
|
+
return [] if api.empty? || (api[:endpoints]&.empty? && api[:graphql].nil?)
|
|
393
|
+
|
|
394
|
+
lines = [ "## API", "" ]
|
|
395
|
+
if api[:graphql]
|
|
396
|
+
lines << "GraphQL API detected."
|
|
397
|
+
end
|
|
398
|
+
if api[:endpoints]&.any?
|
|
399
|
+
lines << "#{api[:endpoints].size} API endpoint(s)."
|
|
400
|
+
end
|
|
401
|
+
if api[:serializers]&.any?
|
|
402
|
+
lines << "Serializers: #{api[:serializers].size}."
|
|
403
|
+
end
|
|
404
|
+
lines << ""
|
|
405
|
+
lines
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
def section_devops(ctx)
|
|
409
|
+
devops = ctx[:devops]
|
|
410
|
+
return [] unless devops.is_a?(Hash) && !devops[:error]
|
|
411
|
+
|
|
412
|
+
lines = [ "## Deployment & DevOps", "" ]
|
|
413
|
+
lines << "Dockerfile: #{devops[:dockerfile] ? 'present' : 'not found'}."
|
|
414
|
+
lines << "Procfile: #{devops[:procfile] ? 'present' : 'not found'}." if devops.key?(:procfile)
|
|
415
|
+
deploy = devops[:deployment_method]
|
|
416
|
+
lines << "Deployment: #{deploy}." if deploy
|
|
417
|
+
lines << ""
|
|
418
|
+
lines
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
def section_i18n(ctx)
|
|
422
|
+
i18n = ctx[:i18n]
|
|
423
|
+
return [] unless i18n.is_a?(Hash) && !i18n[:error]
|
|
424
|
+
|
|
425
|
+
locales = i18n[:locales] || []
|
|
426
|
+
return [] if locales.empty?
|
|
427
|
+
|
|
428
|
+
[ "## Internationalization", "", "Locales: #{locales.join(', ')}.", "" ]
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def section_engines(ctx)
|
|
432
|
+
engines = ctx[:engines]
|
|
433
|
+
return [] unless engines.is_a?(Hash) && !engines[:error]
|
|
434
|
+
|
|
435
|
+
mounted = engines[:engines] || engines[:mounted] || []
|
|
436
|
+
return [] if mounted.empty?
|
|
437
|
+
|
|
438
|
+
lines = [ "## Mounted Engines", "" ]
|
|
439
|
+
mounted.each do |e|
|
|
440
|
+
name = e[:name] || e[:engine]
|
|
441
|
+
path = e[:path] || e[:mount_path]
|
|
442
|
+
lines << "- **#{name}** at `#{path}`" if name
|
|
443
|
+
end
|
|
444
|
+
lines << ""
|
|
445
|
+
lines
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
def section_env(ctx)
|
|
449
|
+
# Summarize from models that have encrypts, and auth/payment-related env patterns
|
|
450
|
+
models = ctx[:models]
|
|
451
|
+
return [] unless models.is_a?(Hash) && !models[:error]
|
|
452
|
+
|
|
453
|
+
encrypted = models.select { |_, d| d.is_a?(Hash) && d[:encrypts]&.any? }
|
|
454
|
+
return [] if encrypted.empty?
|
|
455
|
+
|
|
456
|
+
lines = [ "## Encrypted Data", "" ]
|
|
457
|
+
encrypted.each do |name, data|
|
|
458
|
+
lines << "- **#{name}**: encrypts #{data[:encrypts].join(', ')}"
|
|
459
|
+
end
|
|
460
|
+
lines << ""
|
|
461
|
+
lines
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
# ── Helpers ──────────────────────────────────────────────────────
|
|
465
|
+
|
|
466
|
+
# Resolve the DB adapter name, preferring live config over schema introspection
|
|
467
|
+
def resolve_db_adapter(ctx, schema)
|
|
468
|
+
adapter = schema[:adapter]
|
|
469
|
+
|
|
470
|
+
# If the schema introspector returned a non-informative adapter name, try config
|
|
471
|
+
if adapter.nil? || adapter == "static_parse" || adapter == "unknown"
|
|
472
|
+
config_data = ctx[:config]
|
|
473
|
+
if config_data.is_a?(Hash) && !config_data[:error]
|
|
474
|
+
live_adapter = config_data[:database_adapter] || config_data[:adapter]
|
|
475
|
+
adapter = live_adapter if live_adapter
|
|
476
|
+
end
|
|
477
|
+
end
|
|
478
|
+
|
|
479
|
+
# Try to resolve from gems as a fallback
|
|
480
|
+
if adapter.nil? || adapter == "static_parse" || adapter == "unknown"
|
|
481
|
+
gems_data = ctx[:gems]
|
|
482
|
+
if gems_data.is_a?(Hash) && !gems_data[:error]
|
|
483
|
+
notable = gems_data[:notable_gems] || []
|
|
484
|
+
adapter = "PostgreSQL" if notable.any? { |g| g[:name] == "pg" }
|
|
485
|
+
adapter = "MySQL" if notable.any? { |g| g[:name] == "mysql2" }
|
|
486
|
+
adapter = "SQLite" if notable.any? { |g| g[:name] == "sqlite3" }
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
adapter || "unknown"
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def central_models(models, limit = 5)
|
|
494
|
+
models
|
|
495
|
+
.select { |_, d| d.is_a?(Hash) && !d[:error] }
|
|
496
|
+
.sort_by { |_, d| -(d[:associations]&.size || 0) }
|
|
497
|
+
.first(limit)
|
|
498
|
+
.map(&:first)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# ── Purpose inference ────────────────────────────────────────────
|
|
502
|
+
|
|
503
|
+
# Infer a short description of what the app does from its jobs,
|
|
504
|
+
# services, models, gems, and architecture patterns.
|
|
505
|
+
def infer_app_purpose(ctx)
|
|
506
|
+
signals = collect_purpose_signals(ctx)
|
|
507
|
+
return nil if signals.empty?
|
|
508
|
+
|
|
509
|
+
# Deduplicate and join into a natural phrase
|
|
510
|
+
capabilities = signals.uniq
|
|
511
|
+
return nil if capabilities.empty?
|
|
512
|
+
|
|
513
|
+
"#{capabilities.shift} app#{capabilities.any? ? ' with ' + join_capabilities(capabilities) : ''}"
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Collect domain signals from jobs, services, models, gems, and conventions
|
|
517
|
+
def collect_purpose_signals(ctx) # rubocop:disable Metrics
|
|
518
|
+
signals = []
|
|
519
|
+
|
|
520
|
+
# Gather raw names from all sources
|
|
521
|
+
job_names = extract_job_names(ctx)
|
|
522
|
+
service_names = extract_service_names
|
|
523
|
+
model_names = extract_model_names(ctx)
|
|
524
|
+
gem_names = extract_gem_names(ctx)
|
|
525
|
+
architecture = extract_architecture(ctx)
|
|
526
|
+
|
|
527
|
+
# Infer primary domain from model names
|
|
528
|
+
signals.concat(infer_domain(model_names, job_names, service_names))
|
|
529
|
+
|
|
530
|
+
# Infer capabilities from jobs and services
|
|
531
|
+
signals.concat(infer_ingestion_sources(job_names, service_names))
|
|
532
|
+
signals.concat(infer_federation(service_names, gem_names, model_names))
|
|
533
|
+
signals.concat(infer_ai_processing(service_names, job_names, gem_names))
|
|
534
|
+
signals.concat(infer_social_features(model_names, service_names))
|
|
535
|
+
signals.concat(infer_notifications(service_names, job_names))
|
|
536
|
+
signals.concat(infer_search(gem_names, architecture))
|
|
537
|
+
signals.concat(infer_ecommerce(model_names, service_names, gem_names))
|
|
538
|
+
signals.concat(infer_messaging(model_names, job_names))
|
|
539
|
+
|
|
540
|
+
signals
|
|
541
|
+
end
|
|
542
|
+
|
|
543
|
+
def extract_job_names(ctx)
|
|
544
|
+
jobs = ctx[:jobs]
|
|
545
|
+
return [] unless jobs.is_a?(Hash) && !jobs[:error]
|
|
546
|
+
(jobs[:jobs] || []).map { |j| j[:name].to_s }.reject(&:empty?)
|
|
547
|
+
end
|
|
548
|
+
|
|
549
|
+
def extract_service_names
|
|
550
|
+
services_dir = File.join(Rails.root, "app", "services")
|
|
551
|
+
return [] unless Dir.exist?(services_dir)
|
|
552
|
+
|
|
553
|
+
Dir.glob(File.join(services_dir, "**", "*.rb")).filter_map do |path|
|
|
554
|
+
name = File.basename(path, ".rb").camelize
|
|
555
|
+
name unless name == "ApplicationService" || name == "BaseService"
|
|
556
|
+
end
|
|
557
|
+
rescue
|
|
558
|
+
[]
|
|
559
|
+
end
|
|
560
|
+
|
|
561
|
+
def extract_model_names(ctx)
|
|
562
|
+
models = ctx[:models]
|
|
563
|
+
return [] unless models.is_a?(Hash) && !models[:error]
|
|
564
|
+
models.keys.map(&:to_s)
|
|
565
|
+
end
|
|
566
|
+
|
|
567
|
+
def extract_gem_names(ctx)
|
|
568
|
+
gems = ctx[:gems]
|
|
569
|
+
return [] unless gems.is_a?(Hash) && !gems[:error]
|
|
570
|
+
(gems[:notable_gems] || []).map { |g| g[:name].to_s }
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def extract_architecture(ctx)
|
|
574
|
+
conv = ctx[:conventions]
|
|
575
|
+
return [] unless conv.is_a?(Hash) && !conv[:error]
|
|
576
|
+
conv[:architecture] || []
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# Infer the primary domain of the app (e.g., "news aggregation", "e-commerce")
|
|
580
|
+
def infer_domain(model_names, job_names, service_names)
|
|
581
|
+
all_names = (model_names + job_names + service_names).map(&:downcase).join(" ")
|
|
582
|
+
|
|
583
|
+
# Order matters: more specific patterns first
|
|
584
|
+
if all_names.match?(/article|news|rss|feed/) && all_names.match?(/site|source|feed/)
|
|
585
|
+
[ "news aggregation" ]
|
|
586
|
+
elsif all_names.match?(/article|blog|post/) && all_names.match?(/comment|author/)
|
|
587
|
+
[ "content publishing" ]
|
|
588
|
+
elsif all_names.match?(/product|cart|order|checkout/)
|
|
589
|
+
[ "e-commerce" ]
|
|
590
|
+
elsif all_names.match?(/patient|appointment|doctor|medical/)
|
|
591
|
+
[ "healthcare" ]
|
|
592
|
+
elsif all_names.match?(/course|lesson|student|enrollment/)
|
|
593
|
+
[ "education/LMS" ]
|
|
594
|
+
elsif all_names.match?(/listing|property|booking|reservation/)
|
|
595
|
+
[ "marketplace" ]
|
|
596
|
+
elsif all_names.match?(/ticket|issue|sprint|project/) && all_names.match?(/assign|board/)
|
|
597
|
+
[ "project management" ]
|
|
598
|
+
elsif all_names.match?(/message|conversation|chat|thread/)
|
|
599
|
+
[ "messaging" ]
|
|
600
|
+
elsif all_names.match?(/invoice|payment|subscription|billing/)
|
|
601
|
+
[ "billing/SaaS" ]
|
|
602
|
+
elsif all_names.match?(/post|comment|follow|like|feed/)
|
|
603
|
+
[ "social platform" ]
|
|
604
|
+
elsif all_names.match?(/article|post|page|content/)
|
|
605
|
+
[ "content management" ]
|
|
606
|
+
else
|
|
607
|
+
[]
|
|
608
|
+
end
|
|
609
|
+
end
|
|
610
|
+
|
|
611
|
+
# Infer content ingestion sources from job/service names
|
|
612
|
+
def infer_ingestion_sources(job_names, service_names)
|
|
613
|
+
all_names = (job_names + service_names).map(&:downcase)
|
|
614
|
+
sources = []
|
|
615
|
+
|
|
616
|
+
sources << "RSS" if all_names.any? { |n| n.include?("rss") }
|
|
617
|
+
sources << "YouTube" if all_names.any? { |n| n.include?("youtube") }
|
|
618
|
+
sources << "HackerNews" if all_names.any? { |n| n.include?("hackernews") || n.include?("hacker_news") }
|
|
619
|
+
sources << "Reddit" if all_names.any? { |n| n.include?("reddit") }
|
|
620
|
+
sources << "Gmail" if all_names.any? { |n| n.include?("gmail") }
|
|
621
|
+
sources << "Twitter" if all_names.any? { |n| n.include?("twitter") }
|
|
622
|
+
|
|
623
|
+
return [] if sources.empty?
|
|
624
|
+
[ "#{sources.join(', ')} ingestion" ]
|
|
625
|
+
end
|
|
626
|
+
|
|
627
|
+
# Infer ActivityPub/federation features
|
|
628
|
+
def infer_federation(service_names, gem_names, model_names)
|
|
629
|
+
all = (service_names + gem_names + model_names).map(&:downcase)
|
|
630
|
+
|
|
631
|
+
if all.any? { |n| n.match?(/mastodon|activitypub|federails|federation/) }
|
|
632
|
+
[ "ActivityPub federation" ]
|
|
633
|
+
elsif all.any? { |n| n.match?(/fediverse/) }
|
|
634
|
+
[ "Fediverse integration" ]
|
|
635
|
+
else
|
|
636
|
+
[]
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# Infer AI/ML processing features
|
|
641
|
+
def infer_ai_processing(service_names, job_names, gem_names)
|
|
642
|
+
all = (service_names + job_names + gem_names).map(&:downcase)
|
|
643
|
+
|
|
644
|
+
if all.any? { |n| n.match?(/agent|openai|anthropic|llm|ai_|_ai/) }
|
|
645
|
+
[ "AI processing" ]
|
|
646
|
+
elsif all.any? { |n| n.match?(/ml_|machine_learn|predict/) }
|
|
647
|
+
[ "ML processing" ]
|
|
648
|
+
else
|
|
649
|
+
[]
|
|
650
|
+
end
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
# Infer social features (follows, likes, etc.)
|
|
654
|
+
def infer_social_features(model_names, service_names)
|
|
655
|
+
all = (model_names + service_names).map(&:downcase)
|
|
656
|
+
|
|
657
|
+
if all.any? { |n| n.match?(/follow|like|mention|social/) } && all.any? { |n| n.match?(/federation|mastodon/) }
|
|
658
|
+
[] # Already covered by federation
|
|
659
|
+
elsif all.any? { |n| n.match?(/oauth|social_media/) }
|
|
660
|
+
[ "social media integration" ]
|
|
661
|
+
else
|
|
662
|
+
[]
|
|
663
|
+
end
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
# Infer push/notification features
|
|
667
|
+
def infer_notifications(service_names, job_names)
|
|
668
|
+
all = (service_names + job_names).map(&:downcase)
|
|
669
|
+
|
|
670
|
+
if all.any? { |n| n.match?(/push_notif|web_push|notification/) }
|
|
671
|
+
[ "push notifications" ]
|
|
672
|
+
else
|
|
673
|
+
[]
|
|
674
|
+
end
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
# Infer search capabilities
|
|
678
|
+
def infer_search(gem_names, architecture)
|
|
679
|
+
all = (gem_names + architecture).map(&:downcase)
|
|
680
|
+
|
|
681
|
+
if all.any? { |n| n.match?(/elasticsearch|searchkick|meilisearch/) }
|
|
682
|
+
[ "full-text search" ]
|
|
683
|
+
else
|
|
684
|
+
[]
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
# Infer e-commerce features
|
|
689
|
+
def infer_ecommerce(model_names, service_names, gem_names)
|
|
690
|
+
all = (model_names + service_names + gem_names).map(&:downcase)
|
|
691
|
+
|
|
692
|
+
if all.any? { |n| n.match?(/stripe|pay\b|braintree/) }
|
|
693
|
+
[ "payment processing" ]
|
|
694
|
+
else
|
|
695
|
+
[]
|
|
696
|
+
end
|
|
697
|
+
end
|
|
698
|
+
|
|
699
|
+
# Infer messaging/real-time features
|
|
700
|
+
def infer_messaging(model_names, job_names)
|
|
701
|
+
all = (model_names + job_names).map(&:downcase)
|
|
702
|
+
|
|
703
|
+
if all.any? { |n| n.match?(/conversation|chat|direct_message/) }
|
|
704
|
+
[ "real-time messaging" ]
|
|
705
|
+
else
|
|
706
|
+
[]
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
|
|
710
|
+
# Quick one-line frontend summary from conventions
|
|
711
|
+
def quick_frontend_summary(ctx)
|
|
712
|
+
conv = ctx[:conventions]
|
|
713
|
+
return nil unless conv.is_a?(Hash) && !conv[:error]
|
|
714
|
+
|
|
715
|
+
arch = conv[:architecture] || []
|
|
716
|
+
parts = []
|
|
717
|
+
|
|
718
|
+
parts << "Hotwire" if arch.include?("hotwire")
|
|
719
|
+
parts << "Phlex" if arch.include?("phlex")
|
|
720
|
+
parts << "ViewComponent" if arch.include?("view_components") && !arch.include?("phlex")
|
|
721
|
+
parts << "Stimulus" if arch.include?("stimulus") && !arch.include?("hotwire")
|
|
722
|
+
parts << "React" if arch.include?("react")
|
|
723
|
+
parts << "Vue" if arch.include?("vue")
|
|
724
|
+
|
|
725
|
+
# Check frontend frameworks introspection too
|
|
726
|
+
frontend = ctx[:frontend_frameworks]
|
|
727
|
+
if frontend.is_a?(Hash) && !frontend[:error]
|
|
728
|
+
frameworks = frontend[:frameworks]
|
|
729
|
+
if frameworks.is_a?(Hash)
|
|
730
|
+
frameworks.each_key do |name|
|
|
731
|
+
n = name.to_s.downcase
|
|
732
|
+
parts << "React" if n.include?("react") && !parts.include?("React")
|
|
733
|
+
parts << "Vue" if n.include?("vue") && !parts.include?("Vue")
|
|
734
|
+
parts << "Angular" if n.include?("angular") && !parts.include?("Angular")
|
|
735
|
+
parts << "Svelte" if n.include?("svelte") && !parts.include?("Svelte")
|
|
736
|
+
end
|
|
737
|
+
end
|
|
738
|
+
end
|
|
739
|
+
|
|
740
|
+
parts.any? ? "#{parts.join(' + ')} frontend" : nil
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
# Join a list of capabilities with commas and "and" before the last
|
|
744
|
+
def join_capabilities(items)
|
|
745
|
+
case items.size
|
|
746
|
+
when 0 then ""
|
|
747
|
+
when 1 then items.first
|
|
748
|
+
when 2 then "#{items[0]} and #{items[1]}"
|
|
749
|
+
else "#{items[0..-2].join(', ')}, and #{items.last}"
|
|
750
|
+
end
|
|
751
|
+
end
|
|
752
|
+
end
|
|
753
|
+
end
|
|
754
|
+
end
|
|
755
|
+
end
|