rails-ai-context 5.32.0 → 5.32.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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +138 -0
  3. data/CONTEXT.md +10 -8
  4. data/CONTRIBUTING.md +2 -2
  5. data/README.md +14 -11
  6. data/SECURITY.md +4 -4
  7. data/docs/ARCHITECTURE.md +11 -10
  8. data/docs/CLI.md +33 -7
  9. data/docs/COMPATIBILITY.md +69 -32
  10. data/docs/CONFIGURATION.md +10 -8
  11. data/docs/CUSTOM_TOOLS.md +8 -8
  12. data/docs/FAQ.md +6 -5
  13. data/docs/GUIDE.md +154 -80
  14. data/docs/INTROSPECTORS.md +25 -17
  15. data/docs/QUICKSTART.md +14 -3
  16. data/docs/RECIPES.md +50 -40
  17. data/docs/SECURITY.md +23 -13
  18. data/docs/SETUP.md +23 -26
  19. data/docs/STANDALONE.md +7 -3
  20. data/docs/TOOLS.md +79 -45
  21. data/docs/TROUBLESHOOTING.md +12 -11
  22. data/exe/rails-ai-context +8 -4
  23. data/lib/generators/rails_ai_context/install/install_generator.rb +1 -1
  24. data/lib/rails_ai_context/configuration.rb +2 -2
  25. data/lib/rails_ai_context/database_yml.rb +52 -2
  26. data/lib/rails_ai_context/install/ai_tool.rb +11 -2
  27. data/lib/rails_ai_context/install/cleanup.rb +69 -23
  28. data/lib/rails_ai_context/install/program.rb +4 -1
  29. data/lib/rails_ai_context/install/selection_record.rb +20 -2
  30. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +30 -9
  31. data/lib/rails_ai_context/introspectors/model_introspector.rb +1 -1
  32. data/lib/rails_ai_context/introspectors/pg_naming.rb +133 -0
  33. data/lib/rails_ai_context/introspectors/schema_conventions.rb +0 -6
  34. data/lib/rails_ai_context/introspectors/schema_introspector.rb +188 -51
  35. data/lib/rails_ai_context/introspectors/schema_reader.rb +98 -12
  36. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +56 -33
  37. data/lib/rails_ai_context/payload.rb +25 -2
  38. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +2 -2
  39. data/lib/rails_ai_context/server.rb +2 -2
  40. data/lib/rails_ai_context/tasks/rails_ai_context.rake +12 -4
  41. data/lib/rails_ai_context/test_helper.rb +2 -0
  42. data/lib/rails_ai_context/tools/base_tool.rb +3 -1
  43. data/lib/rails_ai_context/tools/dependency_graph.rb +2 -2
  44. data/lib/rails_ai_context/tools/get_concern.rb +1 -1
  45. data/lib/rails_ai_context/tools/get_env.rb +1 -1
  46. data/lib/rails_ai_context/tools/get_gems.rb +2 -2
  47. data/lib/rails_ai_context/tools/get_model_details.rb +2 -0
  48. data/lib/rails_ai_context/tools/get_schema.rb +17 -14
  49. data/lib/rails_ai_context/tools/query.rb +107 -39
  50. data/lib/rails_ai_context/tools/search_code.rb +20 -16
  51. data/lib/rails_ai_context/version.rb +1 -1
  52. data/server.json +1 -1
  53. metadata +2 -1
@@ -13,8 +13,11 @@ module RailsAiContext
13
13
  #
14
14
  # context_paths lists the root file first, then the split-rule targets. The writers,
15
15
  # doctor and cleanup all read this table, so a moved path changes every surface at once.
16
- AiTool = Struct.new(:number, :key, :name, :files, :context_paths, :rules_dir, :mcp_config, :legacy_paths,
17
- :owned_dir, keyword_init: true)
16
+ #
17
+ # rule_files names what the gem writes inside rules_dir. The directory is shared with
18
+ # rules the user writes by hand, so cleanup removes these names and nothing else.
19
+ AiTool = Struct.new(:number, :key, :name, :files, :context_paths, :rules_dir, :rule_files, :mcp_config,
20
+ :legacy_paths, :owned_dir, keyword_init: true)
18
21
 
19
22
  class AiTool
