rails-ai-context 5.30.0 → 5.30.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +159 -0
- data/docs/ARCHITECTURE.md +5 -1
- data/docs/CLI.md +1 -1
- data/docs/COMPATIBILITY.md +7 -1
- data/docs/CONFIGURATION.md +2 -2
- data/docs/FAQ.md +1 -1
- data/docs/GUIDE.md +1 -1
- data/exe/rails-ai-context +3 -4
- data/lib/rails_ai_context/action_filters.rb +69 -6
- data/lib/rails_ai_context/boot_manager.rb +21 -5
- data/lib/rails_ai_context/cli/entry_boot.rb +3 -2
- data/lib/rails_ai_context/cli/tool_runner.rb +22 -3
- data/lib/rails_ai_context/concern_paths.rb +2 -1
- data/lib/rails_ai_context/configuration.rb +6 -6
- data/lib/rails_ai_context/erb_source.rb +22 -3
- data/lib/rails_ai_context/introspector.rb +13 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +43 -16
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +10 -15
- data/lib/rails_ai_context/introspectors/eager_load.rb +22 -10
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +4 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +24 -10
- data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +12 -0
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +9 -1
- data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +13 -6
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +83 -27
- data/lib/rails_ai_context/introspectors/listeners/with_options_scope.rb +49 -12
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +15 -0
- data/lib/rails_ai_context/introspectors/migration_replay.rb +2 -0
- data/lib/rails_ai_context/introspectors/model_introspector.rb +14 -3
- data/lib/rails_ai_context/introspectors/pg_partitions.rb +73 -0
- data/lib/rails_ai_context/introspectors/route_introspector.rb +62 -4
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +17 -2
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +13 -7
- data/lib/rails_ai_context/introspectors/schema_reader.rb +28 -2
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +8 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +18 -16
- data/lib/rails_ai_context/migration_status.rb +6 -1
- data/lib/rails_ai_context/pending_migrations.rb +3 -2
- data/lib/rails_ai_context/portable_path.rb +14 -0
- data/lib/rails_ai_context/schema_adapter.rb +6 -1
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -1
- data/lib/rails_ai_context/tools/base_tool.rb +9 -8
- data/lib/rails_ai_context/tools/diagnose.rb +4 -0
- data/lib/rails_ai_context/tools/generate_test.rb +19 -0
- data/lib/rails_ai_context/tools/get_model_details.rb +1 -1
- data/lib/rails_ai_context/tools/get_partial_interface.rb +18 -4
- data/lib/rails_ai_context/tools/get_routes.rb +19 -9
- data/lib/rails_ai_context/tools/get_schema.rb +24 -5
- data/lib/rails_ai_context/tools/get_service_pattern.rb +44 -25
- data/lib/rails_ai_context/tools/get_test_info.rb +8 -0
- data/lib/rails_ai_context/tools/migration_advisor.rb +1 -1
- data/lib/rails_ai_context/tools/query.rb +8 -7
- data/lib/rails_ai_context/tools/runtime_info.rb +13 -10
- data/lib/rails_ai_context/tools/validate_semantics.rb +56 -14
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- metadata +2 -1
|
@@ -7,16 +7,53 @@ module RailsAiContext
|
|
|
7
7
|
# this tracks the open blocks for `in_scope?` and `scope_options`.
|
|
8
8
|
module WithOptionsScope
|
|
9
9
|
UNNAMED_BLOCK_PARAM = :"*"
|
|
10
|
+
UNCERTAIN_BLOCK = Object.new.freeze
|
|
11
|
+
# Zero-arity blocks these run on the class itself, state_machines' and aasm's included;
|
|
12
|
+
# any other such block may be evaluated on another object (`has_details_table do`, `Menu.define do`).
|
|
13
|
+
OWN_BLOCKS = %i[with_options included prepended class_methods concerning state_machine state event aasm].to_set.freeze
|
|
14
|
+
EVALS = %i[class_eval class_exec module_eval module_exec instance_eval instance_exec].to_set.freeze
|
|
10
15
|
|
|
11
16
|
def self.included(base)
|
|
12
17
|
base.prepend(Dispatch)
|
|
13
18
|
end
|
|
14
19
|
|
|
20
|
+
# Whether a block's declarations may belong to another object: it takes no parameters and
|
|
21
|
+
# its call is neither a known one nor an eval on the class. The block answers whether a
|
|
22
|
+
# local names an open mixin hook's class.
|
|
23
|
+
def self.uncertain_block?(node)
|
|
24
|
+
block = node.block
|
|
25
|
+
return false unless block.is_a?(Prism::BlockNode) && block.parameters.nil?
|
|
26
|
+
return !(OWN_BLOCKS.include?(node.name) || EVALS.include?(node.name)) if node.receiver.nil?
|
|
27
|
+
|
|
28
|
+
receiver = node.receiver
|
|
29
|
+
if EVALS.include?(node.name)
|
|
30
|
+
own = receiver.is_a?(Prism::SelfNode) || (receiver.is_a?(Prism::LocalVariableReadNode) && block_given? && yield(receiver.name))
|
|
31
|
+
return !own
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# `Menu.define do`, `Class.new do`: a constant's DSL block; `[1].each {}` yields.
|
|
35
|
+
receiver.is_a?(Prism::ConstantReadNode) || receiver.is_a?(Prism::ConstantPathNode)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# `base` in `def self.included(base)`: the class every includer is.
|
|
39
|
+
def self.hook_param(node)
|
|
40
|
+
return unless node.receiver.is_a?(Prism::SelfNode) && ConcernMembership::MIXIN_HOOKS.include?(node.name.to_s)
|
|
41
|
+
|
|
42
|
+
first = node.parameters&.requireds&.first
|
|
43
|
+
first.name if first.respond_to?(:name)
|
|
44
|
+
end
|
|
45
|
+
|
|
15
46
|
# Prepended, so the listener's own handler never sees the call that
|
|
16
47
|
# opened the scope and no listener repeats the guard.
|
|
17
48
|
module Dispatch
|
|
18
49
|
def on_call_node_enter(node)
|
|
19
|
-
|
|
50
|
+
return enter_scope(node) if with_options_block?(node)
|
|
51
|
+
|
|
52
|
+
count = @results.size
|
|
53
|
+
super
|
|
54
|
+
# Kept, and marked so a schema check does not judge them against this class's table.
|
|
55
|
+
@results.drop(count).each { |result| result[:scope_uncertain] = true } if uncertain_scope?
|
|
56
|
+
@scopes.push({ node: node, param: UNCERTAIN_BLOCK, options: {} }) if uncertain_block?(node)
|
|
20
57
|
end
|
|
21
58
|
|
|
22
59
|
# `super` where the listener has its own leave hook: swallowing it
|
|
@@ -29,7 +66,8 @@ module RailsAiContext
|
|
|
29
66
|
# `base.has_many` inside `def self.included(base)` declares on every includer,
|
|
30
67
|
# as a `with_options` block's parameter does.
|
|
31
68
|
def on_def_node_enter(node)
|
|
32
|
-
|
|
69
|
+
param = WithOptionsScope.hook_param(node)
|
|
70
|
+
@scopes.push({ node: node, param: param, options: {} }) if param
|
|
33
71
|
super if defined?(super)
|
|
34
72
|
end
|
|
35
73
|
|
|
@@ -46,15 +84,6 @@ module RailsAiContext
|
|
|
46
84
|
|
|
47
85
|
private
|
|
48
86
|
|
|
49
|
-
def mixin_hook?(node)
|
|
50
|
-
node.receiver.is_a?(Prism::SelfNode) && ConcernMembership::MIXIN_HOOKS.include?(node.name.to_s) && hook_param(node)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def hook_param(node)
|
|
54
|
-
first = node.parameters&.requireds&.first
|
|
55
|
-
first.name if first.respond_to?(:name)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
87
|
def with_options_block?(node)
|
|
59
88
|
node.name == :with_options && node.block
|
|
60
89
|
end
|
|
@@ -66,7 +95,15 @@ module RailsAiContext
|
|
|
66
95
|
end
|
|
67
96
|
|
|
68
97
|
def leave_scope(node)
|
|
69
|
-
@scopes.pop if
|
|
98
|
+
@scopes.pop if @scopes.last&.fetch(:node)&.equal?(node)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def uncertain_block?(node)
|
|
102
|
+
WithOptionsScope.uncertain_block?(node) { |name| enclosing_scope(name) }
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def uncertain_scope?
|
|
106
|
+
@scopes.any? { |scope| scope[:param].equal?(UNCERTAIN_BLOCK) }
|
|
70
107
|
end
|
|
71
108
|
|
|
72
109
|
# A macro this listener should read: receiverless, or sent to the
|
|
@@ -262,12 +262,27 @@ module RailsAiContext
|
|
|
262
262
|
tables[table][:columns].reject! { |c| c[:name] == col[:name] }
|
|
263
263
|
tables[table][:columns] << col
|
|
264
264
|
end
|
|
265
|
+
add_reference_foreign_key(tables[table], table, name, options || {})
|
|
265
266
|
return unless ref[:index]
|
|
266
267
|
|
|
267
268
|
tables[table][:indexes].reject! { |idx| idx[:name] == ref[:index][:name] }
|
|
268
269
|
tables[table][:indexes] << ref[:index]
|
|
269
270
|
end
|
|
270
271
|
|
|
272
|
+
# ReferenceDefinition's foreign_key: (7.0 to 8.1): true points at the
|
|
273
|
+
# pluralized name, a hash may name to_table: and primary_key:.
|
|
274
|
+
def add_reference_foreign_key(table_data, table, name, options)
|
|
275
|
+
fk = options[:foreign_key]
|
|
276
|
+
return if !fk || options[:polymorphic]
|
|
277
|
+
|
|
278
|
+
fk = {} unless fk.is_a?(Hash)
|
|
279
|
+
to = fk[:to_table] || name.to_s.pluralize
|
|
280
|
+
return if to == RailsAiContext::Confidence::INFERRED
|
|
281
|
+
|
|
282
|
+
(table_data[:foreign_keys] ||= []) <<
|
|
283
|
+
SchemaConventions.foreign_key_entry(table, to.to_s, SchemaConventions.reference_column_name(name), fk[:primary_key])
|
|
284
|
+
end
|
|
285
|
+
|
|
271
286
|
def apply_schema_index(entry, current_table, tables)
|
|
272
287
|
return unless tables[current_table]
|
|
273
288
|
cols = entry[:columns]&.map(&:to_s) || []
|
|
@@ -211,6 +211,8 @@ module RailsAiContext
|
|
|
211
211
|
Statements.apply_schema_column(entry, current_table, tables, pk_type)
|
|
212
212
|
elsif entry[:type] == :index && current_table
|
|
213
213
|
Statements.apply_schema_index(entry, current_table, tables)
|
|
214
|
+
elsif entry[:type] == :unread_call && current_table
|
|
215
|
+
SchemaConventions.note_unread_call(tables[current_table], entry[:name])
|
|
214
216
|
end
|
|
215
217
|
end
|
|
216
218
|
end
|
|
@@ -844,7 +844,8 @@ module RailsAiContext
|
|
|
844
844
|
type: assoc.macro.to_s,
|
|
845
845
|
# A leading `::` stays: it means top level to the class resolver.
|
|
846
846
|
class_name: assoc.class_name.to_s,
|
|
847
|
-
|
|
847
|
+
# Rails 7.1+ answers an Array for a composite key.
|
|
848
|
+
foreign_key: assoc.foreign_key.is_a?(Array) ? assoc.foreign_key.map(&:to_s) : assoc.foreign_key.to_s
|
|
848
849
|
}
|
|
849
850
|
detail[:through] = assoc.options[:through].to_s if assoc.options[:through]
|
|
850
851
|
detail[:source_type] = assoc.options[:source_type].to_s if assoc.options[:source_type]
|
|
@@ -1292,9 +1293,9 @@ module RailsAiContext
|
|
|
1292
1293
|
|
|
1293
1294
|
shaped = assoc.merge(assoc[:name] ? { name: assoc[:name].to_s } : {})
|
|
1294
1295
|
options = assoc[:options]
|
|
1295
|
-
return shaped unless options.is_a?(Hash)
|
|
1296
|
+
return with_default_foreign_key(shaped) unless options.is_a?(Hash)
|
|
1296
1297
|
|
|
1297
|
-
LIFTED_ASSOCIATION_OPTIONS.each_with_object(shaped) do |key, acc|
|
|
1298
|
+
lifted = LIFTED_ASSOCIATION_OPTIONS.each_with_object(shaped) do |key, acc|
|
|
1298
1299
|
next unless options.key?(key) && !acc.key?(key)
|
|
1299
1300
|
|
|
1300
1301
|
value = options[key]
|
|
@@ -1303,9 +1304,19 @@ module RailsAiContext
|
|
|
1303
1304
|
next if value.nil?
|
|
1304
1305
|
|
|
1305
1306
|
acc[key] = if BOOLEAN_ASSOCIATION_OPTIONS.include?(key) then value
|
|
1307
|
+
elsif value.is_a?(Array) then value.map(&:to_s)
|
|
1306
1308
|
else value.to_s
|
|
1307
1309
|
end
|
|
1308
1310
|
end
|
|
1311
|
+
with_default_foreign_key(lifted)
|
|
1312
|
+
end
|
|
1313
|
+
|
|
1314
|
+
# Reflection answers a belongs_to's key when none is declared: the name plus _id.
|
|
1315
|
+
def with_default_foreign_key(assoc)
|
|
1316
|
+
return assoc unless assoc[:type] == "belongs_to" && !assoc.key?(:foreign_key)
|
|
1317
|
+
return assoc if assoc[:computed_name] || assoc[:name].to_s.empty?
|
|
1318
|
+
|
|
1319
|
+
assoc.merge(foreign_key: SchemaConventions.reference_column_name(assoc[:name]))
|
|
1309
1320
|
end
|
|
1310
1321
|
|
|
1311
1322
|
# A value that survives as itself: `in: %w[draft sent]` reached
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RailsAiContext
|
|
4
|
+
module Introspectors
|
|
5
|
+
# A partition repeats its parent's columns, indexes and keys, so it is listed
|
|
6
|
+
# as its partitioned parent, and its rows, bytes and index scans count there.
|
|
7
|
+
module PgPartitions
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# activerecord-postgis-adapter reports "PostGIS" for what is a PostgreSQL connection.
|
|
11
|
+
POSTGRES_ADAPTER = /postg/i
|
|
12
|
+
|
|
13
|
+
# Scoped the way connection.tables is.
|
|
14
|
+
NAMES_SQL = <<~SQL.squish.freeze
|
|
15
|
+
SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
16
|
+
WHERE c.relispartition AND c.relkind IN ('r', 'p') AND n.nspname = ANY (current_schemas(false))
|
|
17
|
+
SQL
|
|
18
|
+
|
|
19
|
+
def names(conn)
|
|
20
|
+
# relispartition arrived with partitions in 10; querying it before then
|
|
21
|
+
# fails, and a failed query aborts any open transaction.
|
|
22
|
+
return [] unless conn.adapter_name.match?(POSTGRES_ADAPTER) && conn.database_version >= 10_00_00
|
|
23
|
+
|
|
24
|
+
conn.select_values(NAMES_SQL)
|
|
25
|
+
rescue => e
|
|
26
|
+
RailsAiContext.debug_fail(e, [], label: "PgPartitions.names")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @return [Array<Hash>] { name:, rows:, dead_rows: } per table, from the statistics alone
|
|
30
|
+
def table_rows(conn)
|
|
31
|
+
per_table(conn, "SUM(s.n_live_tup) AS live_rows, SUM(s.n_dead_tup) AS dead_rows").map do |r|
|
|
32
|
+
{ name: r["name"], rows: r["live_rows"].to_i, dead_rows: r["dead_rows"].to_i }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Array<Hash>] { name:, bytes: } per table. Sizing a relation waits on its lock.
|
|
37
|
+
def table_bytes(conn)
|
|
38
|
+
per_table(conn, "SUM(pg_total_relation_size(s.relid)) AS bytes").map do |r|
|
|
39
|
+
{ name: r["name"], bytes: r["bytes"].to_i }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @return [Array<Hash>] { table:, index:, scans: } per index, from pg_stat_user_indexes
|
|
44
|
+
def index_stats(conn)
|
|
45
|
+
conn.select_all(<<~SQL).map do |r|
|
|
46
|
+
SELECT t.relname AS table_name, i.relname AS index_name, SUM(s.idx_scan) AS scans
|
|
47
|
+
FROM pg_stat_user_indexes s
|
|
48
|
+
JOIN pg_class t ON t.oid = #{root_of(conn, "s.relid")}
|
|
49
|
+
JOIN pg_class i ON i.oid = #{root_of(conn, "s.indexrelid")}
|
|
50
|
+
GROUP BY t.oid, t.relname, i.oid, i.relname
|
|
51
|
+
SQL
|
|
52
|
+
{ table: r["table_name"], index: r["index_name"], scans: r["scans"].to_i }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def per_table(conn, sums)
|
|
57
|
+
conn.select_all(<<~SQL)
|
|
58
|
+
SELECT c.relname AS name, #{sums}
|
|
59
|
+
FROM pg_stat_user_tables s
|
|
60
|
+
JOIN pg_class c ON c.oid = #{root_of(conn, "s.relid")}
|
|
61
|
+
GROUP BY c.oid, c.relname
|
|
62
|
+
SQL
|
|
63
|
+
end
|
|
64
|
+
private_class_method :per_table
|
|
65
|
+
|
|
66
|
+
def root_of(conn, relid)
|
|
67
|
+
# ponytail: pg_partition_root needs PostgreSQL 12, so on 10 and 11 each partition stays its own row.
|
|
68
|
+
conn.database_version >= 12_00_00 ? "COALESCE(pg_partition_root(#{relid}), #{relid})" : relid
|
|
69
|
+
end
|
|
70
|
+
private_class_method :root_of
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -53,6 +53,9 @@ module RailsAiContext
|
|
|
53
53
|
return { error: "config/routes.rb not found in #{app.root}" } if top_files.empty?
|
|
54
54
|
|
|
55
55
|
records, mounts, files = walk_route_files(top_files)
|
|
56
|
+
in_repo = in_repo_routes(mounts)
|
|
57
|
+
records += in_repo.values.flat_map(&:first)
|
|
58
|
+
mounts = (mounts + in_repo.values.flat_map(&:last)).uniq { |mount| [ mount[:engine], mount[:path] ] }
|
|
56
59
|
# What an app draws into an engine's table is the engine's, which the
|
|
57
60
|
# booted tier's Rails.application.routes holds only as the mount.
|
|
58
61
|
app_records, engine_records = records.partition { |r| r[:engine].nil? }
|
|
@@ -79,21 +82,51 @@ module RailsAiContext
|
|
|
79
82
|
confidence: Confidence::STATIC
|
|
80
83
|
}
|
|
81
84
|
result[:dynamic_routes] = dynamic if dynamic.positive?
|
|
82
|
-
engine_routes = engine_route_groups(engine_records, mounts)
|
|
85
|
+
engine_routes = engine_route_groups(engine_records, mounts) + unread_engine_tables(engine_records, mounts)
|
|
83
86
|
result[:engine_routes] = engine_routes if engine_routes.any?
|
|
84
|
-
unread = in_repo_route_files.size
|
|
87
|
+
unread = (in_repo_route_files - in_repo.keys).size
|
|
85
88
|
result[:in_repo_route_files] = unread if unread.positive?
|
|
86
89
|
result
|
|
87
90
|
end
|
|
88
91
|
|
|
89
|
-
#
|
|
90
|
-
#
|
|
92
|
+
# Every routes.rb under an in-repo engine or plugin root. One the app does not mount has
|
|
93
|
+
# no place in the static table, so the count says it was skipped.
|
|
91
94
|
def in_repo_route_files
|
|
92
95
|
PathResolver.code_roots(app.root.to_s)
|
|
93
96
|
.map { |dir| File.join(dir, "config", "routes.rb") }
|
|
94
97
|
.select { |path| File.exist?(path) }
|
|
95
98
|
end
|
|
96
99
|
|
|
100
|
+
# Rails loads every engine's routes.rb. One that appends to the app's own
|
|
101
|
+
# table adds app routes; one that draws into an engine the app mounts is
|
|
102
|
+
# placed under the mount.
|
|
103
|
+
# @return [Hash] path => [records, mounts], for files read into the app's table
|
|
104
|
+
def in_repo_routes(mounts)
|
|
105
|
+
walked = in_repo_route_files.to_h { |path| [ path, walk_routes_file(path) ] }
|
|
106
|
+
app_files = walked.keys.select { |path| app_route_file?(path) }.to_set
|
|
107
|
+
mounted = (mounts + app_files.flat_map { |path| walked[path][1] }).map { |m| m[:engine] }.to_set
|
|
108
|
+
walked.each_with_object({}) do |(path, (records, file_mounts, _files)), found|
|
|
109
|
+
app = app_files.include?(path)
|
|
110
|
+
kept = records.select { |r| r[:engine] ? mounted.include?(r[:engine]) : app }
|
|
111
|
+
file_mounts = app ? file_mounts : []
|
|
112
|
+
# A file that draws nothing (all commented out) has nothing left unread.
|
|
113
|
+
found[path] = [ kept, file_mounts ] if kept.any? || file_mounts.any? || records.empty?
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# `Rails.application.routes` or `X::Application.routes`, drawn, appended or prepended.
|
|
118
|
+
def app_route_file?(path)
|
|
119
|
+
AstWalk.each(AstCache.parse_string(SafeFile.read(path).to_s).value).any? do |node|
|
|
120
|
+
next false unless node.is_a?(Prism::CallNode) && %i[draw append prepend].include?(node.name) && node.block
|
|
121
|
+
|
|
122
|
+
routes = node.receiver
|
|
123
|
+
routes.is_a?(Prism::CallNode) && routes.name == :routes &&
|
|
124
|
+
routes.receiver&.slice.to_s.delete_prefix("::").match?(/\A(Rails\.application|(\w+::)*Application)\z/)
|
|
125
|
+
end
|
|
126
|
+
rescue StandardError, ScriptError => e
|
|
127
|
+
RailsAiContext.debug_fail(e, false, label: "app_route_file? #{path}")
|
|
128
|
+
end
|
|
129
|
+
|
|
97
130
|
# What config/routes.rb and every file it draws mount, from source. The
|
|
98
131
|
# engines section reads this rather than walking config/routes.rb on its
|
|
99
132
|
# own, so the two sections name one set of mounted apps.
|
|
@@ -126,11 +159,36 @@ module RailsAiContext
|
|
|
126
159
|
).compact
|
|
127
160
|
end),
|
|
128
161
|
dynamic_routes: dynamic.positive? ? dynamic : nil,
|
|
162
|
+
mount_computed: (true if mount_record && mount.nil?),
|
|
129
163
|
also_mounted_at: mount_records.drop(1).map { |m| m[:path] }.presence
|
|
130
164
|
}.compact
|
|
131
165
|
end
|
|
132
166
|
end
|
|
133
167
|
|
|
168
|
+
# An engine the app draws nothing into still has its own table, drawn in
|
|
169
|
+
# its own code, which only boot reads. A mounted Rack app has no table at all.
|
|
170
|
+
def unread_engine_tables(engine_records, mounts)
|
|
171
|
+
drawn = engine_records.map { |r| r[:engine] }.to_set
|
|
172
|
+
roots = PathResolver.autoload_roots(app.root.to_s) + PathResolver.path_gem_libs(app.root.to_s)
|
|
173
|
+
mounts.reject { |m| drawn.include?(m[:engine]) }
|
|
174
|
+
.group_by { |m| m[:engine] }
|
|
175
|
+
.select { |engine, _| engine_constant?(engine, roots) }
|
|
176
|
+
.map do |engine, same|
|
|
177
|
+
{ engine: engine, mount: same.first[:path], mount_computed: (true unless same.first[:path]), routes: [],
|
|
178
|
+
also_mounted_at: same.drop(1).map { |m| m[:path] }.presence,
|
|
179
|
+
unavailable: "the engine's own routes are read only with the app booted" }.compact
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# The app's own definition says whether it subclasses Rails::Engine; a
|
|
184
|
+
# gem's constant is read nowhere here, so its name is all there is.
|
|
185
|
+
def engine_constant?(name, roots)
|
|
186
|
+
path = PathResolver.file_for_constant(app.root.to_s, name, roots: roots)
|
|
187
|
+
return name.to_s.match?(/(?:\A|::)Engine\z/) unless path
|
|
188
|
+
|
|
189
|
+
SafeFile.read(path).to_s.match?(/<\s*(?:::)?Rails::Engine\b/)
|
|
190
|
+
end
|
|
191
|
+
|
|
134
192
|
# Rails names the proxy after the mount: its `as:`, else the engine's
|
|
135
193
|
# engine_name, under the names of the scopes around it.
|
|
136
194
|
def route_proxy(engine, mount)
|
|
@@ -32,8 +32,8 @@ module RailsAiContext
|
|
|
32
32
|
def foreign_key_entry(from, to, column, primary_key)
|
|
33
33
|
{
|
|
34
34
|
from_table: from, to_table: to,
|
|
35
|
-
column: column
|
|
36
|
-
primary_key: primary_key
|
|
35
|
+
column: primary_key_value(column) || "#{to.to_s.singularize}_id",
|
|
36
|
+
primary_key: primary_key_value(primary_key) || "id"
|
|
37
37
|
}
|
|
38
38
|
end
|
|
39
39
|
|
|
@@ -63,6 +63,21 @@ module RailsAiContext
|
|
|
63
63
|
(key ? leading << key.to_s : leading).to_set
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# A table whose block called what no reader interprets may have indexes
|
|
67
|
+
# the static schema lacks.
|
|
68
|
+
def note_unread_call(table, name)
|
|
69
|
+
return unless table
|
|
70
|
+
|
|
71
|
+
table[:unread_calls] = Array(table[:unread_calls]) | [ name.to_s ]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# A key as a reader writes it beside another key: `id`, or `(id, day)`, so
|
|
75
|
+
# the two sides of a foreign key stay apart.
|
|
76
|
+
def key_text(value)
|
|
77
|
+
columns = Array(value).map(&:to_s)
|
|
78
|
+
columns.size > 1 ? "(#{columns.join(", ")})" : columns.first.to_s
|
|
79
|
+
end
|
|
80
|
+
|
|
66
81
|
# A partial index's condition as the dump wrote it, for the end of a line.
|
|
67
82
|
def where_clause(where)
|
|
68
83
|
where ? " where #{where}" : ""
|
|
@@ -12,6 +12,7 @@ module RailsAiContext
|
|
|
12
12
|
def call
|
|
13
13
|
return attach_secondary_databases(static_schema_parse) unless active_record_connected?
|
|
14
14
|
|
|
15
|
+
@partitions = PgPartitions.names(connection)
|
|
15
16
|
if table_names.empty?
|
|
16
17
|
# Connected but not migrated: the files answer, and the notes say so.
|
|
17
18
|
@connection_state = "connected, no tables yet"
|
|
@@ -30,8 +31,10 @@ module RailsAiContext
|
|
|
30
31
|
declared_tables: declared_table_names,
|
|
31
32
|
check_constraints: check_constraints,
|
|
32
33
|
enum_types: enum_types,
|
|
33
|
-
generated_columns: generated_columns(schema_reader)
|
|
34
|
-
|
|
34
|
+
generated_columns: generated_columns(schema_reader),
|
|
35
|
+
# What names the migration behind a declared table the connection lacks.
|
|
36
|
+
pending_migrations: RailsAiContext::PendingMigrations.live(RailsAiContext::PendingMigrations.migrate_dir_for(app.root))
|
|
37
|
+
}.compact)
|
|
35
38
|
end
|
|
36
39
|
|
|
37
40
|
# Static tier entry: skip the connection probe entirely and answer from
|
|
@@ -77,7 +80,8 @@ module RailsAiContext
|
|
|
77
80
|
end
|
|
78
81
|
|
|
79
82
|
def table_names
|
|
80
|
-
@table_names ||= connection.tables
|
|
83
|
+
@table_names ||= (connection.tables - @partitions.to_a)
|
|
84
|
+
.reject { |t| t.start_with?("ar_internal_metadata", "schema_migrations") }
|
|
81
85
|
end
|
|
82
86
|
|
|
83
87
|
def extract_tables
|
|
@@ -135,7 +139,8 @@ module RailsAiContext
|
|
|
135
139
|
end
|
|
136
140
|
|
|
137
141
|
def extract_foreign_keys(table)
|
|
138
|
-
|
|
142
|
+
# PostgreSQL clones a key that references a partitioned table once per partition.
|
|
143
|
+
connection.foreign_keys(table).reject { |fk| @partitions.to_a.include?(fk.to_table) }.map do |fk|
|
|
139
144
|
{
|
|
140
145
|
from_table: fk.from_table,
|
|
141
146
|
to_table: fk.to_table,
|
|
@@ -158,7 +163,7 @@ module RailsAiContext
|
|
|
158
163
|
end
|
|
159
164
|
|
|
160
165
|
def schema_reader
|
|
161
|
-
@schema_reader ||= SchemaReader.new(schema_file_path)
|
|
166
|
+
@schema_reader ||= SchemaReader.new(schema_file_path, partitions: @partitions.to_a)
|
|
162
167
|
end
|
|
163
168
|
|
|
164
169
|
# Constraints and enum types are declared in the dump, not reported by
|
|
@@ -352,8 +357,9 @@ module RailsAiContext
|
|
|
352
357
|
tables[table_name] = {
|
|
353
358
|
columns: declared[:columns].map { |c| static_column(c) },
|
|
354
359
|
indexes: declared[:indexes].filter_map { |i| static_index(i) },
|
|
355
|
-
foreign_keys: []
|
|
356
|
-
|
|
360
|
+
foreign_keys: [],
|
|
361
|
+
unread_calls: declared[:unread_calls]
|
|
362
|
+
}.compact
|
|
357
363
|
key = declared.dig(:options, :primary_key)
|
|
358
364
|
tables[table_name][:primary_key] = SchemaConventions.primary_key_value(key) if key
|
|
359
365
|
end
|
|
@@ -59,9 +59,12 @@ module RailsAiContext
|
|
|
59
59
|
|
|
60
60
|
attr_reader :source
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
# partitions: the tables the database says are partitions, which a
|
|
63
|
+
# schema.rb from before Rails 8 dumps as plain tables.
|
|
64
|
+
def initialize(path, pk_type: nil, partitions: [])
|
|
63
65
|
@path = path
|
|
64
66
|
@pk_type = pk_type
|
|
67
|
+
@partitions = partitions
|
|
65
68
|
@source = File.exist?(path.to_s) ? :schema_rb : :none
|
|
66
69
|
end
|
|
67
70
|
|
|
@@ -147,11 +150,32 @@ module RailsAiContext
|
|
|
147
150
|
current = absorb(event, schema, current)
|
|
148
151
|
end
|
|
149
152
|
|
|
150
|
-
schema
|
|
153
|
+
drop_partitions(schema)
|
|
151
154
|
rescue => e
|
|
152
155
|
RailsAiContext.debug_fail(e, empty_schema, label: "SchemaReader")
|
|
153
156
|
end
|
|
154
157
|
|
|
158
|
+
# Rails dumps a partition as a table inheriting its parent, and PostgreSQL
|
|
159
|
+
# lets only a partition inherit a partitioned table.
|
|
160
|
+
def drop_partitions(schema)
|
|
161
|
+
table_options = schema[:tables].transform_values { |t| t.dig(:options, :options).to_s }
|
|
162
|
+
partitioned = table_options.select { |_, o| o.start_with?("PARTITION BY") }.keys
|
|
163
|
+
dumped_partitions = []
|
|
164
|
+
loop do
|
|
165
|
+
found = table_options.select { |_, o| partitioned.include?(o[/\AINHERITS \(([^,]+)\)\z/, 1]) }.keys - dumped_partitions
|
|
166
|
+
break if found.empty?
|
|
167
|
+
|
|
168
|
+
dumped_partitions.concat(found)
|
|
169
|
+
partitioned.concat(found)
|
|
170
|
+
end
|
|
171
|
+
partitions = dumped_partitions | @partitions
|
|
172
|
+
|
|
173
|
+
partitions.each { |name| schema[:tables].delete(name) }
|
|
174
|
+
schema[:foreign_keys].reject! { |fk| partitions.include?(fk[:from]) || partitions.include?(fk[:to]) }
|
|
175
|
+
schema[:check_constraints].reject! { |c| partitions.include?(c[:table]) }
|
|
176
|
+
schema
|
|
177
|
+
end
|
|
178
|
+
|
|
155
179
|
# AstCache caps parses below the configured schema limit, so a dump
|
|
156
180
|
# between the two has to be parsed directly. Everything under the cap
|
|
157
181
|
# goes through the cache, which the other introspectors share.
|
|
@@ -186,6 +210,8 @@ module RailsAiContext
|
|
|
186
210
|
schema[:tables][current]&.dig(:columns)&.push(column_entry(event)) if current
|
|
187
211
|
when :index
|
|
188
212
|
schema[:tables][current]&.dig(:indexes)&.push(index_entry(event)) if current
|
|
213
|
+
when :unread_call
|
|
214
|
+
SchemaConventions.note_unread_call(schema[:tables][current], event[:name]) if current
|
|
189
215
|
when :add_index
|
|
190
216
|
schema[:tables][event[:table]]&.dig(:indexes)&.push(index_entry(event))
|
|
191
217
|
when :foreign_key
|
|
@@ -54,10 +54,13 @@ module RailsAiContext
|
|
|
54
54
|
# [^;]*? keeps the match inside one statement: with .*? a pkey-only
|
|
55
55
|
# ADD CONSTRAINT would swallow up to the FOREIGN KEY of a LATER
|
|
56
56
|
# statement and attribute the FK to the wrong table.
|
|
57
|
-
content.scan(/ALTER TABLE\s+(?:ONLY\s+)?(?:public\.)?[`"]?(\w+)[`"]?\s+ADD CONSTRAINT[^;]*?FOREIGN KEY\s*\([
|
|
58
|
-
tables[from]&.dig(:foreign_keys)&.push(
|
|
57
|
+
content.scan(/ALTER TABLE\s+(?:ONLY\s+)?(?:public\.)?[`"]?(\w+)[`"]?\s+ADD CONSTRAINT[^;]*?FOREIGN KEY\s*\(([^)]*)\)\s*REFERENCES\s+(?:public\.)?[`"]?(\w+)[`"]?\s*\(([^)]*)\)/m) do |from, cols, to, pks|
|
|
58
|
+
tables[from]&.dig(:foreign_keys)&.push(SchemaConventions.foreign_key_entry(from, to, cols.scan(/\w+/), pks.scan(/\w+/)))
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# pg_dump writes each partition as a table, then attaches it in exactly this form.
|
|
62
|
+
content.scan(/^ALTER TABLE ONLY .+? ATTACH PARTITION (?:public\.)?(?:"([^"]+)"|(\w+)) /) { |quoted, bare| tables.delete(quoted || bare) }
|
|
63
|
+
|
|
61
64
|
{ dialect: detect_sql_dialect(content), tables: tables }
|
|
62
65
|
end
|
|
63
66
|
|
|
@@ -93,8 +96,9 @@ module RailsAiContext
|
|
|
93
96
|
body.each_line do |line|
|
|
94
97
|
line = line.strip.chomp(",")
|
|
95
98
|
case line
|
|
96
|
-
when /\ACONSTRAINT\s+[`"]?\w+[`"]?\s+FOREIGN KEY\s*\([
|
|
97
|
-
|
|
99
|
+
when /\ACONSTRAINT\s+[`"]?\w+[`"]?\s+FOREIGN KEY\s*\(([^)]*)\)\s*REFERENCES\s+[`"]?(\w+)[`"]?\s*\(([^)]*)\)/i
|
|
100
|
+
columns, to, keys = $1, $2, $3
|
|
101
|
+
table[:foreign_keys] << SchemaConventions.foreign_key_entry(table_name, to, columns.scan(/\w+/), keys.scan(/\w+/))
|
|
98
102
|
when /\A(UNIQUE\s+)?(?:KEY|INDEX)\s+[`"](\w+)[`"]\s*(\(.*)/i
|
|
99
103
|
# Captured to locals first: the parsing below runs more regexes,
|
|
100
104
|
# which would clobber $~ before the hash literal reads it.
|
|
@@ -368,7 +368,7 @@ module RailsAiContext
|
|
|
368
368
|
refs = []
|
|
369
369
|
# The partial or template a render call names, positionally or as a
|
|
370
370
|
# keyword anywhere in its arguments, with or without parentheses.
|
|
371
|
-
|
|
371
|
+
self.class.render_calls(content).each do |_, args|
|
|
372
372
|
named = [ args[RENDER_POSITIONAL, 2], *top_level(args).scan(RENDER_NAMED).flatten ].compact
|
|
373
373
|
# An interpolated name is decided at runtime, not a partial on disk.
|
|
374
374
|
refs.concat(named.select { |name| name.match?(PARTIAL_NAME) })
|
|
@@ -388,28 +388,21 @@ module RailsAiContext
|
|
|
388
388
|
refs.uniq
|
|
389
389
|
end
|
|
390
390
|
|
|
391
|
-
# Each render call
|
|
392
|
-
# the line and every line a trailing comma continues onto.
|
|
393
|
-
def
|
|
391
|
+
# Each render call as [offset, argument text]: its balanced parentheses,
|
|
392
|
+
# or the rest of the line and every line a trailing comma continues onto.
|
|
393
|
+
def self.render_calls(content)
|
|
394
394
|
content.to_enum(:scan, RENDER_CALL).map do
|
|
395
|
-
|
|
395
|
+
at = Regexp.last_match.end(0)
|
|
396
|
+
rest = content[at..]
|
|
396
397
|
open = rest[/\A\s*\(/]
|
|
397
|
-
next continued_line(rest) unless open
|
|
398
|
+
next [ at, continued_line(rest) ] unless open
|
|
398
399
|
|
|
399
400
|
span = RailsAiContext::Brackets.span(rest, open.length - 1, comments: :ruby)
|
|
400
|
-
span ? span[1...-1] : rest[open.length..]
|
|
401
|
+
[ at, span ? span[1...-1] : rest[open.length..] ]
|
|
401
402
|
end
|
|
402
403
|
end
|
|
403
404
|
|
|
404
|
-
|
|
405
|
-
# `template:` inside `locals: {...}` or `Foo.new(...)` is not the call's.
|
|
406
|
-
def top_level(args)
|
|
407
|
-
kept = +""
|
|
408
|
-
RailsAiContext::Brackets.each_top_level(args, comments: :ruby) { |piece, kind| kept << piece unless %i[group comment].include?(kind) }
|
|
409
|
-
kept
|
|
410
|
-
end
|
|
411
|
-
|
|
412
|
-
def continued_line(text)
|
|
405
|
+
def self.continued_line(text)
|
|
413
406
|
lines = []
|
|
414
407
|
text.each_line do |line|
|
|
415
408
|
lines << line
|
|
@@ -417,6 +410,15 @@ module RailsAiContext
|
|
|
417
410
|
end
|
|
418
411
|
lines.join
|
|
419
412
|
end
|
|
413
|
+
private_class_method :continued_line
|
|
414
|
+
|
|
415
|
+
# The argument text with every nested (), [] and {} group dropped, so a
|
|
416
|
+
# `template:` inside `locals: {...}` or `Foo.new(...)` is not the call's.
|
|
417
|
+
def top_level(args)
|
|
418
|
+
kept = +""
|
|
419
|
+
RailsAiContext::Brackets.each_top_level(args, comments: :ruby) { |piece, kind| kept << piece unless %i[group comment].include?(kind) }
|
|
420
|
+
kept
|
|
421
|
+
end
|
|
420
422
|
|
|
421
423
|
def extract_stimulus_refs(content)
|
|
422
424
|
self.class.stimulus_refs(content)
|
|
@@ -21,7 +21,12 @@ module RailsAiContext
|
|
|
21
21
|
return nil unless defined?(ActiveRecord::Base)
|
|
22
22
|
|
|
23
23
|
context = migration_context(migrate_dir)
|
|
24
|
-
|
|
24
|
+
begin
|
|
25
|
+
context.open.pending_migrations.map { |m| { version: m.version.to_s, name: m.name } }
|
|
26
|
+
rescue ActiveRecord::MigrationError
|
|
27
|
+
# A future or duplicate version stops Rails loading the directory; the applied set still answers.
|
|
28
|
+
PendingMigrations.for(migrate_dir: migrate_dir, applied: context.get_all_versions)
|
|
29
|
+
end
|
|
25
30
|
rescue => e
|
|
26
31
|
RailsAiContext.debug_fail(e, nil, label: "MigrationStatus.pending")
|
|
27
32
|
end
|
|
@@ -25,9 +25,10 @@ module RailsAiContext
|
|
|
25
25
|
unapplied.map { |m| { version: m[:version], name: m[:name] } }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
# The connection's answer; nil when there is no database to ask.
|
|
28
|
+
# The connection's answer; nil when there is no database to ask. The schema
|
|
29
|
+
# and migrations sections both ask, so a run queries once.
|
|
29
30
|
def live(migrate_dir)
|
|
30
|
-
MigrationStatus.pending(migrate_dir)
|
|
31
|
+
RunCache.fetch([ :pending_migrations, migrate_dir.to_s ]) { MigrationStatus.pending(migrate_dir) }
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
# Each secondary database keeps its own migrate directory, so a secondary
|
|
@@ -80,6 +80,20 @@ module RailsAiContext
|
|
|
80
80
|
relative unless relative.start_with?(File::SEPARATOR)
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
+
# For free text such as an exception message, which names a file by its
|
|
84
|
+
# absolute path, often through the resolved form of the root (/private/tmp).
|
|
85
|
+
def relativize_text(text, root)
|
|
86
|
+
root = root.to_s
|
|
87
|
+
return text.to_s if root.empty?
|
|
88
|
+
|
|
89
|
+
real = File.realpath(root) rescue root
|
|
90
|
+
prefixes = [ root, real ].uniq.sort_by { |prefix| -prefix.length }.map { |prefix| Regexp.escape(prefix) }
|
|
91
|
+
alternation = prefixes.join("|")
|
|
92
|
+
text.to_s
|
|
93
|
+
.gsub(%r{(\A|[\s"'`(:\[=,])(?:#{alternation})/}, '\1')
|
|
94
|
+
.gsub(%r{(\A|[\s"'`(:\[=,])(?:#{alternation})(?=\z|[\s"'`)\],;:])}, '\1.')
|
|
95
|
+
end
|
|
96
|
+
|
|
83
97
|
def relativize_all(paths, root)
|
|
84
98
|
Array(paths).map { |path| relativize(path, root) }.uniq
|
|
85
99
|
end
|