rails-ai-context 5.32.2 → 5.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1491 -0
- data/CONTEXT.md +27 -3
- data/README.md +16 -12
- data/Rakefile +6 -0
- data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
- data/config/routes.rb +2 -3
- data/docs/ARCHITECTURE.md +7 -5
- data/docs/CLI.md +79 -6
- data/docs/COMPATIBILITY.md +75 -1
- data/docs/CONFIGURATION.md +30 -11
- data/docs/CUSTOM_TOOLS.md +2 -1
- data/docs/FAQ.md +16 -6
- data/docs/GUIDE.md +117 -71
- data/docs/INTROSPECTORS.md +10 -8
- data/docs/QUICKSTART.md +3 -3
- data/docs/RECIPES.md +1 -1
- data/docs/SECURITY.md +109 -25
- data/docs/SETUP.md +30 -6
- data/docs/STANDALONE.md +10 -3
- data/docs/TOOLS.md +152 -34
- data/docs/TROUBLESHOOTING.md +90 -12
- data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
- data/docs/adr/0005-workspace-mcp-entries.md +39 -0
- data/exe/rails-ai-context +733 -104
- data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
- data/lib/rails_ai_context/app_kind.rb +18 -5
- data/lib/rails_ai_context/ast_cache.rb +7 -0
- data/lib/rails_ai_context/base_mixins.rb +8 -8
- data/lib/rails_ai_context/boot_manager.rb +22 -3
- data/lib/rails_ai_context/change_watch.rb +14 -11
- data/lib/rails_ai_context/cli/app_root.rb +401 -0
- data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
- data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
- data/lib/rails_ai_context/code_reloader.rb +115 -1
- data/lib/rails_ai_context/concern_membership.rb +3 -1
- data/lib/rails_ai_context/concern_paths.rb +8 -3
- data/lib/rails_ai_context/configuration.rb +41 -9
- data/lib/rails_ai_context/context_file_report.rb +19 -2
- data/lib/rails_ai_context/database_file.rb +26 -0
- data/lib/rails_ai_context/doctor.rb +1122 -184
- data/lib/rails_ai_context/engine.rb +18 -1
- data/lib/rails_ai_context/erb_source.rb +19 -0
- data/lib/rails_ai_context/executable.rb +21 -0
- data/lib/rails_ai_context/facts_formatter.rb +5 -2
- data/lib/rails_ai_context/file_walk.rb +61 -10
- data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
- data/lib/rails_ai_context/fingerprinter.rb +40 -12
- data/lib/rails_ai_context/gem_lock.rb +186 -27
- data/lib/rails_ai_context/git_ignore.rb +22 -0
- data/lib/rails_ai_context/install/ai_tool.rb +31 -5
- data/lib/rails_ai_context/install/cleanup.rb +21 -0
- data/lib/rails_ai_context/install/program.rb +209 -35
- data/lib/rails_ai_context/install/selection_record.rb +258 -97
- data/lib/rails_ai_context/install/surface.rb +6 -1
- data/lib/rails_ai_context/install/validation_hook.rb +282 -0
- data/lib/rails_ai_context/install/workspace.rb +166 -0
- data/lib/rails_ai_context/install_mode.rb +71 -13
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
- data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
- data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
- data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
- data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
- data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
- data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
- data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
- data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
- data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
- data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
- data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
- data/lib/rails_ai_context/live_reload.rb +20 -20
- data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
- data/lib/rails_ai_context/mcp_edge.rb +182 -32
- data/lib/rails_ai_context/middleware.rb +5 -1
- data/lib/rails_ai_context/migration_status.rb +88 -25
- data/lib/rails_ai_context/output_guard.rb +48 -2
- data/lib/rails_ai_context/package_json.rb +1 -1
- data/lib/rails_ai_context/path_resolver.rb +46 -13
- data/lib/rails_ai_context/payload.rb +92 -8
- data/lib/rails_ai_context/pending_migrations.rb +5 -1
- data/lib/rails_ai_context/portable_path.rb +3 -2
- data/lib/rails_ai_context/presets.rb +15 -11
- data/lib/rails_ai_context/redaction.rb +262 -51
- data/lib/rails_ai_context/resources.rb +55 -20
- data/lib/rails_ai_context/route_coverage.rb +8 -0
- data/lib/rails_ai_context/safe_file.rb +14 -1
- data/lib/rails_ai_context/safe_path.rb +101 -4
- data/lib/rails_ai_context/schema_adapter.rb +8 -0
- data/lib/rails_ai_context/schema_version.rb +9 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
- data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
- data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
- data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
- data/lib/rails_ai_context/server.rb +304 -71
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
- data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
- data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
- data/lib/rails_ai_context/tools/base_tool.rb +367 -46
- data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
- data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
- data/lib/rails_ai_context/tools/diagnose.rb +218 -11
- data/lib/rails_ai_context/tools/generate_test.rb +339 -39
- data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
- data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
- data/lib/rails_ai_context/tools/get_concern.rb +91 -27
- data/lib/rails_ai_context/tools/get_config.rb +13 -4
- data/lib/rails_ai_context/tools/get_context.rb +71 -14
- data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
- data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
- data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
- data/lib/rails_ai_context/tools/get_env.rb +18 -9
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
- data/lib/rails_ai_context/tools/get_gems.rb +1 -1
- data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
- data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
- data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
- data/lib/rails_ai_context/tools/get_routes.rb +29 -8
- data/lib/rails_ai_context/tools/get_schema.rb +52 -19
- data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
- data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
- data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
- data/lib/rails_ai_context/tools/get_view.rb +14 -14
- data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
- data/lib/rails_ai_context/tools/onboard.rb +52 -8
- data/lib/rails_ai_context/tools/performance_check.rb +5 -3
- data/lib/rails_ai_context/tools/query.rb +914 -86
- data/lib/rails_ai_context/tools/read_logs.rb +69 -15
- data/lib/rails_ai_context/tools/review_changes.rb +215 -53
- data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
- data/lib/rails_ai_context/tools/safe_call.rb +35 -14
- data/lib/rails_ai_context/tools/search_code.rb +243 -67
- data/lib/rails_ai_context/tools/search_docs.rb +4 -4
- data/lib/rails_ai_context/tools/security_scan.rb +143 -33
- data/lib/rails_ai_context/tools/session_context.rb +39 -5
- data/lib/rails_ai_context/tools/validate.rb +259 -40
- data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +67 -40
- data/lib/rails_ai_context/view_file.rb +41 -3
- data/lib/rails_ai_context/watcher.rb +51 -13
- data/lib/rails_ai_context.rb +21 -0
- data/server.json +1 -1
- metadata +14 -1
|
@@ -109,11 +109,12 @@ module RailsAiContext
|
|
|
109
109
|
else
|
|
110
110
|
return json_response(table_data.except(:unread_calls).merge({ database: database }.compact)) if format == "json"
|
|
111
111
|
|
|
112
|
-
output = format_table_markdown(table_key, table_data, models_data, schema[:enum_types], schema[:search_path],
|
|
112
|
+
output = format_table_markdown(table_key, table_data, models_data, schema[:enum_types], schema[:search_path],
|
|
113
|
+
listed_as: listed_as, schema: schema, database: (databases.first if databases.size == 1))
|
|
113
114
|
output = output.sub("\n\n", "\n\n**Database:** #{database}\n") if database
|
|
114
115
|
end
|
|
115
116
|
# Cross-reference hint for AI: suggest next tool call
|
|
116
|
-
model_refs = models_for_table([ table_key, listed_as ], models_data)
|
|
117
|
+
model_refs = models_for_table([ table_key, listed_as ], models_data, schema: schema, database: (databases.first if databases.size == 1))
|
|
117
118
|
if model_refs.any?
|
|
118
119
|
output += "\n\n_Next: `rails_get_model_details(model:\"#{model_refs.first}\")` for associations, validations, scopes._"
|
|
119
120
|
end
|
|
@@ -139,7 +140,7 @@ module RailsAiContext
|
|
|
139
140
|
idx_count = data[:indexes]&.size || 0
|
|
140
141
|
lines << "- **#{name}**#{relation_suffix(data)} - #{count_phrase(col_count, "column")}, #{count_phrase(idx_count, "index", plural: "indexes")}"
|
|
141
142
|
end
|
|
142
|
-
coverage = model_coverage_lines(tables, models_data)
|
|
143
|
+
coverage = model_coverage_lines(tables, models_data, schema)
|
|
143
144
|
lines.concat([ "" ] + coverage) if coverage.any?
|
|
144
145
|
lines.concat(secondary_databases_lines(schema))
|
|
145
146
|
if page[:offset] + page[:limit] < total
|
|
@@ -150,7 +151,7 @@ module RailsAiContext
|
|
|
150
151
|
|
|
151
152
|
when "standard"
|
|
152
153
|
# Sort by column count (most complex first) - AI agents care about big tables first
|
|
153
|
-
sorted = tables.keys.sort_by { |name| -(tables[name][:columns]&.size || 0) }
|
|
154
|
+
sorted = tables.keys.sort_by.with_index { |name, i| [ -(tables[name][:columns]&.size || 0), i ] }
|
|
154
155
|
page = paginate(sorted, offset: offset, limit: limit, default_limit: 25)
|
|
155
156
|
paginated = page[:items]
|
|
156
157
|
return json_page_response(schema, tables, paginated, models_data) if format == "json"
|
|
@@ -195,7 +196,7 @@ module RailsAiContext
|
|
|
195
196
|
|
|
196
197
|
# Detect encrypted columns from model data
|
|
197
198
|
encrypted_cols = Set.new
|
|
198
|
-
model_refs = models_for_table(name, models_data)
|
|
199
|
+
model_refs = models_for_table(name, models_data, schema: schema, database: "primary")
|
|
199
200
|
model_refs.each do |model_name|
|
|
200
201
|
(models_data.dig(model_name, :encrypts) || []).each { |f| encrypted_cols.add(f) }
|
|
201
202
|
end
|
|
@@ -215,13 +216,17 @@ module RailsAiContext
|
|
|
215
216
|
trailing.fetch(c[:name], []).uniq.each { |before| hints << "in index after #{before.join(', ')}" }
|
|
216
217
|
end
|
|
217
218
|
hints << "encrypted" if encrypted_cols.include?(c[:name])
|
|
219
|
+
# The database computes a generated column, so a write to it fails.
|
|
220
|
+
hints << (c[:stored] ? "generated, stored" : "generated") if c.key?(:generated)
|
|
218
221
|
# Show default value if present
|
|
219
222
|
if c.key?(:default) && !c[:default].nil? && c[:default] != ""
|
|
220
223
|
hints << "default: #{c[:default]}"
|
|
221
224
|
end
|
|
222
225
|
# A clause can hold a column list, so clauses part with a semicolon.
|
|
223
226
|
hint_str = hints.any? ? " [#{hints.join('; ')}]" : ""
|
|
224
|
-
|
|
227
|
+
# An array column reads `string[]`, as the table view writes it.
|
|
228
|
+
type = c[:type] && c[:array] ? "#{c[:type]}[]" : c[:type]
|
|
229
|
+
"#{[ c[:name], type ].compact.join(':')}#{hint_str}"
|
|
225
230
|
end.join(", ")
|
|
226
231
|
# Inline model info so AI doesn't need a separate get_model_details call
|
|
227
232
|
# Every model on the table, richest first: an STI child or a
|
|
@@ -247,9 +252,12 @@ module RailsAiContext
|
|
|
247
252
|
lines << ""
|
|
248
253
|
end
|
|
249
254
|
|
|
250
|
-
coverage = model_coverage_lines(tables, models_data)
|
|
255
|
+
coverage = model_coverage_lines(tables, models_data, schema)
|
|
251
256
|
lines.concat(coverage + [ "" ]) if coverage.any?
|
|
252
257
|
lines.concat(secondary_databases_lines(schema))
|
|
258
|
+
if page[:offset] + page[:limit] < total
|
|
259
|
+
lines << "_Showing #{paginated.size} of #{total}. Use `offset:#{page[:offset] + page[:limit]}` for more._"
|
|
260
|
+
end
|
|
253
261
|
lines << "_Use `detail:\"summary\"` for all #{Introspectors::SchemaConventions.relations_phrase(tables)}, `detail:\"full\"` for indexes/FKs, or `table:\"name\"` for one table._" if total > page[:limit]
|
|
254
262
|
text_response(lines.join("\n"))
|
|
255
263
|
|
|
@@ -265,10 +273,10 @@ module RailsAiContext
|
|
|
265
273
|
lines = [ "# Schema Full Detail (#{paginated.size} of #{Introspectors::SchemaConventions.relations_phrase(tables)})", "" ]
|
|
266
274
|
lines.concat(note_lines(schema))
|
|
267
275
|
paginated.each do |name|
|
|
268
|
-
lines << format_table_markdown(name, tables[name], models_data, schema[:enum_types], schema[:search_path])
|
|
276
|
+
lines << format_table_markdown(name, tables[name], models_data, schema[:enum_types], schema[:search_path], schema: schema, database: "primary")
|
|
269
277
|
lines << ""
|
|
270
278
|
end
|
|
271
|
-
coverage = model_coverage_lines(tables, models_data)
|
|
279
|
+
coverage = model_coverage_lines(tables, models_data, schema)
|
|
272
280
|
lines.concat(coverage + [ "" ]) if coverage.any?
|
|
273
281
|
lines.concat(secondary_databases_lines(schema))
|
|
274
282
|
if page[:offset] + page[:limit] < total
|
|
@@ -343,8 +351,14 @@ module RailsAiContext
|
|
|
343
351
|
RailsAiContext.debug_fail(e, Set.new, label: "declared_join_tables")
|
|
344
352
|
end
|
|
345
353
|
|
|
346
|
-
|
|
347
|
-
|
|
354
|
+
# A table name is not a table: in a multi-database app the primary's
|
|
355
|
+
# `settings` and analytics' `settings` are two, and a model reads the one
|
|
356
|
+
# its database holds. `database` keeps the models that read that one.
|
|
357
|
+
private_class_method def self.models_for_table(table_names, models, schema: nil, database: nil)
|
|
358
|
+
models.select do |_, d|
|
|
359
|
+
d.is_a?(Hash) && Array(table_names).compact.include?(d[:table_name]) &&
|
|
360
|
+
(database.nil? || Payload.model_databases(schema, d).include?(database))
|
|
361
|
+
end.keys
|
|
348
362
|
rescue => e
|
|
349
363
|
RailsAiContext.debug_fail(e, [], label: "models_for_table")
|
|
350
364
|
end
|
|
@@ -383,6 +397,11 @@ module RailsAiContext
|
|
|
383
397
|
lines
|
|
384
398
|
end
|
|
385
399
|
|
|
400
|
+
# The dump a static parse read, from its own note when nothing else names it.
|
|
401
|
+
private_class_method def self.dump_name(schema)
|
|
402
|
+
schema[:declared_in] || schema[:note].to_s[/\AParsed from (\S+)/, 1] || "db/schema.rb"
|
|
403
|
+
end
|
|
404
|
+
|
|
386
405
|
private_class_method def self.static_source_lines(schema)
|
|
387
406
|
lines = note_lines(schema)
|
|
388
407
|
lines << "**Dialect:** #{schema[:dialect]}" if schema[:dialect] && schema[:dialect] != "unknown"
|
|
@@ -398,7 +417,14 @@ module RailsAiContext
|
|
|
398
417
|
"#{connected_total}. Missing: #{missing.sort.first(5).join(', ')}" \
|
|
399
418
|
"#{missing.size > 5 ? " (+#{missing.size - 5} more)" : ""}. Run `rails db:migrate`._"
|
|
400
419
|
end
|
|
401
|
-
|
|
420
|
+
# A database that holds none of the tables the dump lists is created
|
|
421
|
+
# but not migrated, and the listing above is the dump's.
|
|
422
|
+
if schema[:connected_tables]&.zero? && (declared_total = Introspectors::SchemaConventions.table_count(schema[:tables] || {})).positive?
|
|
423
|
+
lines << "_#{dump_name(schema)} declares #{count_phrase(declared_total, "table")}; the connected database has none of them. Run `rails db:migrate`._"
|
|
424
|
+
end
|
|
425
|
+
if schema[:pending_unknown]
|
|
426
|
+
lines << "**Pending migrations:** not known: #{schema[:pending_unknown]}"
|
|
427
|
+
elsif schema[:pending_migrations].is_a?(Array)
|
|
402
428
|
pending = schema[:pending_migrations]
|
|
403
429
|
if pending.any?
|
|
404
430
|
lines << "**Pending migrations:** #{pending_phrase(pending)}"
|
|
@@ -437,7 +463,7 @@ module RailsAiContext
|
|
|
437
463
|
private_class_method def self.json_page_response(schema, tables, names, models = {})
|
|
438
464
|
# unread_calls is the static reader's note to validate; the booted tier has none.
|
|
439
465
|
page = names.to_h { |name| [ name, tables[name].is_a?(Hash) ? tables[name].except(:unread_calls) : tables[name] ] }
|
|
440
|
-
coverage = model_coverage(tables, models)
|
|
466
|
+
coverage = model_coverage(tables, models, schema)
|
|
441
467
|
json_response(schema.merge(tables: page, gem_owned_tables: coverage[:gems], tables_without_model_file: coverage[:unclaimed]))
|
|
442
468
|
end
|
|
443
469
|
|
|
@@ -445,9 +471,11 @@ module RailsAiContext
|
|
|
445
471
|
|
|
446
472
|
# Over every table, not the page: which ones no model file claims, and
|
|
447
473
|
# of those, which a gem the app bundles owns.
|
|
448
|
-
private_class_method def self.model_coverage(tables, models)
|
|
474
|
+
private_class_method def self.model_coverage(tables, models, schema = nil)
|
|
449
475
|
# A view or virtual table often has no model and is still read, so only tables count.
|
|
450
|
-
|
|
476
|
+
database = ("primary" if schema)
|
|
477
|
+
unclaimed = tables.keys.sort.select { |name| !tables[name][:kind] && models_for_table(name, models, schema: schema, database: database).empty? } -
|
|
478
|
+
habtm_join_tables(models).to_a
|
|
451
479
|
unclaimed -= declared_join_tables(models).to_a if unclaimed.any?
|
|
452
480
|
# A file the walk could not read claims no table, but it is still a model file.
|
|
453
481
|
unclaimed -= models.filter_map { |_, d| d[:table_name] || Introspectors::TableName.stem(d[:file]) if d.is_a?(Hash) && d[:error] && d[:file] }
|
|
@@ -455,8 +483,8 @@ module RailsAiContext
|
|
|
455
483
|
{ gems: gems, unclaimed: unclaimed - gems.values.flatten }
|
|
456
484
|
end
|
|
457
485
|
|
|
458
|
-
private_class_method def self.model_coverage_lines(tables, models)
|
|
459
|
-
coverage = model_coverage(tables, models)
|
|
486
|
+
private_class_method def self.model_coverage_lines(tables, models, schema = nil)
|
|
487
|
+
coverage = model_coverage(tables, models, schema)
|
|
460
488
|
lines = coverage[:gems].map { |gem, owned| "Tables the #{gem} gem owns: #{capped(owned)}" }
|
|
461
489
|
unclaimed = coverage[:unclaimed]
|
|
462
490
|
if unclaimed.any?
|
|
@@ -526,14 +554,19 @@ module RailsAiContext
|
|
|
526
554
|
end
|
|
527
555
|
|
|
528
556
|
# listed_as: the listing's name for a table asked for by its qualified name.
|
|
529
|
-
private_class_method def self.format_table_markdown(name, data, models, enum_types = nil, search_path = nil, listed_as: nil)
|
|
557
|
+
private_class_method def self.format_table_markdown(name, data, models, enum_types = nil, search_path = nil, listed_as: nil, schema: nil, database: nil)
|
|
530
558
|
columns = data[:columns] || []
|
|
531
559
|
# Always show Nullable and Default - agents need these for migrations and validations
|
|
532
560
|
has_defaults = columns.any? { |c| c.key?(:default) && !c[:default].nil? }
|
|
533
561
|
|
|
534
|
-
model_refs = models_for_table([ name, listed_as ], models)
|
|
562
|
+
model_refs = models_for_table([ name, listed_as ], models, schema: schema, database: database)
|
|
535
563
|
lines = [ "## #{RELATION_KINDS.fetch(data[:kind].to_s, "Table")}: #{name}", "" ]
|
|
536
564
|
lines << "**Models:** #{model_refs.join(', ')}" if model_refs.any?
|
|
565
|
+
# The listing marks these [encrypted]; a column the database holds as
|
|
566
|
+
# ciphertext reads differently from what the model hands back.
|
|
567
|
+
column_names = columns.map { |c| c[:name].to_s }
|
|
568
|
+
encrypted = model_refs.flat_map { |m| Array(models.dig(m, :encrypts)).map(&:to_s) }.uniq & column_names
|
|
569
|
+
lines << "**Encrypted:** #{encrypted.map { |c| "`#{c}`" }.join(', ')} (`encrypts`: ciphertext in the database)" if encrypted.any?
|
|
537
570
|
lines << "**Module:** #{data[:module]}" if data[:module]
|
|
538
571
|
lines << "**Primary key:** #{RailsAiContext::Introspectors::SchemaConventions.primary_key_label(data[:primary_key])}" if data[:primary_key]
|
|
539
572
|
lines << "**Comment:** #{data[:comment]}" if data[:comment]
|
|
@@ -597,34 +597,48 @@ module RailsAiContext
|
|
|
597
597
|
end
|
|
598
598
|
|
|
599
599
|
private_class_method def self.find_callers(class_name, real_root, own_file = nil)
|
|
600
|
-
callers =
|
|
601
|
-
|
|
600
|
+
callers, truncated = callers_of({ class_name => own_file }, real_root)
|
|
601
|
+
[ callers[class_name], truncated ]
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# The files that name each service, from one pass over the tree:
|
|
605
|
+
# analyze_feature asks for every service a feature matched at once. The
|
|
606
|
+
# services' own files are not read.
|
|
607
|
+
#
|
|
608
|
+
# @param services [Hash{String => String, nil}] class name => the file defining it
|
|
609
|
+
# @return [Array(Hash{String => Array<String>}, Boolean)] the callers of each, by path,
|
|
610
|
+
# and whether the scan stopped at MAX_CALLER_SCAN_FILES
|
|
611
|
+
def self.callers_of(services, real_root)
|
|
602
612
|
# A bare `include?` matched `Billing::Invoices::Create` inside
|
|
603
613
|
# `Workers::Billing::Invoices::CreateReminderWorker`, and the
|
|
604
614
|
# underscored-path skip dropped the one real caller, whose path
|
|
605
615
|
# contains the service's own path as a prefix.
|
|
606
|
-
|
|
616
|
+
references = services.keys.to_h { |name| [ name, /(?<![\w:])(?:::)?#{Regexp.escape(name)}(?![\w:])/ ] }
|
|
607
617
|
# A second file declaring the same short name under its own namespace
|
|
608
618
|
# is not a caller of this one, so the declaration is not a reference.
|
|
609
|
-
|
|
619
|
+
definitions = services.keys.to_h { |name| [ name, /\b(?:class|module)\s+(?:::)?#{Regexp.escape(name)}(?![\w:])/ ] }
|
|
610
620
|
|
|
611
621
|
# The paths first, so the ceiling is measured against the files there
|
|
612
622
|
# are rather than the files read: a tree of exactly the cap skips
|
|
613
623
|
# nothing and must not say it stopped.
|
|
614
|
-
paths =
|
|
615
|
-
paths
|
|
624
|
+
paths = caller_search_dirs(real_root).flat_map { |dir| safe_glob(dir, "**/*.rb", real_root) }.uniq
|
|
625
|
+
paths -= services.values.compact
|
|
616
626
|
truncated = paths.size > MAX_CALLER_SCAN_FILES
|
|
617
627
|
|
|
628
|
+
callers = services.keys.to_h { |name| [ name, [] ] }
|
|
618
629
|
paths.first(MAX_CALLER_SCAN_FILES).each do |real|
|
|
619
630
|
source = safe_read(real)
|
|
620
631
|
next unless source
|
|
621
|
-
next unless source.match?(reference)
|
|
622
|
-
next unless source.gsub(definition, "").match?(reference)
|
|
623
632
|
|
|
624
|
-
|
|
633
|
+
references.each do |name, reference|
|
|
634
|
+
next unless source.match?(reference)
|
|
635
|
+
next unless source.gsub(definitions[name], "").match?(reference)
|
|
636
|
+
|
|
637
|
+
callers[name] << real.sub("#{real_root}/", "")
|
|
638
|
+
end
|
|
625
639
|
end
|
|
626
640
|
|
|
627
|
-
[ callers.
|
|
641
|
+
[ callers.transform_values(&:sort), truncated ]
|
|
628
642
|
end
|
|
629
643
|
|
|
630
644
|
private_class_method def self.detect_common_pattern(stats)
|
|
@@ -258,13 +258,49 @@ module RailsAiContext
|
|
|
258
258
|
attrs << "data-#{html_name}-#{kebab}-value=\"...\""
|
|
259
259
|
end
|
|
260
260
|
|
|
261
|
+
# A class or an outlet the controller reads throws "Missing attribute"
|
|
262
|
+
# without its attribute, so the markup carries one for each.
|
|
263
|
+
(ctrl[:classes] || []).each do |name|
|
|
264
|
+
attrs << "data-#{html_name}-#{name.to_s.gsub(/([a-z\d])([A-Z])/, '\1-\2').downcase}-class=\"...\""
|
|
265
|
+
end
|
|
266
|
+
(ctrl[:outlets] || []).each do |name|
|
|
267
|
+
attrs << "data-#{html_name}-#{name}-outlet=\"...\""
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
used = view_action_events(html_name)
|
|
271
|
+
own_calls = Array(ctrl[:self_calls])
|
|
261
272
|
(ctrl[:actions] || []).each do |a|
|
|
262
|
-
|
|
273
|
+
events = used[a]
|
|
274
|
+
if events
|
|
275
|
+
# The event the app's own markup wires the action to: a text field's
|
|
276
|
+
# `input->`, not the `click->` every action used to get.
|
|
277
|
+
events.each { |event| attrs << "data-action=\"#{"#{event}->" if event}#{html_name}##{a}\"" }
|
|
278
|
+
elsif !own_calls.include?(a)
|
|
279
|
+
# No event names the element's default one: click for a button,
|
|
280
|
+
# input for a text field, submit for a form.
|
|
281
|
+
attrs << "data-action=\"#{html_name}##{a}\""
|
|
282
|
+
end
|
|
283
|
+
# A method no markup names and the controller calls itself is a helper, not an action.
|
|
263
284
|
end
|
|
264
285
|
|
|
265
286
|
attrs
|
|
266
287
|
end
|
|
267
288
|
|
|
289
|
+
# {action => the events the app's templates wire it to, nil for the
|
|
290
|
+
# element's default}, for one controller.
|
|
291
|
+
private_class_method def self.view_action_events(html_name)
|
|
292
|
+
root = rails_app.root.to_s
|
|
293
|
+
descriptor = /(?:([\w:.@+-]+)->)?(?<![\w-])#{Regexp.escape(html_name)}#(\w+)/
|
|
294
|
+
Introspectors::StimulusIntrospector.template_files(root).each_with_object({}) do |path, found|
|
|
295
|
+
raw = RailsAiContext::SafeFile.read(path) or next
|
|
296
|
+
next unless raw.include?("#{html_name}#")
|
|
297
|
+
|
|
298
|
+
raw.scan(descriptor) { |event, action| (found[action] ||= []) << event unless Array(found[action]).include?(event) }
|
|
299
|
+
end
|
|
300
|
+
rescue => e
|
|
301
|
+
RailsAiContext.debug_fail(e, {}, label: "view_action_events")
|
|
302
|
+
end
|
|
303
|
+
|
|
268
304
|
# Views are named by their path under their views root, anything else from the app root.
|
|
269
305
|
private_class_method def self.find_views_using(controller_name)
|
|
270
306
|
root = rails_app.root.to_s
|
|
@@ -230,6 +230,10 @@ module RailsAiContext
|
|
|
230
230
|
refused = refuse_unsafe_paths([ name ])
|
|
231
231
|
return refused if refused
|
|
232
232
|
|
|
233
|
+
# Longer than any file name, so no test file carries it, and each
|
|
234
|
+
# searched path it was put in echoed it whole: a 600 KB answer.
|
|
235
|
+
return empty_response("No test file found for #{echo_input(name)}.") if name_too_long?(name)
|
|
236
|
+
|
|
233
237
|
candidates = RailsAiContext::TestFramework.candidates(suite_root, type, snake, cached_context)
|
|
234
238
|
exercising = type == :controller ? exercising_tests(snake, candidates) : []
|
|
235
239
|
|
|
@@ -246,7 +250,7 @@ module RailsAiContext
|
|
|
246
250
|
listed = examples(content)
|
|
247
251
|
"# #{shown(rel)} (#{count_phrase(listed.size, "test")})\n\n#{listed.join("\n")}"
|
|
248
252
|
else
|
|
249
|
-
"# #{shown(rel)}\n\n```ruby\n#{content}\n```"
|
|
253
|
+
"# #{shown(rel)}\n\n```ruby\n#{RailsAiContext::Redaction.redact_source(content, path: rel)}\n```"
|
|
250
254
|
end
|
|
251
255
|
return text_response(answer + exercising_section(exercising, "## Also exercised by"))
|
|
252
256
|
end
|
|
@@ -264,7 +268,7 @@ module RailsAiContext
|
|
|
264
268
|
# A refused candidate was never read, so listing it reads as a search
|
|
265
269
|
# that happened. When every one was refused, the name is the answer.
|
|
266
270
|
if contained.empty?
|
|
267
|
-
return error_response("
|
|
271
|
+
return error_response("Path not allowed: the test file for #{name} leaves the app root " \
|
|
268
272
|
"or names a sensitive file.")
|
|
269
273
|
end
|
|
270
274
|
|
|
@@ -402,7 +406,17 @@ module RailsAiContext
|
|
|
402
406
|
lines << ""
|
|
403
407
|
lines << "RSpec.describe ModelName, type: :model do"
|
|
404
408
|
lines << " describe \"validations\" do"
|
|
405
|
-
|
|
409
|
+
# validate_presence_of is shoulda-matchers', a NoMethodError in an
|
|
410
|
+
# app that does not bundle it; rails_generate_test asks the same.
|
|
411
|
+
if RailsAiContext::GemLock.for(rails_app.root.to_s).present?("shoulda-matchers")
|
|
412
|
+
lines << " it { is_expected.to validate_presence_of(:field) }"
|
|
413
|
+
else
|
|
414
|
+
lines << " it \"requires field\" do"
|
|
415
|
+
lines << " record = ModelName.new"
|
|
416
|
+
lines << " expect(record).not_to be_valid"
|
|
417
|
+
lines << " expect(record.errors[:field]).to include(\"can't be blank\")"
|
|
418
|
+
lines << " end"
|
|
419
|
+
end
|
|
406
420
|
lines << " end"
|
|
407
421
|
lines << ""
|
|
408
422
|
lines << " describe \"#method_name\" do"
|
|
@@ -628,7 +642,7 @@ module RailsAiContext
|
|
|
628
642
|
|
|
629
643
|
relationships
|
|
630
644
|
rescue => e
|
|
631
|
-
{}
|
|
645
|
+
RailsAiContext.debug_fail(e, {}, label: "extract_fixture_relationships")
|
|
632
646
|
end
|
|
633
647
|
end
|
|
634
648
|
end
|