20
23
  ALL = [
@@ -23,6 +26,7 @@ module RailsAiContext
23
26
  files: "CLAUDE.md + .claude/rules/",
24
27
  context_paths: %w[CLAUDE.md .claude/rules],
25
28
  rules_dir: ".claude/rules",
29
+ rule_files: %w[rails-context.md rails-schema.md rails-models.md rails-mcp-tools.md rails-components.md],
26
30
  mcp_config: { path: ".mcp.json", root_key: "mcpServers", format: :mcp_json },
27
31
  legacy_paths: [ ".claude/rules/rails-ui-patterns.md", ".claude/rules/rails-accessibility.md" ],
28
32
  owned_dir: ".claude"
@@ -32,6 +36,7 @@ module RailsAiContext
32
36
  files: ".cursor/rules/ + .cursorrules (legacy fallback)",
33
37
  context_paths: %w[.cursor/rules .cursorrules],
34
38
  rules_dir: ".cursor/rules",
39
+ rule_files: %w[rails-project.mdc rails-models.mdc rails-controllers.mdc rails-mcp-tools.mdc],
35
40
  mcp_config: { path: ".cursor/mcp.json", root_key: "mcpServers", format: :mcp_json },
36
41
  legacy_paths: [ ".cursor/rules/rails-ui-patterns.mdc" ],
37
42
  owned_dir: ".cursor"
@@ -41,6 +46,10 @@ module RailsAiContext
41
46
  files: ".github/copilot-instructions.md + .github/instructions/",
42
47
  context_paths: %w[.github/copilot-instructions.md .github/instructions],
43
48
  rules_dir: ".github/instructions",
49
+ rule_files: %w[
50
+ rails-context.instructions.md rails-models.instructions.md
51
+ rails-controllers.instructions.md rails-mcp-tools.instructions.md
52
+ ],
44
53
  mcp_config: { path: ".vscode/mcp.json", root_key: "servers", format: :vscode_json },
45
54
  legacy_paths: [ ".github/instructions/rails-ui-patterns.instructions.md" ]
46
55
  ),
@@ -11,49 +11,95 @@ module RailsAiContext
11
11
  module Cleanup
12
12
  module_function
13
13
 
14
+ # The block SectionMarkerWriter manages inside a root context file.
15
+ BEGIN_MARKER = "<!-- BEGIN rails-ai-context -->"
16
+ MANAGED_BLOCK = /#{BEGIN_MARKER}.*?<!-- END rails-ai-context -->(?:\r?\n)?/m
17
+
18
+ # The line a split rules file generated whole carries.
19
+ GENERATED_NOTE = "Auto-generated by rails-ai-context. Do not edit manually."
20
+ GENERATED_LINE = /^> #{Regexp.escape(GENERATED_NOTE)}\r?$/
21
+
22
+ # Removes what this gem generated and nothing the user wrote: the named
23
+ # rule files inside a rules directory, the managed block inside a root
24
+ # file, and a file generated whole.
25
+ #
14
26
  # @param tools [Array<Symbol>] the AI tools being dropped
15
27
  # @param keeping [Array<Symbol>] the tools still selected, whose files
16
28
  # must survive even when a dropped tool names the same path
17
29
  # @param root [String, Pathname] project root
18
- # @return [Hash] { removed: [paths], failed: [paths] }, directories
19
- # marked with a trailing slash so callers can print them the way they
20
- # always have. Both rm calls swallow their errors, so a path is only
30
+ # @return [Hash] { removed: [paths], trimmed: [paths], kept: [paths], failed: [paths] }.
31
+ # A directory carries a trailing slash. `trimmed` files lost their
32
+ # managed block and kept the user's lines; `kept` files hold nothing
33
+ # this gem generated. rm swallows its errors, so a path is only
21
34
  # reported removed once it is gone.
22
35
  def remove(tools:, keeping:, root:)
23
36
  kept = Array(keeping).flat_map { |key| AiTool.find(key)&.context_paths || [] }.to_set
24
- removed = []
25
- failed = []
37
+ outcome = { removed: [], trimmed: [], kept: [], failed: [] }
26
38
 
27
39
  Array(tools).each do |key|
28
- paths = AiTool.find(key)&.context_paths || []
29
- paths.each do |relative|
40
+ tool = AiTool.find(key) or next
41
+ tool.context_paths.each do |relative|
30
42
  next if kept.include?(relative)
31
43
 
