rails-ai-context 5.30.2 → 5.32.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 +868 -0
- data/CONTEXT.md +7 -3
- data/README.md +1 -1
- data/docs/ARCHITECTURE.md +3 -3
- data/docs/CLI.md +1 -1
- data/docs/COMPATIBILITY.md +50 -14
- data/docs/CONFIGURATION.md +4 -1
- data/docs/FAQ.md +1 -1
- data/docs/GUIDE.md +14 -8
- data/docs/INTROSPECTORS.md +50 -23
- data/docs/SECURITY.md +56 -1
- data/docs/TOOLS.md +58 -12
- data/lib/generators/rails_ai_context/install/install_generator.rb +1 -0
- data/lib/rails_ai_context/action_filters.rb +121 -48
- data/lib/rails_ai_context/app_commands.rb +2 -2
- data/lib/rails_ai_context/app_kind.rb +65 -8
- data/lib/rails_ai_context/ast_cache.rb +53 -18
- data/lib/rails_ai_context/base_mixins.rb +46 -16
- data/lib/rails_ai_context/boot_manager.rb +5 -1
- data/lib/rails_ai_context/cli/entry_boot.rb +17 -2
- data/lib/rails_ai_context/cli/tool_runner.rb +15 -1
- data/lib/rails_ai_context/concern_macros.rb +791 -111
- data/lib/rails_ai_context/concern_membership.rb +25 -5
- data/lib/rails_ai_context/concern_paths.rb +63 -17
- data/lib/rails_ai_context/config_yaml.rb +40 -0
- data/lib/rails_ai_context/configuration.rb +3 -3
- data/lib/rails_ai_context/data/docs/index.json +324 -0
- data/lib/rails_ai_context/database_yml.rb +136 -0
- data/lib/rails_ai_context/doctor.rb +30 -37
- data/lib/rails_ai_context/erb_source.rb +40 -0
- data/lib/rails_ai_context/fingerprinter.rb +48 -22
- data/lib/rails_ai_context/fixture_keys.rb +42 -1
- data/lib/rails_ai_context/gem_lock.rb +275 -47
- data/lib/rails_ai_context/git_ignore.rb +13 -11
- data/lib/rails_ai_context/hydrators/hydration_formatter.rb +8 -2
- data/lib/rails_ai_context/hydrators/schema_hint_builder.rb +10 -5
- data/lib/rails_ai_context/introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +27 -19
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -8
- data/lib/rails_ai_context/introspectors/action_resolver.rb +38 -26
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +56 -0
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +98 -22
- data/lib/rails_ai_context/introspectors/admin_resources.rb +101 -0
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +64 -0
- data/lib/rails_ai_context/introspectors/apartment_config.rb +47 -0
- data/lib/rails_ai_context/introspectors/api_introspector.rb +125 -26
- data/lib/rails_ai_context/introspectors/asset_pipeline_introspector.rb +24 -19
- data/lib/rails_ai_context/introspectors/ast_walk.rb +18 -0
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +71 -18
- data/lib/rails_ai_context/introspectors/autoload_introspector.rb +62 -15
- data/lib/rails_ai_context/introspectors/base.rb +38 -0
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +349 -39
- data/lib/rails_ai_context/introspectors/component_introspector.rb +26 -14
- data/lib/rails_ai_context/introspectors/config_introspector.rb +22 -9
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +1 -4
- data/lib/rails_ai_context/introspectors/controller_filters.rb +335 -31
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +315 -129
- data/lib/rails_ai_context/introspectors/controller_settings.rb +182 -0
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +18 -15
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/declared_constant.rb +108 -23
- data/lib/rails_ai_context/introspectors/eager_load.rb +12 -7
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +13 -5
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +62 -17
- data/lib/rails_ai_context/introspectors/env_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/env_references.rb +21 -4
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +37 -22
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/gemfile_gems.rb +53 -5
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +109 -0
- data/lib/rails_ai_context/introspectors/helper_names.rb +12 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +101 -46
- data/lib/rails_ai_context/introspectors/includers.rb +6 -1
- data/lib/rails_ai_context/introspectors/initializer_introspector.rb +2 -5
- data/lib/rails_ai_context/introspectors/job_introspector.rb +548 -103
- data/lib/rails_ai_context/introspectors/listener_registration.rb +11 -1
- data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +51 -4
- data/lib/rails_ai_context/introspectors/listeners/autoload_ignore_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/autoload_paths_listener.rb +4 -47
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +80 -14
- data/lib/rails_ai_context/introspectors/listeners/branch_conditions.rb +156 -0
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +85 -23
- data/lib/rails_ai_context/introspectors/listeners/class_definition_listener.rb +35 -6
- data/lib/rails_ai_context/introspectors/listeners/component_structure_listener.rb +30 -2
- data/lib/rails_ai_context/introspectors/listeners/conditional_macro_listener.rb +19 -0
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +255 -10
- data/lib/rails_ai_context/introspectors/listeners/constant_reference_listener.rb +34 -0
- data/lib/rails_ai_context/introspectors/listeners/constructor_macro_listener.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/definition_file_paths_listener.rb +66 -0
- data/lib/rails_ai_context/introspectors/listeners/enums_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +31 -0
- data/lib/rails_ai_context/introspectors/listeners/filter_macro_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/fixture_paths_listener.rb +62 -0
- data/lib/rails_ai_context/introspectors/listeners/gemfile_dsl_listener.rb +42 -1
- data/lib/rails_ai_context/introspectors/listeners/generic_macro_listener.rb +28 -2
- data/lib/rails_ai_context/introspectors/listeners/grape_api_listener.rb +137 -0
- data/lib/rails_ai_context/introspectors/listeners/http_client_call_listener.rb +50 -0
- data/lib/rails_ai_context/introspectors/listeners/i18n_load_path_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/literal_paths.rb +123 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +248 -4
- data/lib/rails_ai_context/introspectors/listeners/mailbox_routing_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/method_call_listener.rb +7 -1
- data/lib/rails_ai_context/introspectors/listeners/methods_listener.rb +352 -69
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +1 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +35 -26
- data/lib/rails_ai_context/introspectors/listeners/mixins_listener.rb +62 -29
- data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +8 -0
- data/lib/rails_ai_context/introspectors/listeners/namespaced_roots_listener.rb +35 -0
- data/lib/rails_ai_context/introspectors/listeners/nested_constants_listener.rb +32 -0
- data/lib/rails_ai_context/introspectors/listeners/owner_scope.rb +67 -0
- data/lib/rails_ai_context/introspectors/listeners/preview_paths_listener.rb +16 -7
- data/lib/rails_ai_context/introspectors/listeners/proc_literal_listener.rb +42 -0
- data/lib/rails_ai_context/introspectors/listeners/queue_assignment_listener.rb +54 -0
- data/lib/rails_ai_context/introspectors/listeners/rake_task_dsl_listener.rb +21 -8
- data/lib/rails_ai_context/introspectors/listeners/route_files_listener.rb +4 -11
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +461 -65
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +76 -14
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +40 -1
- data/lib/rails_ai_context/introspectors/listeners/validations_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/view_paths_listener.rb +37 -0
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +47 -3
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +4 -3
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +72 -12
- data/lib/rails_ai_context/introspectors/migration_replay.rb +110 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +708 -214
- data/lib/rails_ai_context/introspectors/module_aliases.rb +11 -3
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +9 -57
- data/lib/rails_ai_context/introspectors/observability_introspector.rb +5 -8
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +28 -8
- data/lib/rails_ai_context/introspectors/rake_task_introspector.rb +118 -51
- data/lib/rails_ai_context/introspectors/recurring_schedules.rb +150 -0
- data/lib/rails_ai_context/introspectors/rendered_record.rb +87 -0
- data/lib/rails_ai_context/introspectors/retry_policy.rb +8 -5
- data/lib/rails_ai_context/introspectors/route_introspector.rb +153 -42
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +127 -40
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +80 -0
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +346 -112
- data/lib/rails_ai_context/introspectors/schema_reader.rb +69 -29
- data/lib/rails_ai_context/introspectors/service_classes.rb +21 -15
- data/lib/rails_ai_context/introspectors/source_calls.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_introspector.rb +17 -2
- data/lib/rails_ai_context/introspectors/source_scan.rb +174 -26
- data/lib/rails_ai_context/introspectors/sqlite_virtual_tables.rb +28 -0
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +16 -5
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +372 -98
- data/lib/rails_ai_context/introspectors/superclass_chain.rb +14 -15
- data/lib/rails_ai_context/introspectors/table_name.rb +46 -45
- data/lib/rails_ai_context/introspectors/test_introspector.rb +289 -78
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +10 -5
- data/lib/rails_ai_context/introspectors/view_introspector.rb +5 -40
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +12 -10
- data/lib/rails_ai_context/method_name.rb +31 -0
- data/lib/rails_ai_context/migration_status.rb +5 -5
- data/lib/rails_ai_context/output_guard.rb +6 -4
- data/lib/rails_ai_context/package_json.rb +96 -5
- data/lib/rails_ai_context/path_resolver.rb +170 -15
- data/lib/rails_ai_context/payload.rb +48 -0
- data/lib/rails_ai_context/pending_migrations.rb +15 -18
- data/lib/rails_ai_context/portable_path.rb +76 -4
- data/lib/rails_ai_context/redaction.rb +8 -2
- data/lib/rails_ai_context/route_coverage.rb +5 -1
- data/lib/rails_ai_context/safe_file.rb +4 -1
- data/lib/rails_ai_context/safe_path.rb +61 -10
- data/lib/rails_ai_context/schema_hint.rb +8 -2
- data/lib/rails_ai_context/schema_version.rb +7 -9
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +13 -4
- data/lib/rails_ai_context/serializers/section_facts.rb +5 -1
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +2 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +7 -4
- data/lib/rails_ai_context/server.rb +26 -7
- data/lib/rails_ai_context/test_framework.rb +32 -5
- data/lib/rails_ai_context/tools/analyze_feature.rb +22 -10
- data/lib/rails_ai_context/tools/base_tool.rb +69 -30
- data/lib/rails_ai_context/tools/diagnose.rb +1 -1
- data/lib/rails_ai_context/tools/generate_test.rb +8 -9
- data/lib/rails_ai_context/tools/get_active_support.rb +19 -4
- data/lib/rails_ai_context/tools/get_api.rb +17 -11
- data/lib/rails_ai_context/tools/get_autoload.rb +10 -5
- data/lib/rails_ai_context/tools/get_callbacks.rb +65 -14
- data/lib/rails_ai_context/tools/get_component_catalog.rb +7 -4
- data/lib/rails_ai_context/tools/get_concern.rb +31 -18
- data/lib/rails_ai_context/tools/get_config.rb +52 -18
- data/lib/rails_ai_context/tools/get_controllers.rb +57 -13
- data/lib/rails_ai_context/tools/get_conventions.rb +44 -22
- data/lib/rails_ai_context/tools/get_engines.rb +2 -0
- data/lib/rails_ai_context/tools/get_env.rb +209 -52
- data/lib/rails_ai_context/tools/get_env_config.rb +38 -7
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +56 -7
- data/lib/rails_ai_context/tools/get_helper_methods.rb +136 -26
- data/lib/rails_ai_context/tools/get_job_pattern.rb +143 -87
- data/lib/rails_ai_context/tools/get_mailers.rb +59 -2
- data/lib/rails_ai_context/tools/get_model_details.rb +178 -28
- data/lib/rails_ai_context/tools/get_partial_interface.rb +101 -62
- data/lib/rails_ai_context/tools/get_routes.rb +35 -9
- data/lib/rails_ai_context/tools/get_schema.rb +160 -61
- data/lib/rails_ai_context/tools/get_service_pattern.rb +121 -25
- data/lib/rails_ai_context/tools/get_stimulus.rb +1 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +152 -82
- data/lib/rails_ai_context/tools/get_view.rb +37 -20
- data/lib/rails_ai_context/tools/migration_advisor.rb +64 -24
- data/lib/rails_ai_context/tools/onboard.rb +174 -88
- data/lib/rails_ai_context/tools/performance_check.rb +5 -13
- data/lib/rails_ai_context/tools/query.rb +122 -28
- data/lib/rails_ai_context/tools/read_logs.rb +45 -17
- data/lib/rails_ai_context/tools/runtime_info.rb +1 -1
- data/lib/rails_ai_context/tools/safe_call.rb +20 -15
- data/lib/rails_ai_context/tools/search_code.rb +114 -40
- data/lib/rails_ai_context/tools/search_docs.rb +22 -11
- data/lib/rails_ai_context/tools/security_scan.rb +51 -4
- data/lib/rails_ai_context/tools/validate.rb +21 -4
- data/lib/rails_ai_context/tools/validate_semantics.rb +18 -6
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +2 -2
- data/lib/rails_ai_context/view_file.rb +53 -3
- data/lib/rails_ai_context.rb +10 -2
- data/server.json +1 -1
- metadata +34 -3
|
@@ -65,10 +65,12 @@ module RailsAiContext
|
|
|
65
65
|
# "Post" and "Admin::ActionLog" are model names here, and the model
|
|
66
66
|
# tier knows the table each of them reads.
|
|
67
67
|
table_as_table = RailsAiContext::Introspectors::TableName.for_model_name(table, models_data)
|
|
68
|
-
table_key =
|
|
68
|
+
_, table_key, table_data = Payload.schema_tables(schema).find { |_, k, _|
|
|
69
69
|
k.downcase == table_down || k == table_as_table || k == table.underscore
|
|
70
|
-
}
|
|
71
|
-
|
|
70
|
+
}
|
|
71
|
+
databases = table_data ? Payload.schema_databases(schema, table_key) : []
|
|
72
|
+
database = databases.join(", ") unless databases == [ "primary" ]
|
|
73
|
+
table_key ||= table
|
|
72
74
|
unless table_data
|
|
73
75
|
# A table db/schema.rb declares and the connection does not have
|
|
74
76
|
# is not a misspelling: the migration that adds it has not run
|
|
@@ -80,18 +82,35 @@ module RailsAiContext
|
|
|
80
82
|
declared = table_key.to_s.underscore
|
|
81
83
|
if declared_not_connected(schema).include?(declared)
|
|
82
84
|
return text_response(
|
|
83
|
-
"Table '#{declared}' is declared in db/schema.rb and missing from the connected database. " \
|
|
85
|
+
"Table '#{declared}' is declared in #{schema[:declared_in] || "db/schema.rb"} and missing from the connected database. " \
|
|
84
86
|
"#{pending_for_table(schema, declared)}" \
|
|
85
87
|
"Run `rails db:migrate`, or pass `--no-boot` to read the declaration instead."
|
|
86
88
|
)
|
|
87
89
|
end
|
|
88
90
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
recovery = "Call rails_get_schema(detail:\"summary\") to see all tables"
|
|
92
|
+
# Rails' own schema.rb dumper writes no view; only a gem such as scenic adds them.
|
|
93
|
+
if [ schema[:adapter], schema[:adapter_source] ].include?("static_parse")
|
|
94
|
+
recovery += ". Without a connection a view is listed only when the dump records it: structure.sql does, schema.rb only through a gem such as scenic"
|
|
95
|
+
end
|
|
96
|
+
return not_found_response("Table", table, Payload.schema_tables(schema).map { |_, name, _| name }.uniq.sort, recovery_tool: recovery)
|
|
97
|
+
end
|
|
98
|
+
# Databases holding the same table read as one; ones that differ each show their own.
|
|
99
|
+
groups = Payload.schema_tables(schema).select { |_, name, _| name == table_key }
|
|
100
|
+
.group_by { |_, _, data| data }.map { |data, rows| [ rows.map { |db, _, _| db || "primary" }, data ] }
|
|
101
|
+
if groups.size > 1
|
|
102
|
+
return json_response(table: table_key, databases: groups.flat_map { |dbs, data| dbs.map { |db| [ db, data.except(:unread_calls) ] } }.to_h) if format == "json"
|
|
103
|
+
|
|
104
|
+
output = groups.map do |dbs, data|
|
|
105
|
+
in_db = models_data.select { |_, model| model.is_a?(Hash) && model[:table_name] == table_key && Payload.model_databases(schema, model).intersect?(dbs) }
|
|
106
|
+
format_table_markdown(table_key, data, in_db, schema[:enum_types]).sub("\n\n", "\n\n**Database:** #{dbs.join(", ")}\n")
|
|
107
|
+
end.join("\n\n")
|
|
108
|
+
else
|
|
109
|
+
return json_response(table_data.except(:unread_calls).merge({ database: database }.compact)) if format == "json"
|
|
110
|
+
|
|
111
|
+
output = format_table_markdown(table_key, table_data, models_data, schema[:enum_types])
|
|
112
|
+
output = output.sub("\n\n", "\n\n**Database:** #{database}\n") if database
|
|
91
113
|
end
|
|
92
|
-
return json_response(table_data.except(:unread_calls)) if format == "json"
|
|
93
|
-
|
|
94
|
-
output = format_table_markdown(table_key, table_data, models_data)
|
|
95
114
|
# Cross-reference hint for AI: suggest next tool call
|
|
96
115
|
model_refs = models_for_table(table_key, models_data)
|
|
97
116
|
if model_refs.any?
|
|
@@ -110,14 +129,14 @@ module RailsAiContext
|
|
|
110
129
|
return text_response("No tables at offset #{page[:offset]}. Total: #{total}. Use `offset:0` to start over.")
|
|
111
130
|
end
|
|
112
131
|
|
|
113
|
-
lines = [ "# Schema Summary (#{
|
|
132
|
+
lines = [ "# Schema Summary (#{Introspectors::SchemaConventions.relations_phrase(tables)})", "" ]
|
|
114
133
|
lines << "**Adapter:** #{adapter_label(ctx)}" if schema[:adapter]
|
|
115
134
|
lines.concat(static_source_lines(schema))
|
|
116
135
|
paginated.each do |name|
|
|
117
136
|
data = tables[name]
|
|
118
137
|
col_count = data[:columns]&.size || 0
|
|
119
138
|
idx_count = data[:indexes]&.size || 0
|
|
120
|
-
lines << "- **#{name}
|
|
139
|
+
lines << "- **#{name}**#{relation_suffix(data)} - #{count_phrase(col_count, "column")}, #{count_phrase(idx_count, "index", plural: "indexes")}"
|
|
121
140
|
end
|
|
122
141
|
coverage = model_coverage_lines(tables, models_data)
|
|
123
142
|
lines.concat([ "" ] + coverage) if coverage.any?
|
|
@@ -135,11 +154,11 @@ module RailsAiContext
|
|
|
135
154
|
paginated = page[:items]
|
|
136
155
|
return json_page_response(schema, tables, paginated, models_data) if format == "json"
|
|
137
156
|
|
|
138
|
-
if paginated.empty?
|
|
157
|
+
if paginated.empty? && total > 0
|
|
139
158
|
return text_response("No tables at offset #{page[:offset]}. Total tables: #{total}. Use `offset:0` to start from the beginning.")
|
|
140
159
|
end
|
|
141
160
|
|
|
142
|
-
lines = [ "# Schema (#{
|
|
161
|
+
lines = [ "# Schema (#{Introspectors::SchemaConventions.relations_phrase(tables)}, showing #{paginated.size})", "" ]
|
|
143
162
|
lines.concat(static_source_lines(schema))
|
|
144
163
|
paginated.each do |name|
|
|
145
164
|
data = tables[name]
|
|
@@ -201,7 +220,7 @@ module RailsAiContext
|
|
|
201
220
|
end
|
|
202
221
|
# A clause can hold a column list, so clauses part with a semicolon.
|
|
203
222
|
hint_str = hints.any? ? " [#{hints.join('; ')}]" : ""
|
|
204
|
-
"#{c[:name]
|
|
223
|
+
"#{[ c[:name], c[:type] ].compact.join(':')}#{hint_str}"
|
|
205
224
|
end.join(", ")
|
|
206
225
|
# Inline model info so AI doesn't need a separate get_model_details call
|
|
207
226
|
# Every model on the table, richest first: an STI child or a
|
|
@@ -221,15 +240,16 @@ module RailsAiContext
|
|
|
221
240
|
else
|
|
222
241
|
""
|
|
223
242
|
end
|
|
224
|
-
|
|
225
|
-
lines <<
|
|
243
|
+
key = data[:primary_key] && data[:primary_key] != "id" ? " (primary key: #{RailsAiContext::Introspectors::SchemaConventions.primary_key_label(data[:primary_key])})" : ""
|
|
244
|
+
lines << "### #{name}#{key}#{relation_suffix(data)}#{model_info}"
|
|
245
|
+
lines << cols unless cols.empty? && data[:columns].blank?
|
|
226
246
|
lines << ""
|
|
227
247
|
end
|
|
228
248
|
|
|
229
249
|
coverage = model_coverage_lines(tables, models_data)
|
|
230
250
|
lines.concat(coverage + [ "" ]) if coverage.any?
|
|
231
251
|
lines.concat(secondary_databases_lines(schema))
|
|
232
|
-
lines << "_Use `detail:\"summary\"` for all #{
|
|
252
|
+
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]
|
|
233
253
|
text_response(lines.join("\n"))
|
|
234
254
|
|
|
235
255
|
when "full"
|
|
@@ -241,10 +261,10 @@ module RailsAiContext
|
|
|
241
261
|
return text_response("No tables at offset #{page[:offset]}. Total: #{total}. Use `offset:0` to start over.")
|
|
242
262
|
end
|
|
243
263
|
|
|
244
|
-
lines = [ "# Schema Full Detail (#{paginated.size} of #{
|
|
264
|
+
lines = [ "# Schema Full Detail (#{paginated.size} of #{Introspectors::SchemaConventions.relations_phrase(tables)})", "" ]
|
|
245
265
|
lines.concat(note_lines(schema))
|
|
246
266
|
paginated.each do |name|
|
|
247
|
-
lines << format_table_markdown(name, tables[name], models_data)
|
|
267
|
+
lines << format_table_markdown(name, tables[name], models_data, schema[:enum_types])
|
|
248
268
|
lines << ""
|
|
249
269
|
end
|
|
250
270
|
coverage = model_coverage_lines(tables, models_data)
|
|
@@ -355,12 +375,14 @@ module RailsAiContext
|
|
|
355
375
|
# schema version recorded by the dump, and migration files it doesn't
|
|
356
376
|
# cover - the static-tier stand-ins for a live connection's answers.
|
|
357
377
|
private_class_method def self.note_lines(schema)
|
|
358
|
-
schema[:note].to_s.empty? ? [] : [ "_#{schema[:note]}_" ]
|
|
378
|
+
lines = schema[:note].to_s.empty? ? [] : [ "_#{schema[:note]}_" ]
|
|
379
|
+
lines << "**Extensions:** #{schema[:extensions].join(', ')}" if schema[:extensions]&.any?
|
|
380
|
+
lines
|
|
359
381
|
end
|
|
360
382
|
|
|
361
383
|
private_class_method def self.static_source_lines(schema)
|
|
362
384
|
lines = note_lines(schema)
|
|
363
|
-
lines << "**Dialect:** #{schema[:dialect]}
|
|
385
|
+
lines << "**Dialect:** #{schema[:dialect]}" if schema[:dialect] && schema[:dialect] != "unknown"
|
|
364
386
|
lines << "**Schema version:** #{schema[:schema_version]}" if schema[:schema_version]
|
|
365
387
|
# The header pairs a live table count with the version stamp read off
|
|
366
388
|
# db/schema.rb, and nothing joined the two: at that migration the app
|
|
@@ -368,16 +390,15 @@ module RailsAiContext
|
|
|
368
390
|
missing = declared_not_connected(schema)
|
|
369
391
|
if missing.any?
|
|
370
392
|
declared_total = Array(schema[:declared_tables]).size
|
|
371
|
-
|
|
372
|
-
|
|
393
|
+
connected_total = Introspectors::SchemaConventions.table_count(schema[:tables] || {})
|
|
394
|
+
lines << "_#{schema[:declared_in] || "db/schema.rb"} declares #{count_phrase(declared_total, "table")}; the connected database has " \
|
|
395
|
+
"#{connected_total}. Missing: #{missing.sort.first(5).join(', ')}" \
|
|
373
396
|
"#{missing.size > 5 ? " (+#{missing.size - 5} more)" : ""}. Run `rails db:migrate`._"
|
|
374
397
|
end
|
|
375
398
|
if schema[:pending_migrations].is_a?(Array)
|
|
376
399
|
pending = schema[:pending_migrations]
|
|
377
400
|
if pending.any?
|
|
378
|
-
|
|
379
|
-
more = pending.size > 5 ? " (+#{pending.size - 5} more)" : ""
|
|
380
|
-
lines << "**Pending migrations:** #{pending.size} - #{shown}#{more}"
|
|
401
|
+
lines << "**Pending migrations:** #{pending_phrase(pending)}"
|
|
381
402
|
elsif schema[:schema_version]
|
|
382
403
|
lines << "**Pending migrations:** none"
|
|
383
404
|
end
|
|
@@ -395,12 +416,18 @@ module RailsAiContext
|
|
|
395
416
|
|
|
396
417
|
lines = [ "", "## Secondary databases", "" ]
|
|
397
418
|
secondary.each do |name, db|
|
|
398
|
-
|
|
399
|
-
|
|
419
|
+
pending = Array(db[:pending_migrations])
|
|
420
|
+
pending_text = pending.any? ? "; pending migrations: #{pending_phrase(pending)}" : ""
|
|
421
|
+
lines << "- **#{name}**: #{Introspectors::SchemaConventions.relations_phrase(db[:tables])} (#{db[:tables].keys.join(', ')}) - #{db[:note]}#{pending_text}"
|
|
400
422
|
end
|
|
401
423
|
lines
|
|
402
424
|
end
|
|
403
425
|
|
|
426
|
+
private_class_method def self.pending_phrase(pending)
|
|
427
|
+
more = pending.size > 5 ? " (+#{pending.size - 5} more)" : ""
|
|
428
|
+
"#{pending.size} - #{pending.first(5).map { |m| m[:version] }.join(', ')}#{more}"
|
|
429
|
+
end
|
|
430
|
+
|
|
404
431
|
# One JSON shape for every detail level: the schema as introspected,
|
|
405
432
|
# with :tables cut down to the page the same pagination produced for
|
|
406
433
|
# markdown. `detail` still decides how many tables a page holds.
|
|
@@ -416,7 +443,8 @@ module RailsAiContext
|
|
|
416
443
|
# Over every table, not the page: which ones no model file claims, and
|
|
417
444
|
# of those, which a gem the app bundles owns.
|
|
418
445
|
private_class_method def self.model_coverage(tables, models)
|
|
419
|
-
|
|
446
|
+
# A view or virtual table often has no model and is still read, so only tables count.
|
|
447
|
+
unclaimed = tables.keys.sort.select { |name| !tables[name][:kind] && models_for_table(name, models).empty? } - habtm_join_tables(models).to_a
|
|
420
448
|
unclaimed -= declared_join_tables(models).to_a if unclaimed.any?
|
|
421
449
|
# A file the walk could not read claims no table, but it is still a model file.
|
|
422
450
|
unclaimed -= models.filter_map { |_, d| d[:table_name] || Introspectors::TableName.stem(d[:file]) if d.is_a?(Hash) && d[:error] && d[:file] }
|
|
@@ -446,78 +474,149 @@ module RailsAiContext
|
|
|
446
474
|
"#{names.first(COVERAGE_CAP).join(', ')}, and #{names.size - COVERAGE_CAP} more"
|
|
447
475
|
end
|
|
448
476
|
|
|
449
|
-
|
|
477
|
+
# The type as a migration declares it: `decimal(10,2)`, `string, limit: 20`.
|
|
478
|
+
private_class_method def self.column_type_label(col)
|
|
479
|
+
label = col[:type].to_s
|
|
480
|
+
sizes = [ col[:precision], col[:scale] ].compact
|
|
481
|
+
label += "(#{sizes.join(',')})" if col[:precision]
|
|
482
|
+
label += "[]" if col[:array]
|
|
483
|
+
label += ", limit: #{col[:limit]}" if col[:limit]
|
|
484
|
+
label += ", size: #{col[:size]}" if col[:size]
|
|
485
|
+
label += ", unsigned" if col[:unsigned]
|
|
486
|
+
label += ", collation: #{col[:collation]}" if col[:collation]
|
|
487
|
+
label += ", enum_type: #{col[:enum_type]}" if col[:enum_type]
|
|
488
|
+
label
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Options are joined by semicolons because a value can list columns.
|
|
492
|
+
private_class_method def self.index_options_text(idx)
|
|
493
|
+
parts = []
|
|
494
|
+
parts << "using: #{idx[:using]}" if idx[:using]
|
|
495
|
+
parts << "type: #{idx[:type]}" if idx[:type]
|
|
496
|
+
parts << "include: #{idx[:include].join(', ')}" if idx[:include]
|
|
497
|
+
%i[order opclass length].each do |key|
|
|
498
|
+
parts << "#{key}: #{idx[key].map { |column, value| "#{column} #{value}" }.join(', ')}" if idx[key]
|
|
499
|
+
end
|
|
500
|
+
parts << "nulls not distinct" if idx[:nulls_not_distinct]
|
|
501
|
+
parts.any? ? " - #{parts.join('; ')}" : ""
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
RELATION_KINDS = { "view" => "View", "materialized_view" => "Materialized view", "virtual_table" => "Virtual table" }.freeze
|
|
505
|
+
|
|
506
|
+
# What a listed name is when it is not a plain table.
|
|
507
|
+
private_class_method def self.relation_suffix(data)
|
|
508
|
+
label = case data[:kind]
|
|
509
|
+
when "virtual_table" then "#{data[:module]} virtual table".strip
|
|
510
|
+
when "view", "materialized_view" then RELATION_KINDS[data[:kind]].downcase
|
|
511
|
+
else "not dumped" if data[:not_dumped]
|
|
512
|
+
end
|
|
513
|
+
label ? " (#{label})" : ""
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
private_class_method def self.index_lines(data)
|
|
517
|
+
return [] unless data[:indexes]&.any?
|
|
518
|
+
|
|
519
|
+
[ "", "### Indexes" ] + data[:indexes].map do |idx|
|
|
520
|
+
unique = idx[:unique] ? " (unique)" : ""
|
|
521
|
+
"- `#{idx[:name]}` on (#{Array(idx[:columns]).join(', ')})#{unique}#{RailsAiContext::Introspectors::SchemaConventions.where_clause(idx[:where])}#{index_options_text(idx)}"
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
private_class_method def self.format_table_markdown(name, data, models, enum_types = nil)
|
|
450
526
|
columns = data[:columns] || []
|
|
451
527
|
# Always show Nullable and Default - agents need these for migrations and validations
|
|
452
528
|
has_defaults = columns.any? { |c| c.key?(:default) && !c[:default].nil? }
|
|
453
529
|
|
|
454
530
|
model_refs = models_for_table(name, models)
|
|
455
|
-
lines = [ "## Table: #{name}", "" ]
|
|
531
|
+
lines = [ "## #{RELATION_KINDS.fetch(data[:kind].to_s, "Table")}: #{name}", "" ]
|
|
456
532
|
lines << "**Models:** #{model_refs.join(', ')}" if model_refs.any?
|
|
533
|
+
lines << "**Module:** #{data[:module]}" if data[:module]
|
|
534
|
+
lines << "**Primary key:** #{RailsAiContext::Introspectors::SchemaConventions.primary_key_label(data[:primary_key])}" if data[:primary_key]
|
|
535
|
+
lines << "**Comment:** #{data[:comment]}" if data[:comment]
|
|
536
|
+
if data[:not_dumped]
|
|
537
|
+
lines << "The schema dumper could not describe this table (#{data[:not_dumped]}), so the dump holds no columns for it."
|
|
538
|
+
elsif data[:sql] && columns.empty?
|
|
539
|
+
lines << "A view's columns are read from the database, and this answer has no connection: boot the app to list them."
|
|
540
|
+
end
|
|
541
|
+
# A table right after a paragraph line would read as part of it.
|
|
542
|
+
lines << "" if lines.size > 2
|
|
543
|
+
definition = data[:sql] ? [ "### Definition", "```sql", data[:sql], "```" ] : []
|
|
544
|
+
if columns.empty? && (data[:sql] || data[:not_dumped])
|
|
545
|
+
indexes = index_lines(data)
|
|
546
|
+
return lines.concat(indexes.any? ? indexes.drop(1) + [ "" ] : []).concat(definition).join("\n").rstrip
|
|
547
|
+
end
|
|
457
548
|
|
|
458
|
-
|
|
459
|
-
|
|
549
|
+
has_comments = columns.any? { |c| c[:comment] && !c[:comment].to_s.empty? }
|
|
550
|
+
# A virtual table's module, not a column type, decides what its columns hold.
|
|
551
|
+
typed = columns.any? { |c| c[:type] }
|
|
552
|
+
header = typed ? "| Column | Type | Null" : "| Column"
|
|
553
|
+
sep = typed ? "|--------|------|-----" : "|--------"
|
|
460
554
|
header += " | Default" if has_defaults
|
|
461
555
|
sep += "-|---------" if has_defaults
|
|
556
|
+
header += " | Comment" if has_comments
|
|
557
|
+
sep += "-|---------" if has_comments
|
|
462
558
|
lines << "#{header} |" << "#{sep}|"
|
|
463
559
|
|
|
464
|
-
has_comments = columns.any? { |c| c[:comment] && !c[:comment].to_s.empty? }
|
|
465
|
-
|
|
466
560
|
columns.each do |col|
|
|
467
561
|
nullable = col.key?(:null) ? (col[:null] ? "yes" : "**NO**") : "yes"
|
|
468
|
-
|
|
469
|
-
line = "| #{col[:name]} | #{col_type} | #{nullable}"
|
|
562
|
+
line = typed ? "| #{col[:name]} | #{column_type_label(col)} | #{nullable}" : "| #{col[:name]}"
|
|
470
563
|
if has_defaults
|
|
471
564
|
default_val = col[:default]
|
|
472
565
|
display_default = default_val == "" ? '""' : default_val
|
|
473
566
|
line += " | #{display_default}"
|
|
474
567
|
end
|
|
568
|
+
line += " | #{col[:comment].to_s.gsub('|', '\\|').gsub(/\s*\n\s*/, ' ')}" if has_comments
|
|
475
569
|
lines << "#{line} |"
|
|
476
|
-
lines << " _#{col[:comment]}_" if has_comments && col[:comment] && !col[:comment].to_s.empty?
|
|
477
570
|
end
|
|
571
|
+
if data[:inherits_unresolved]&.any?
|
|
572
|
+
parents = data[:inherits_unresolved].map { |parent| "`#{parent}`" }.join(", ")
|
|
573
|
+
lines << "" << "Inherits from #{parents}, which the structure.sql dump does not define: its columns are not shown."
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
lines.concat(index_lines(data))
|
|
478
577
|
|
|
479
|
-
if data[:
|
|
480
|
-
lines << "" << "###
|
|
481
|
-
data[:
|
|
482
|
-
|
|
483
|
-
lines << "- `#{
|
|
578
|
+
if data[:unique_constraints]&.any?
|
|
579
|
+
lines << "" << "### Unique constraints"
|
|
580
|
+
data[:unique_constraints].each do |constraint|
|
|
581
|
+
deferrable = constraint[:deferrable] ? ", deferrable: #{constraint[:deferrable]}" : ""
|
|
582
|
+
lines << "- `#{constraint[:name]}` on (#{Array(constraint[:columns]).join(', ')})#{deferrable}"
|
|
484
583
|
end
|
|
485
584
|
end
|
|
486
585
|
|
|
487
586
|
if data[:foreign_keys]&.any?
|
|
488
587
|
lines << "" << "### Foreign keys"
|
|
489
588
|
data[:foreign_keys].each do |fk|
|
|
589
|
+
actions = fk.slice(:on_delete, :on_update, :deferrable, :validate).map { |key, value| "#{key}: #{value}" }
|
|
490
590
|
lines << "- `#{RailsAiContext::Introspectors::SchemaConventions.key_text(fk[:column])}` → " \
|
|
491
|
-
"`#{fk[:to_table]}.#{RailsAiContext::Introspectors::SchemaConventions.key_text(fk[:primary_key])}`"
|
|
591
|
+
"`#{fk[:to_table]}.#{RailsAiContext::Introspectors::SchemaConventions.key_text(fk[:primary_key])}`" \
|
|
592
|
+
"#{" (#{actions.join(', ')})" if actions.any?}"
|
|
492
593
|
end
|
|
493
594
|
end
|
|
494
595
|
|
|
495
|
-
# Check constraints (full detail)
|
|
496
596
|
if data[:check_constraints]&.any?
|
|
497
|
-
lines << "" << "### Check
|
|
498
|
-
data[:check_constraints].each do |
|
|
499
|
-
|
|
500
|
-
lines << "- #{label} #{cc[:expression]}"
|
|
597
|
+
lines << "" << "### Check constraints"
|
|
598
|
+
data[:check_constraints].each do |constraint|
|
|
599
|
+
lines << "- #{"`#{constraint[:name]}`: " if constraint[:name]}#{constraint[:expression]}"
|
|
501
600
|
end
|
|
502
601
|
end
|
|
503
602
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
lines << "- `#{et[:name]}`: #{values}"
|
|
510
|
-
end
|
|
603
|
+
used = columns.filter_map { |c| c[:enum_type] }
|
|
604
|
+
enums = Array(enum_types).select { |enum| used.include?(enum[:name]) }
|
|
605
|
+
if enums.any?
|
|
606
|
+
lines << "" << "### Enum types"
|
|
607
|
+
enums.each { |enum| lines << "- `#{enum[:name]}`: #{Array(enum[:values]).join(', ')}" }
|
|
511
608
|
end
|
|
512
609
|
|
|
513
|
-
|
|
514
|
-
if
|
|
515
|
-
lines << "" << "### Generated
|
|
516
|
-
|
|
517
|
-
|
|
610
|
+
generated = columns.select { |c| c.key?(:generated) }
|
|
611
|
+
if generated.any?
|
|
612
|
+
lines << "" << "### Generated columns"
|
|
613
|
+
generated.each do |col|
|
|
614
|
+
expression = col[:generated].to_s.empty? ? "" : ": #{col[:generated]}"
|
|
615
|
+
lines << "- `#{col[:name]}`#{expression} (#{col[:stored] ? "stored" : "virtual"})"
|
|
518
616
|
end
|
|
519
617
|
end
|
|
520
618
|
|
|
619
|
+
lines.concat([ "" ] + definition) if definition.any?
|
|
521
620
|
lines.join("\n")
|
|
522
621
|
end
|
|
523
622
|
end
|
|
@@ -4,7 +4,7 @@ module RailsAiContext
|
|
|
4
4
|
module Tools
|
|
5
5
|
class GetServicePattern < BaseTool
|
|
6
6
|
tool_name "rails_get_service_pattern"
|
|
7
|
-
description "Analyze service objects in app/services/: patterns, interfaces, dependencies, and side effects. " \
|
|
7
|
+
description "Analyze service objects in app/services/, app/interactions/ and app/interactors/: patterns, interfaces, dependencies, and side effects. " \
|
|
8
8
|
"Use when: understanding how services are structured, adding a new service, or tracing what a service does. " \
|
|
9
9
|
"Specify service:\"CreateOrder\" for full detail, or omit to detect the common pattern and list all services."
|
|
10
10
|
|
|
@@ -26,15 +26,17 @@ module RailsAiContext
|
|
|
26
26
|
|
|
27
27
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: true, open_world_hint: false)
|
|
28
28
|
|
|
29
|
+
SERVICE_ROOTS = Introspectors::ServiceClasses::ROOTS
|
|
30
|
+
|
|
29
31
|
def self.call(service: nil, detail: "standard", server_context: nil)
|
|
30
32
|
blank = blank_name_response("service", service)
|
|
31
33
|
return blank if blank
|
|
32
34
|
|
|
33
35
|
root = rails_app.root.to_s
|
|
34
|
-
service_dirs = PathResolver.dirs_for(root,
|
|
36
|
+
service_dirs = SERVICE_ROOTS.flat_map { |kind| PathResolver.dirs_for(root, kind) }.uniq
|
|
35
37
|
|
|
36
38
|
if service_dirs.empty?
|
|
37
|
-
searched = PathResolver.search_patterns(root,
|
|
39
|
+
searched = SERVICE_ROOTS.flat_map { |kind| PathResolver.search_patterns(root, kind) }.uniq.map { |pattern| "#{pattern}/" }
|
|
38
40
|
return text_response("No services directory found. Searched #{searched.join(', ')}. " \
|
|
39
41
|
"This app may not use the service objects pattern.")
|
|
40
42
|
end
|
|
@@ -127,11 +129,20 @@ module RailsAiContext
|
|
|
127
129
|
lines.concat(inputs)
|
|
128
130
|
end
|
|
129
131
|
|
|
130
|
-
owned =
|
|
132
|
+
owned, macros, steps = class_interface(source, constant_for(file, service_dirs))
|
|
133
|
+
built = macro_constructor(macros, record, lookup)
|
|
131
134
|
|
|
132
|
-
|
|
133
|
-
init_params = extract_initialize_params(owned)
|
|
135
|
+
init_params = extract_initialize_params(owned) || built&.dig(:signature)
|
|
134
136
|
lines << "**Initialize:** `#{init_params}`" if init_params
|
|
137
|
+
if built
|
|
138
|
+
lines << "" << "## Inputs (#{built[:library]})"
|
|
139
|
+
built[:inputs].each { |input| lines << "- #{input}" }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if steps.any?
|
|
143
|
+
lines << "" << "## Organizes"
|
|
144
|
+
steps.each_with_index { |step, index| lines << "#{index + 1}. `#{step}`" }
|
|
145
|
+
end
|
|
135
146
|
|
|
136
147
|
# Public methods
|
|
137
148
|
public_methods = extract_public_methods(owned)
|
|
@@ -193,10 +204,10 @@ module RailsAiContext
|
|
|
193
204
|
return { path: file } unless source
|
|
194
205
|
return if Introspectors::ServiceClasses.concern?(nil, source)
|
|
195
206
|
|
|
196
|
-
class_name, superclass = name_and_superclass(source, file, service_dirs)
|
|
207
|
+
class_name, superclass, nesting = name_and_superclass(source, file, service_dirs)
|
|
197
208
|
return if Introspectors::ServiceClasses.mailer?(source, class_name, superclass, lookup)
|
|
198
209
|
|
|
199
|
-
{ path: file, source: source, class_name: class_name, superclass: superclass,
|
|
210
|
+
{ path: file, source: source, class_name: class_name, superclass: superclass, nesting: nesting,
|
|
200
211
|
entryless: Introspectors::ServiceClasses.entryless_module?(source, class_name) }
|
|
201
212
|
end
|
|
202
213
|
|
|
@@ -211,19 +222,22 @@ module RailsAiContext
|
|
|
211
222
|
end
|
|
212
223
|
|
|
213
224
|
private_class_method def self.format_service_listing(service_files, service_dirs, root, detail, lookup)
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
225
|
+
read = service_records(service_files, service_dirs, root, lookup).select { |record| record[:source] }.map do |record|
|
|
226
|
+
[ record, *class_interface(record[:source], constant_for(record[:path], service_dirs)) ]
|
|
227
|
+
end
|
|
228
|
+
# A listed parent's macros are already read, so its children's constructors reuse them.
|
|
229
|
+
walks = read.to_h { |record, _, macros| [ record[:class_name], { name: record[:class_name], superclass: record[:superclass], macros: macros } ] }
|
|
230
|
+
service_data = read.map do |record, owned, macros|
|
|
217
231
|
file, source = record.values_at(:path, :source)
|
|
218
|
-
owned = owned_methods(source, constant_for(file, service_dirs))
|
|
219
232
|
|
|
220
233
|
{
|
|
221
234
|
file: file.sub("#{root}/", ""),
|
|
222
235
|
class_name: record[:class_name],
|
|
223
236
|
superclass: record[:superclass],
|
|
237
|
+
nesting: record[:nesting],
|
|
224
238
|
line_count: source.lines.size,
|
|
225
239
|
public_methods: extract_public_methods(owned),
|
|
226
|
-
init_params: extract_initialize_params(owned),
|
|
240
|
+
init_params: extract_initialize_params(owned) || macro_constructor(macros, record, lookup, walks)&.dig(:signature),
|
|
227
241
|
class_method_call: owned.any? { |m| m[:scope] == :class && m[:name] == "call" },
|
|
228
242
|
result_object: source.match?(/Result\.new|OpenStruct\.new|Struct\.new|\.success|\.failure/),
|
|
229
243
|
active_interaction: Introspectors::Interaction.interaction?(source, lookup: lookup),
|
|
@@ -308,7 +322,7 @@ module RailsAiContext
|
|
|
308
322
|
# A base class is not a service a caller invokes: counted as one it
|
|
309
323
|
# inflates the total and the pattern denominator beside it.
|
|
310
324
|
private_class_method def self.base_class_names(service_data)
|
|
311
|
-
Introspectors::ServiceClasses.abstract_names(service_data.map { |s|
|
|
325
|
+
Introspectors::ServiceClasses.abstract_names(service_data.map { |s| s.values_at(:class_name, :superclass, :nesting) })
|
|
312
326
|
end
|
|
313
327
|
|
|
314
328
|
# The class this file declares and the superclass it names, from the one
|
|
@@ -379,18 +393,101 @@ module RailsAiContext
|
|
|
379
393
|
# outermost definition in the file.
|
|
380
394
|
#
|
|
381
395
|
# One walk per file: the interface and the constructor have to resolve
|
|
382
|
-
# the same owner, and a second walk could pick a different one.
|
|
383
|
-
|
|
396
|
+
# the same owner, and a second walk could pick a different one. The
|
|
397
|
+
# constructor macros come from the same walk, outside any `def`.
|
|
398
|
+
private_class_method def self.class_interface(source, expected_constant = nil)
|
|
384
399
|
ast = Introspectors::SourceIntrospector.walk_source(
|
|
385
|
-
source, { methods: -> { Introspectors::Listeners::MethodsListener.new(include_initialize: true) }
|
|
400
|
+
source, { methods: -> { Introspectors::Listeners::MethodsListener.new(include_initialize: true) },
|
|
401
|
+
macros: Introspectors::Listeners::ConstructorMacroListener,
|
|
402
|
+
organize: -> { Introspectors::Listeners::GenericMacroListener.new(:organize) } }
|
|
386
403
|
)
|
|
387
404
|
methods = ast[:methods] || []
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
Introspectors::
|
|
405
|
+
macros = Introspectors::SourceIntrospector.outside_defs(ast[:macros], methods)
|
|
406
|
+
owners = (methods + macros).map { |m| Introspectors::ActionResolver.owner_name(m) }
|
|
407
|
+
# An organizer's steps, in the order interactor runs them.
|
|
408
|
+
steps = Introspectors::SourceIntrospector.outside_defs(ast[:organize], methods).flat_map { |m| Array(m[:values]) }
|
|
409
|
+
.flat_map { |value| Array(value) }.select { |value| value.is_a?(String) }
|
|
410
|
+
return [ [], [], steps ] if owners.empty?
|
|
411
|
+
|
|
412
|
+
owner = primary_owner(owners, expected_constant)
|
|
413
|
+
[ Introspectors::ActionResolver.own_methods(methods, owner),
|
|
414
|
+
macros.select { |m| Introspectors::ActionResolver.owner_name(m) == owner }, steps ]
|
|
392
415
|
rescue => e
|
|
393
|
-
RailsAiContext.debug_fail(e, [], label: "
|
|
416
|
+
RailsAiContext.debug_fail(e, [ [], [], [] ], label: "class_interface AST")
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
T_STRUCT_BASES = %w[T::Struct T::ImmutableStruct T::InexactStruct].freeze
|
|
420
|
+
DRY_STRUCT_BASES = %w[Dry::Struct Dry::Struct::Value].freeze
|
|
421
|
+
|
|
422
|
+
# The constructor a library's macros define, kept only when the class is
|
|
423
|
+
# that library's: `attribute` is also ActiveModel's, `param` anybody's.
|
|
424
|
+
# Dry::Struct attributes and dry-initializer params and options add to the
|
|
425
|
+
# parent's, so the app's own superclasses contribute theirs, farthest first.
|
|
426
|
+
INHERITED_MACROS = %i[extend attribute attribute? param option].freeze
|
|
427
|
+
|
|
428
|
+
private_class_method def self.macro_constructor(macros, record, lookup, walks = {})
|
|
429
|
+
inherited = inherited_macros(record, lookup, walks).reverse.flat_map do |parent|
|
|
430
|
+
parent[:macros].select { |m| INHERITED_MACROS.include?(m[:macro]) }.map { |m| m.merge(from: parent[:name]) }
|
|
431
|
+
end
|
|
432
|
+
macros = inherited + macros
|
|
433
|
+
return nil if macros.empty?
|
|
434
|
+
|
|
435
|
+
dry_initializer = macros.any? { |m| m[:macro] == :extend && Array(m[:values]).include?("Dry::Initializer") }
|
|
436
|
+
library = ->(bases) { reaches?(record, bases, lookup) }
|
|
437
|
+
kept = macros.select do |m|
|
|
438
|
+
case m[:macro]
|
|
439
|
+
when :extend then false
|
|
440
|
+
when :const, :prop then library.call(T_STRUCT_BASES)
|
|
441
|
+
when :attribute, :attribute? then library.call(DRY_STRUCT_BASES)
|
|
442
|
+
when :param, :option then dry_initializer
|
|
443
|
+
else true
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
return nil if kept.empty?
|
|
447
|
+
|
|
448
|
+
kept = kept.partition { |m| m[:macro] != :option }.flatten if dry_initializer
|
|
449
|
+
names = { const: "T::Struct", prop: "T::Struct", attribute: "Dry::Struct", attribute?: "Dry::Struct",
|
|
450
|
+
param: "dry-initializer", option: "dry-initializer" }
|
|
451
|
+
{ library: kept.map { |m| names.fetch(m[:macro], "attr_extras") }.uniq.join(", "),
|
|
452
|
+
signature: "initialize(#{kept.flat_map { |m| m[:params] }.map { |param| param_text(param) }.join(', ')})",
|
|
453
|
+
inputs: kept.map { |m| m[:from] ? "`#{m[:source]}` (from `#{m[:from]}`)" : "`#{m[:source]}`" } }
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# The app's own superclasses, nearest first, each with the constructor macros its body declares.
|
|
457
|
+
private_class_method def self.inherited_macros(record, lookup, walks)
|
|
458
|
+
chain = []
|
|
459
|
+
seen = [ record[:class_name] ]
|
|
460
|
+
parent = record[:superclass]
|
|
461
|
+
while parent && lookup && chain.size < Introspectors::SuperclassChain::MAX_DEPTH && !seen.include?(parent)
|
|
462
|
+
seen << parent
|
|
463
|
+
entry = walks[parent] ||= begin
|
|
464
|
+
source = lookup.call(parent)
|
|
465
|
+
declared = source && Introspectors::DeclaredConstant.declaration_named(Introspectors::DeclaredConstant.declarations(source, path_name: parent), parent)
|
|
466
|
+
declared ? { name: declared.name, superclass: declared.superclass, macros: class_interface(source, declared.name)[1] } : {}
|
|
467
|
+
end
|
|
468
|
+
break unless entry[:name]
|
|
469
|
+
|
|
470
|
+
chain << entry
|
|
471
|
+
parent = entry[:superclass]
|
|
472
|
+
end
|
|
473
|
+
chain
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
private_class_method def self.reaches?(record, bases, lookup)
|
|
477
|
+
return true if bases.include?(record[:superclass].to_s.delete_prefix("::"))
|
|
478
|
+
return false unless record[:superclass]
|
|
479
|
+
|
|
480
|
+
Introspectors::SuperclassChain.to(record[:source], bases: bases, lookup: lookup, only: record[:class_name]).any?
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
private_class_method def self.param_text(param)
|
|
484
|
+
kind, name, default = param
|
|
485
|
+
case kind
|
|
486
|
+
when :req then name
|
|
487
|
+
when :opt then "#{name} = #{default}"
|
|
488
|
+
when :keyreq then "#{name}:"
|
|
489
|
+
else "#{name}: #{default}"
|
|
490
|
+
end
|
|
394
491
|
end
|
|
395
492
|
|
|
396
493
|
# A service that defines no constructor answers `.new` with no arguments.
|
|
@@ -403,11 +500,10 @@ module RailsAiContext
|
|
|
403
500
|
# interface, which is why it is dropped here rather than at the walk.
|
|
404
501
|
private_class_method def self.extract_public_methods(owned)
|
|
405
502
|
owned.select { |m| m[:visibility] == :public && m[:name] != "initialize" }
|
|
406
|
-
.map { |m| m[:signature] }
|
|
503
|
+
.map { |m| m[:scope] == :class && !m[:signature].start_with?("self.") ? "self.#{m[:signature]}" : m[:signature] }
|
|
407
504
|
end
|
|
408
505
|
|
|
409
|
-
private_class_method def self.primary_owner(
|
|
410
|
-
owners = methods.map { |m| Introspectors::ActionResolver.owner_name(m) }
|
|
506
|
+
private_class_method def self.primary_owner(owners, expected_constant)
|
|
411
507
|
return expected_constant if expected_constant && owners.include?(expected_constant)
|
|
412
508
|
|
|
413
509
|
owners.min_by { |o| [ o.count(":"), o.length ] }
|
|
@@ -278,7 +278,7 @@ module RailsAiContext
|
|
|
278
278
|
raw = RailsAiContext::SafeFile.read(path) or next
|
|
279
279
|
next unless Introspectors::StimulusIntrospector.identifiers_in(path, raw).include?(token)
|
|
280
280
|
|
|
281
|
-
dir =
|
|
281
|
+
dir = RailsAiContext::ViewFile.root_for(path, view_dirs)
|
|
282
282
|
dir ? [ path.delete_prefix("#{dir}/"), true ] : [ path.delete_prefix("#{root}/"), false ]
|
|
283
283
|
rescue SystemCallError
|
|
284
284
|
next
|