32
- label, gone = remove_path(relative, root)
33
- next unless label
34
-
35
- (gone ? removed : failed) << label
44
+ if File.directory?(File.join(root.to_s, relative))
45
+ remove_rule_files(tool, relative, root, outcome)
46
+ else
47
+ remove_generated(relative, root, outcome)
48
+ end
36
49
  end
37
50
  end
38
51
 
39
- { removed: removed, failed: failed }
52
+ outcome
40
53
  end
41
54
 
42
- # @return [Array(String, Boolean), nil] the path's label and whether it
43
- # is gone, or nil when there was nothing there to remove. A directory
44
- # whose children went but which itself survives counts as not gone.
45
- def remove_path(relative, root)
55
+ # A symlink is somebody's own arrangement, and writing through one edits
56
+ # a file this run was not asked about.
57
+ def remove_rule_files(tool, dir, root, outcome)
58
+ full = File.join(root.to_s, dir)
59
+ return if File.symlink?(full)
60
+
61
+ names = Array(tool.rule_files) + tool.legacy_paths.select { |path| File.dirname(path) == dir }.map { |path| File.basename(path) }
62
+ names.each { |name| delete(File.join(dir, name), root, outcome) }
63
+ return unless Dir.empty?(full)
64
+
65
+ Dir.rmdir(full)
66
+ outcome[:removed] << "#{dir}/"
67
+ rescue SystemCallError
68
+ outcome[:failed] << "#{dir}/"
69
+ end
70
+ private_class_method :remove_rule_files
71
+
72
+ # Bytes in, bytes out: the markers are ASCII, and the user's lines may not be valid UTF-8.
73
+ def remove_generated(relative, root, outcome)
46
74
  full = File.join(root.to_s, relative)
75
+ return unless File.file?(full) || File.symlink?(full)
76
+ return outcome[:kept] << relative if File.symlink?(full)
47
77
 
48
- if File.directory?(full)
49
- FileUtils.rm_rf(full)
50
- [ "#{relative}/", !File.exist?(full) ]
51
- elsif File.exist?(full)
52
- FileUtils.rm_f(full)
53
- [ relative, !File.exist?(full) ]
78
+ content = File.binread(full)
79
+ rest = content.gsub(MANAGED_BLOCK, "")
80
+ # The writer puts one blank line under a block it prepends to a file that was already there.
81
+ rest = rest.sub(/\A\r?\n/, "") if content.start_with?(BEGIN_MARKER)
82
+ if rest == content
83
+ content.match?(GENERATED_LINE) ? delete(relative, root, outcome) : outcome[:kept] << relative
84
+ elsif rest.strip.empty?
85
+ delete(relative, root, outcome)
86
+ else
87
+ File.binwrite(full, rest)
88
+ outcome[:trimmed] << relative
54
89
  end
90
+ rescue SystemCallError
91
+ outcome[:failed] << relative
92
+ end
93
+ private_class_method :remove_generated
94
+
95
+ def delete(relative, root, outcome)
96
+ full = File.join(root.to_s, relative)
97
+ return unless File.file?(full) && !File.symlink?(full)
98
+
99
+ FileUtils.rm_f(full)
100
+ (File.exist?(full) ? outcome[:failed] : outcome[:removed]) << relative
55
101
  end
56
- private_class_method :remove_path
102
+ private_class_method :delete
57
103
  end
58
104
  end
59
105
  end
@@ -113,13 +113,16 @@ module RailsAiContext
113
113
 
114
114
  outcome = Cleanup.remove(tools: [ key ], keeping: selected.map(&:to_sym), root: root)
115
115
  outcome[:removed].each { |path| surface.say " Removed #{path}", :ok }
116
+ outcome[:trimmed].each { |path| surface.say " Removed the generated section from #{path}", :ok }
117
+ outcome[:kept].each { |path| surface.say " Kept #{path} (nothing in it was generated by rails-ai-context)", :muted }
116
118
  outcome[:failed].each { |path| surface.say "Could not remove #{path} - check its permissions", :warn }
117
119
 
118
120
  # Merge-safe MCP config cleanup - removes only the rails-ai-context entry
119
121
  cleaned = RailsAiContext::McpConfigGenerator.remove(tools: [ key ], output_dir: root.to_s)
120
122
  cleaned.each { |f| surface.say " Removed MCP entry from #{relative_to(f, root)}", :ok }
121
123
 
122
- surface.say " #{tool.name} files removed", :ok if tool && outcome[:failed].empty?
124
+ gone = outcome[:removed].any? || outcome[:trimmed].any? || cleaned.any?
125
+ surface.say " #{tool.name} files removed", :ok if tool && gone && outcome[:failed].empty?
123
126
  end
124
127
  end
125
128
 
@@ -74,7 +74,7 @@ module RailsAiContext
74
74
  recorded.nil? ? nil : !!recorded
75
75
  end
76
76
 
77
- # @return [Symbol] :updated, :inserted, :unchanged or :absent
77
+ # @return [Symbol] :updated, :inserted, :unchanged, :conflict or :absent
78
78
  def write_context_files(value, root:)
79
79
  write_config_line(root, " config.context_files = #{value ? 'true' : 'false'}",
80
80
  CONTEXT_FILES_LINE, /^[ \t]*config\.context_files\s*=.*$/)
@@ -83,7 +83,7 @@ module RailsAiContext
83
83
  # Only an uncommented line is rewritten - the generated initializer ships
84
84
  # a commented-out default that must stay a comment.
85
85
  #
86
- # @return [Symbol] :updated, :inserted, :unchanged or :absent
86
+ # @return [Symbol] :updated, :inserted, :unchanged, :conflict or :absent
87
87
  def write_tool_mode(mode, root:)
88
88
  write_config_line(root, " config.tool_mode = :#{mode}",
89
89
  MODE_LINE, /^[ \t]*config\.tool_mode\s*=.*$/)
@@ -134,6 +134,20 @@ module RailsAiContext
134
134
  write((read(root: root) || []) | addition, root: root)
135
135
  end
136
136
 
137
+ # What to say when a single-value key came back :conflict, nil otherwise.
138
+ def conflict_message(key, status)
139
+ return unless status == :conflict
140
+
141
+ [ :warn, "#{INITIALIZER} sets config.#{key} in a form this installer does not " \
142
+ "rewrite, and it takes precedence - edit it by hand to change it" ]
143
+ end
144
+
145
+ # Whether the app has settled its tool mode, in the record or in an
146
+ # initializer line of any shape, so no entry asks again.
147
+ def tool_mode_set?(root:)
148
+ !tool_mode(root: root).nil? || initializer_content(root).to_s.match?(/^[ \t]*config\.tool_mode\s*=/)
149
+ end
150
+
137
151
  def initializer_line(tools)
138
152
  " config.ai_tools = %i[#{normalize(tools).join(' ')}]"
139
153
  end
@@ -196,6 +210,10 @@ module RailsAiContext
196
210
 
197
211
  File.write(path, updated)
198
212
  :updated
213
+ elsif content.match?(assignment)
214
+ # Assigned in a shape this module will not rewrite. A second line
215
+ # beside it would lose at boot and win on read.
216
+ :conflict
199
217
  elsif content.match?(SELECTION_LINE)
200
218
  File.write(path, content.sub(/^([ \t]*config\.ai_tools\s*=[^\n]*)$/) { "#{Regexp.last_match(1)}\n#{line}" })
201
219
  :inserted
@@ -52,12 +52,18 @@ module RailsAiContext
52
52
  # The dumper writes a comment, not a create_table, for a table it could not describe.
53
53
  NOT_DUMPED = /\A#\s*Could not dump table "([^"]+)" because of following (\S+)/
54
54
 
55
+ # raw_names: each name as the dump writes it, for a reader that names them after the walk.
56
+ def initialize(raw_names: false)
57
+ super()
58
+ @raw_names = raw_names
59
+ end
60
+
55
61
  def on_program_node_enter(_node)
56
62
  comments = Array(@comments)
57
63
  comments.each_with_index do |comment, i|
58
64
  match = NOT_DUMPED.match(comment.location.slice) or next
59
65
  message = comments[i + 1]&.location&.slice.to_s[/\A#\s+(\S.*)/, 1]
60
- @results << { type: :not_dumped, table: SchemaConventions.local_name(match[1]),
66
+ @results << { type: :not_dumped, table: local_name(match[1]),
61
67
  reason: [ match[2], message ].compact.join(": "), location: comment.location.start_line }
62
68
  end
63
69
  end
@@ -66,6 +72,13 @@ module RailsAiContext
66
72
  note_block(node)
67
73
  if node.receiver.nil?
68
74
  extract_top_level_call(node)
75
+ elsif node.name == :[] && node.receiver.is_a?(Prism::ConstantPathNode) && node.receiver.slice == "ActiveRecord::Schema"
76
+ # ActiveRecord::Schema[8.1].define: the Rails version that wrote the dump.
77
+ stamp = node.arguments&.arguments&.first
78
+ @results << { type: :stamp, version: stamp.slice, location: node.location.start_line } if stamp.is_a?(Prism::FloatNode)
79
+ elsif node.name == :define && node.receiver.is_a?(Prism::ConstantPathNode) && node.receiver.slice == "ActiveRecord::Schema"
80
+ # Rails before 7.0 writes no stamp.
81
+ @results << { type: :stamp, version: nil, location: node.location.start_line }
69
82
  elsif column_call?(node)
70
83
  extract_column(node)
71
84
  elsif index_call?(node)
@@ -87,6 +100,11 @@ module RailsAiContext
87
100
 
88
101
  private
89
102
 
103
+ # Without a search path to read, public's are the bare names.
104
+ def local_name(name)
105
+ @raw_names ? name : name.delete_prefix("public.")
106
+ end
107
+
90
108
  def read_elsewhere?(name)
91
109
  OTHER_TABLE_METHODS.include?(name) || MigrationReplayListener::TABLE_OPS.key?(name)
92
110
  end
@@ -104,6 +122,9 @@ module RailsAiContext
104
122
  extract_foreign_key(node)
105
123
  when :create_enum
106
124
  extract_enum(node)
125
+ when :create_schema
126
+ name = literal_string(node.arguments&.arguments&.first)
127
+ @results << { type: :create_schema, name: name, location: node.location.start_line } if name
107
128
  when :add_index
108
129
  extract_top_level_add_index(node)
109
130
  when :add_check_constraint
@@ -123,7 +144,7 @@ module RailsAiContext
123
144
  name = literal_string(node.arguments&.arguments&.first) or return
124
145
  options = extract_keyword_nodes(node)
125
146
  @results << {
126
- type: :view, name: SchemaConventions.local_name(name), materialized: options[:materialized].is_a?(Prism::TrueNode),
147
+ type: :view, name: local_name(name), materialized: options[:materialized].is_a?(Prism::TrueNode),
127
148
  sql: literal_string(options[:sql_definition])&.strip, location: node.location.start_line
128
149
  }.compact
129
150
  end
@@ -143,7 +164,7 @@ module RailsAiContext
143
164
 
144
165
  @results << {
145
166
  type: :create_table,
146
- table: SchemaConventions.local_name(table_arg.unescaped),
167
+ table: local_name(table_arg.unescaped),
147
168
  # id: false / id: :uuid / primary_key: ... decide whether the
148
169
  # implicit primary-key column exists and what to call it.
149
170
  options: extract_keyword_options(node),
@@ -161,8 +182,8 @@ module RailsAiContext
161
182
 
162
183
  @results << {
163
184
  type: :foreign_key,
164
- from: SchemaConventions.local_name(from_arg.unescaped),
165
- to: SchemaConventions.local_name(to_arg.unescaped),
185
+ from: local_name(from_arg.unescaped),
186
+ to: local_name(to_arg.unescaped),
166
187
  # Absent means the Rails convention holds; naming it here would
167
188
  # make a declared column indistinguishable from a guessed one.
168
189
  column: SchemaConventions.primary_key_value(options[:column]),
@@ -191,7 +212,7 @@ module RailsAiContext
191
212
 
192
213
  @results << {
193
214
  type: :enum,
194
- name: SchemaConventions.local_name(name_arg.unescaped),
215
+ name: local_name(name_arg.unescaped),
195
216
  values: values,
196
217
  location: node.location.start_line
197
218
  }
@@ -207,7 +228,7 @@ module RailsAiContext
207
228
 
208
229
  @results << {
209
230
  type: :add_index,
210
- table: SchemaConventions.local_name(table_arg.unescaped),
231
+ table: local_name(table_arg.unescaped),
211
232
  columns: columns,
212
233
  options: options,
213
234
  location: node.location.start_line
@@ -222,7 +243,7 @@ module RailsAiContext
222
243
 
223
244
  @results << {
224
245
  type: :add_check_constraint,
225
- table: SchemaConventions.local_name(table_arg.unescaped),
246
+ table: local_name(table_arg.unescaped),
226
247
  expression: expr_arg.unescaped,
227
248
  name: literal_string(extract_keyword_nodes(node)[:name]),
228
249
  location: node.location.start_line
@@ -288,7 +309,7 @@ module RailsAiContext
288
309
 
289
310
  options = braced.map { |hash| hash_node_to_hash(hash) }.reduce(extract_keyword_options(node), :merge)
290
311
  virtual = node.name == :virtual
291
- options[:enum_type] = SchemaConventions.local_name(options[:enum_type]) if options[:enum_type].is_a?(String)
312
+ options[:enum_type] = local_name(options[:enum_type]) if options[:enum_type].is_a?(String)
292
313
  # A generated column takes its own type from type: (each adapter's virtual, 7.0 to 8.1).
293
314
  column_type = options[:type].to_s if virtual && (options[:type].is_a?(Symbol) || options[:type].is_a?(String))
294
315
  names.each do |col_name|
@@ -172,7 +172,7 @@ module RailsAiContext
172
172
  # Rails runs no STI on a table without the type column; a table the dump does not hold is not known to lack it.
173
173
  def lacks_column?(table, column)
174
174
  reader = SchemaReader.for(app.root)
175
- reader.tables.key?(table) && column != RailsAiContext::Confidence::INFERRED && !reader.column?(table, column)
175
+ reader.table?(table) && column != RailsAiContext::Confidence::INFERRED && !reader.column?(table, column)
176
176
  end
177
177
 
178
178
  # Rails' default column, the literal a class sets, or nil where `inheritance_column = nil` turns STI off.
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiContext
4
+ module Introspectors
5
+ # How Rails' PostgreSQL connection and schema.rb dumper name extensions, enum types and
6
+ # relations, by Rails version, so the static tier names them as the booted app does.
7
+ # Each rule cites the activerecord source it mirrors.
8
+ module PgNaming
9
+ DEFAULT_SEARCH_PATH = %w[public].freeze
10
+ # 6.1 schema_dumper.rb:89 writes Schema.define, 7.0 :90 Schema[x.y]; every rule here starts at 7.0 or later.
11
+ UNSTAMPED_DUMP = "6.1"
12
+
13
+ module_function
14
+
15
+ # The running Rails when booted, else the lockfile's; nil when neither says.
16
+ def rails_version(root)
17
+ return Rails.version if defined?(Rails) && Rails.respond_to?(:version) && !RailsAiContext.static_tier?
18
+
19
+ lock = GemLock.for(root)
20
+ lock.version("rails") || lock.version("railties")
21
+ end
22
+
23
+ # An unknown version takes the newest rules.
24
+ def at_least?(version, release)
25
+ version.nil? || Gem::Version.new(version) >= Gem::Version.new(release)
26
+ end
27
+
28
+ # 8.0 postgresql_adapter.rb:506-520 drops the schema only when it is current_schema;
29
+ # 7.0 :453, 7.1 :497 and 7.2 :496 select extname alone.
30
+ def extension_name(name, schema, path, version)
31
+ return name if schema.nil? || !at_least?(version, "8.0") || schema == path.first
32
+
33
+ "#{schema}.#{name}"
34
+ end
35
+
36
+ # 7.1 postgresql_adapter.rb:502-522 (to 8.1 :533-553): the types in current_schemas(false),
37
+ # bare in current_schema. 7.0 :458-469 merges same-name types' labels; the static tier keeps the first set.
38
+ def enum_list(enums, path, version)
39
+ legacy = !at_least?(version, "7.1")
40
+ listed = enums.filter_map do |name, values|
41
+ schema, dot, bare = name.rpartition(".")
42
+ shown = if dot.empty? || legacy then bare
43
+ elsif path.include?(schema) then schema == path.first ? bare : name
44
+ end
45
+ { name: shown, values: values } if shown
46
+ end
47
+ listed.uniq { |enum| enum[:name] }.sort_by { |enum| enum[:name] }
48
+ end
49
+
50
+ # 8.1 schema_dumper.rb:154-162 (relation_name) qualifies every name in a dump of more than one
51
+ # schema. Before, the dumper writes connection.tables, enum_types and column.sql_type
52
+ # (7.2 postgresql/schema_dumper.rb:19-29, :94), the names the booted app reads.
53
+ def dump_qualifies_names?(version)
54
+ at_least?(version, "8.1")
55
+ end
56
+
57
+ # Whether a bare name in the dump says its schema. Before 8.1 the dumper names each of
58
+ # connection.tables bare, whichever search path schema holds it, so only a one-schema path places it.
59
+ def dump_places_names?(version, path)
60
+ dump_qualifies_names?(version) || path.size < 2
61
+ end
62
+
63
+ # 7.1 postgresql/schema_dumper.rb:31-40 writes create_schema for every schema; 7.0 has no schemas().
64
+ def dump_lists_schemas?(version)
65
+ at_least?(version, "7.1")
66
+ end
67
+
68
+ # current_schemas(false) skips a schema that does not exist; pg_dump never creates public.
69
+ def existing_path(path, created)
70
+ path.select { |schema| schema == "public" || created.include?(schema) }
71
+ end
72
+
73
+ # The names a dump's relations and enum types go by, given its qualified names.
74
+ def names(path, relations: [], types: [])
75
+ Names.new(path, shadowed(relations, path), shadowed(types, path))
76
+ end
77
+
78
+ # The qualified names an earlier search path schema hides by holding the same bare name.
79
+ def shadowed(names, path)
80
+ return Set.new if path.size < 2
81
+
82
+ ranked = names.uniq.filter_map do |name|
83
+ schema, dot, bare = name.rpartition(".")
84
+ rank = path.index(schema) unless dot.empty?
85
+ [ rank, bare, name ] if rank
86
+ end
87
+ first = ranked.sort_by(&:first).reverse.to_h { |_, bare, name| [ bare, name ] }
88
+ ranked.filter_map { |_, bare, name| name unless first[bare] == name }.to_set
89
+ end
90
+
91
+ # PostgreSQL resolves a bare name through the search path (data_sources and format_type alike).
92
+ Names = Struct.new(:path, :shadowed_relations, :shadowed_types) do
93
+ def relation(name)
94
+ visible(name, shadowed_relations)
95
+ end
96
+
97
+ def type(name)
98
+ visible(name, shadowed_types)
99
+ end
100
+
101
+ private
102
+
103
+ def visible(name, shadowed)
104
+ schema, dot, bare = name.rpartition(".")
105
+ dot.empty? || !path.include?(schema) || shadowed.include?(name) ? name : bare
106
+ end
107
+ end
108
+
109
+ # The enum types a table's columns use: a bare column type is the first search path schema's
110
+ # holding it, and a bare list name is the current schema's.
111
+ def enums_used(enum_types, column_types, path)
112
+ path = Array(path).presence || DEFAULT_SEARCH_PATH
113
+ listed = Array(enum_types).to_h { |enum| [ enum[:name].to_s.include?(".") ? enum[:name].to_s : "#{path.first}.#{enum[:name]}", enum ] }
114
+ used = column_types.map do |type|
115
+ type.include?(".") ? type : path.map { |schema| "#{schema}.#{type}" }.find { |key| listed.key?(key) }
116
+ end
117
+ listed.select { |key, _| used.include?(key) }.values
118
+ end
119
+
120
+ # 8.1 postgresql/schema_dumper.rb:13-23, :50-68 dumps only ActiveRecord.dump_schemas, the search
121
+ # path by default (active_record.rb:436); 7.1 to 8.0 dump every schema's create_schema and ignore it.
122
+ # A dump naming no schema off the path is the default; one that does has dump_schemas set.
123
+ def missing_from_schema_rb(name, path, created, version, dump)
124
+ schema, dot, = name.to_s.rpartition(".")
125
+ return if dot.empty? || !at_least?(version, "8.1") || path.include?(schema) || created.include?(schema)
126
+ return unless (created - path).empty?
127
+
128
+ "If schema '#{schema}' exists, #{dump} leaves it out: Rails 8.1 dumps only the search path schemas there by default. " \
129
+ "`config.active_record.dump_schemas = :all` includes it."
130
+ end
131
+ end
132
+ end
133
+ end
@@ -189,12 +189,6 @@ module RailsAiContext
189
189
  yml.adapter(db_name, entry).first || (yml.adapter("primary", yml.primary(root)).first unless entry)
190
190
  end
191
191
 
192
- # A dump of more than one schema qualifies every name (relation_name, 8.1);
193
- # the app sees a public table by its bare name under the default search_path.
194
- def local_name(name)
195
- name.delete_prefix("public.")
196
- end
197
-
198
192
  # A primary key as connection.primary_key gives it: the column's name, or
199
193
  # the names in order for a composite key; nil for none.
200
194
  def primary_key_value(key)