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
@@ -11,17 +11,31 @@ module RailsAiContext
11
11
  module_function
12
12
 
13
13
  # @return [Hash] { dialect: Symbol, tables: { name => { columns:, indexes:, foreign_keys: } } }
14
- def parse(content)
14
+ # search_path: the database's configured schema_search_path. rails_version: the app's,
15
+ # whose connection names extensions and enum types (PgNaming).
16
+ def parse(content, search_path: PgNaming::DEFAULT_SEARCH_PATH, rails_version: nil)
15
17
  tables = {}
18
+ # The tables the search path does not show bare, findable only by schema-qualified name.
19
+ qualified_tables = {}
16
20
  dialect = detect_sql_dialect(content)
17
21
  enums = enum_types(content)
22
+ # A short PostgreSQL dump can carry no marker detect_sql_dialect knows, so only MySQL and SQLite skip it.
23
+ created = %i[mysql sqlite].include?(dialect) ? [] : content.scan(CREATE_SCHEMA).map { |(name)| name.delete('"') }
24
+ path = PgNaming.existing_path(search_path, created)
25
+ creates = []
26
+ each_create_table(content) { |*create| creates << create }
27
+ view_matches = content.scan(VIEW)
28
+ virtual_matches = content.scan(VIRTUAL_TABLE)
29
+ relations = creates.map(&:first) + view_matches.map { |match| match[1] } + virtual_matches.map(&:first)
30
+ names = PgNaming.names(path, relations: relations.map { |name| qualified_name(name) }, types: enums.keys)
31
+ local = names.method(:relation)
18
32
 
19
33
  # Every table the file creates, by schema-qualified name, so a parent
20
34
  # outside the listed tables still resolves.
21
35
  all = {}
22
- each_create_table(content) do |qualified, body, inherits, trailer|
36
+ creates.each do |qualified, body, inherits, trailer|
23
37
  name = qualified_name(qualified)
24
- shown = shown_name(name)
38
+ shown = local.(name)
25
39
  next if shown.start_with?("ar_internal_metadata", "schema_migrations")
26
40
  # SQLite's own tables; Rails' data_sources leaves them out.
27
41
  next if dialect == :sqlite && shown.start_with?("sqlite_")
@@ -30,10 +44,14 @@ module RailsAiContext
30
44
  comment = trailer[/\bCOMMENT\s*=\s*'((?:[^']|'')*)'/i, 1]
31
45
  table[:comment] = comment.gsub("''", "'") if comment
32
46
  all[name] = { table: table, raw_types: raw_types, parents: inherits && split_top_level(inherits).map { |parent| qualified_name(parent) } }
33
- tables[shown] = table if shown.match?(/\A\w+\z/)
47
+ if shown.match?(/\A\w+\z/)
48
+ tables[shown] = table
49
+ elsif shown.include?(".")
50
+ qualified_tables[shown] = table
51
+ end
34
52
  end
35
53
 
36
- found_views = views(content)
54
+ found_views = views(view_matches, local)
37
55
  content.scan(/CREATE (UNIQUE )?INDEX\s+(?:CONCURRENTLY\s+)?(?:IF NOT EXISTS\s+)?[`"]?(\w+)[`"]?\s+ON\s+(?:ONLY\s+)?#{QUALIFIED_NAME}((?:(?!#{NEXT_STATEMENT})[^;])*)/m) do |unique, idx_name, table, rest|
38
56
  group = first_paren_group(rest)
39
57
  keys = index_keys(group)
@@ -47,7 +65,7 @@ module RailsAiContext
47
65
  include = options[/\A\s*INCLUDE\s*\(([^)]*)\)/i, 1]
48
66
  detail = SchemaConventions.index_detail(keys, using: rest[0, open][/\bUSING\s+(\w+)/i, 1]&.downcase, include: include && index_keys(include),
49
67
  nulls_not_distinct: options.match?(/\bNULLS\s+NOT\s+DISTINCT\b/i), **key_options(group))
50
- target = all.dig(qualified_name(table), :table) || found_views[shown_name(qualified_name(table))]
68
+ target = all.dig(qualified_name(table), :table) || found_views[local.(qualified_name(table))]
51
69
  (target[:indexes] ||= []) << { name: idx_name, columns: keys, unique: !!unique, where: where }.compact.merge(detail) if target
52
70
  end
53
71
 
@@ -66,7 +84,7 @@ module RailsAiContext
66
84
  # statement and attribute the FK to the wrong table.
67
85
  content.scan(/ALTER TABLE\s+(?:ONLY\s+)?#{QUALIFIED_NAME}\s+ADD CONSTRAINT[^;]*?FOREIGN KEY\s*\(([^)]*)\)\s*REFERENCES\s+#{QUALIFIED_NAME}\s*\(([^)]*)\)([^;]*)/m) do |from, cols, to, pks, tail|
68
86
  from = qualified_name(from)
69
- all.dig(from, :table, :foreign_keys)&.push(SchemaConventions.foreign_key_entry(shown_name(from), shown_name(qualified_name(to)), cols.scan(/\w+/), pks.scan(/\w+/), **foreign_key_actions(tail)))
87
+ all.dig(from, :table, :foreign_keys)&.push(SchemaConventions.foreign_key_entry(local.(from), local.(qualified_name(to)), cols.scan(/\w+/), pks.scan(/\w+/), **foreign_key_actions(tail)))
70
88
  end
71
89
 
72
90
  alters = Hash.new { |h, k| h[k] = [] }
@@ -74,7 +92,10 @@ module RailsAiContext
74
92
  alters[qualified_name(table)] << [ column, change ]
75
93
  end
76
94
  resolved = {}
77
- all.each_key { |name| resolve_columns(name, all, alters, resolved) }
95
+ all.each_key { |name| resolve_columns(name, all, alters, resolved, local) }
96
+ all.each_value do |entry|
97
+ entry[:table][:columns].each { |column| column[:enum_type] = names.type(column[:enum_type]) if column[:enum_type] }
98
+ end
78
99
 
79
100
  content.scan(/^COMMENT ON TABLE #{QUALIFIED_NAME} IS '((?:[^']|'')*)';/) do |table, text|
80
101
  table = all.dig(qualified_name(table), :table)
@@ -88,39 +109,44 @@ module RailsAiContext
88
109
 
89
110
  # pg_dump writes each partition as a table, then attaches it in exactly this form.
90
111
  content.scan(/^ALTER TABLE ONLY .+? ATTACH PARTITION #{QUALIFIED_NAME} /) do |(partition)|
91
- name = qualified_name(partition)
92
- tables.delete(shown_name(name)) if name.start_with?("public.")
112
+ shown = local.(qualified_name(partition))
113
+ (shown.include?(".") ? qualified_tables : tables).delete(shown)
93
114
  end
94
115
 
95
- { dialect: dialect, tables: tables, enums: enums.map { |name, values| { name: name, values: values } },
96
- views: found_views, virtual_tables: virtual_tables(content), extensions: extensions(content) }
116
+ { dialect: dialect, tables: tables, qualified_tables: qualified_tables, names: names, enums: PgNaming.enum_list(enums, path, rails_version),
117
+ views: found_views, virtual_tables: virtual_tables(virtual_matches, local), extensions: extensions(content, path, dialect, rails_version) }
97
118
  end
98
119
 
99
- # As PostgreSQL's connection names them: qualified unless in the public schema.
100
- def extensions(content)
101
- content.scan(/^CREATE EXTENSION (?:IF NOT EXISTS )?("[^"]+"|\w+)(?: WITH SCHEMA ("[^"]+"|\w+))?/).map do |name, schema|
102
- [ (schema.delete('"') unless schema.nil? || schema == "public"), name.delete('"') ].compact.join(".")
120
+ # As PostgreSQL's connection names them (PgNaming.extension_name).
121
+ def extensions(content, path, dialect, rails_version)
122
+ found = content.scan(/^CREATE EXTENSION (?:IF NOT EXISTS )?("[^"]+"|\w+)(?: WITH SCHEMA ("[^"]+"|\w+))?/).map do |name, schema|
123
+ [ name.delete('"'), schema&.delete('"') ]
124
+ end
125
+ # Every PostgreSQL database starts with plpgsql in pg_catalog, and pg_dump leaves it out.
126
+ if dialect == :postgresql && found.none? { |name, _| name == "plpgsql" } && !content.match?(/^DROP EXTENSION (?:IF EXISTS )?"?plpgsql\b/)
127
+ found.unshift([ "plpgsql", "pg_catalog" ])
103
128
  end
129
+ found.map { |name, schema| PgNaming.extension_name(name, schema, path, rails_version) }
104
130
  end
105
131
 
106
132
  # Each view by the name the app reads it under, a later definition of a name replacing a placeholder.
107
- def views(content)
108
- content.scan(VIEW).each_with_object({}) do |(materialized, name, sql), found|
109
- name = qualified_name(name)
110
- found[shown_name(name)] = { materialized: !materialized.nil?, sql: sql.strip } if shown_name(name).match?(/\A\w+\z/)
133
+ def views(view_matches, local)
134
+ view_matches.each_with_object({}) do |(materialized, name, sql), found|
135
+ name = local.(qualified_name(name))
136
+ found[name] = { materialized: !materialized.nil?, sql: sql.strip } if name.match?(/\A\w+\z/)
111
137
  end
112
138
  end
113
139
 
114
- def virtual_tables(content)
115
- content.scan(VIRTUAL_TABLE).to_h do |name, mod, arguments|
116
- [ shown_name(qualified_name(name)), { module: mod, arguments: split_top_level(arguments.to_s) } ]
140
+ def virtual_tables(virtual_matches, local)
141
+ virtual_matches.to_h do |name, mod, arguments|
142
+ [ local.(qualified_name(name)), { module: mod, arguments: split_top_level(arguments.to_s) } ]
117
143
  end
118
144
  end
119
145
 
120
- # PostgreSQL's enum types, by the name schema.rb gives them, with their labels.
146
+ # PostgreSQL's enum types by schema-qualified name, with their labels.
121
147
  def enum_types(content)
122
148
  content.scan(/CREATE TYPE\s+#{QUALIFIED_NAME}\s+AS\s+ENUM\s*\(([^;]*)\)\s*;/i).to_h do |name, labels|
123
- [ shown_name(qualified_name(name)), split_top_level(labels).map { |label| label.delete_prefix("'").delete_suffix("'").gsub("''", "'") } ]
149
+ [ qualified_name(name), split_top_level(labels).map { |label| label.delete_prefix("'").delete_suffix("'").gsub("''", "'") } ]
124
150
  end
125
151
  end
126
152
 
@@ -190,6 +216,7 @@ module RailsAiContext
190
216
  INHERITS_KEYWORD = /\s*INHERITS\b/i
191
217
  # The optional INHERITS list after a CREATE TABLE body.
192
218
  INHERITS = /(?:#{INHERITS_KEYWORD}\s*\(([^)]*)\))?/
219
+ CREATE_SCHEMA = /^CREATE SCHEMA (?:IF NOT EXISTS )?("[^"]+"|\w+)/
193
220
  CREATE_TABLE = /CREATE TABLE\s+(?:IF NOT EXISTS\s+)?#{QUALIFIED_NAME}\s*(?=\()/i
194
221
  # mysqldump wraps the statement in version comments and names an algorithm, definer and security first;
195
222
  # sqlite3 closes it with a /* name(columns) */ comment.
@@ -203,13 +230,9 @@ module RailsAiContext
203
230
  parts.join(".")
204
231
  end
205
232
 
206
- def shown_name(name)
207
- SchemaConventions.local_name(name)
208
- end
209
-
210
233
  # Each parent's columns, then the child's own. pg_dump writes only local
211
234
  # columns and sets an inherited one's NOT NULL and default by ALTER.
212
- def resolve_columns(name, all, alters, resolved)
235
+ def resolve_columns(name, all, alters, resolved, local)
213
236
  entry = all[name]
214
237
  return entry if resolved[name]
215
238
 
@@ -219,11 +242,11 @@ module RailsAiContext
219
242
  unresolved = []
220
243
  entry[:parents]&.each do |parent|
221
244
  unless all[parent]
222
- unresolved << shown_name(parent)
245
+ unresolved << local.(parent)
223
246
  next
224
247
  end
225
248
 
226
- resolved_parent = resolve_columns(parent, all, alters, resolved)
249
+ resolved_parent = resolve_columns(parent, all, alters, resolved, local)
227
250
  resolved_parent[:table][:columns].each { |column| merge_column(columns, column.dup) }
228
251
  raw_types = resolved_parent[:raw_types].merge(raw_types)
229
252
  unresolved.concat(Array(resolved_parent[:table][:inherits_unresolved]))
@@ -432,7 +455,7 @@ module RailsAiContext
432
455
  nullable = !rest.match?(/\bNOT\s+NULL\b|\bPRIMARY\s+KEY\b/i)
433
456
  # An array is its element type with the flag, as schema.rb dumps it.
434
457
  type = normalize_sql_type(col_type.delete_suffix("[]"), dialect)
435
- enum_type = shown_name(qualified_name(col_type.delete_suffix("[]"))) if col_type.match?(/\A(?:"[^"]+"|[\w.]+)(?:\[\])?\z/)
458
+ enum_type = qualified_name(col_type.delete_suffix("[]")) if col_type.match?(/\A(?:"[^"]+"|[\w.]+)(?:\[\])?\z/)
436
459
  type = "enum" if enum_type && enums.key?(enum_type)
437
460
  raw_types[col_name] = col_type
438
461
  column = { name: col_name, type: type, null: nullable }
@@ -96,7 +96,7 @@ module RailsAiContext
96
96
  # A name in two databases answers `database`'s when given, else the primary's.
97
97
  def schema_table(schema, name, database: nil)
98
98
  found = table_holders(schema, name)
99
- (found.find { |db, _, _| database && db == database.to_s } || found.first)&.last
99
+ (found.find { |db, _, _| database && db == database.to_s } || found.first)&.[](2)
100
100
  end
101
101
 
102
102
  # The table a model reads, from the database its `connects_to` writes to.
@@ -124,8 +124,31 @@ module RailsAiContext
124
124
  table_holders(schema, name).map { |db, _, _| db || "primary" }
125
125
  end
126
126
 
127
+ # A schema-qualified name the listing does not hold is looked up when asked for: a table outside
128
+ # the search path, or a listed one by its qualified name, as Rails resolves both.
127
129
  def table_holders(schema, name)
128
- schema_tables(schema).select { |_, table, _| table.to_s == name.to_s }
130
+ found = schema_tables(schema).select { |_, table, _| table.to_s == name.to_s }
131
+ return found if found.any? || !name.to_s.include?(".") || !schema.is_a?(Hash)
132
+
133
+ secondary = schema[:secondary_databases].is_a?(Hash) ? schema[:secondary_databases].keys : []
134
+ [ nil, *secondary ].filter_map do |db|
135
+ live = db.nil? && !parsed_schema?(schema) && schema[:adapter].to_s.match?(/postg/i)
136
+ shown, data = Introspectors::SchemaIntrospector.qualified_table(name.to_s, database: db&.to_s, live: live)
137
+ # The fourth entry is the name the listing shows the table under, which its models read.
138
+ [ db&.to_s, name.to_s, data, shown ] if data
139
+ end
140
+ end
141
+
142
+ # Why the primary's dump leaves out a schema-qualified table, or nil.
143
+ def missing_qualified_table(schema, name)
144
+ return unless schema.is_a?(Hash) && parsed_schema?(schema) && name.to_s.include?(".")
145
+
146
+ Introspectors::SchemaIntrospector.qualified_table_note(name.to_s)
147
+ end
148
+
149
+ # Read from the dump: the context names the adapter and keeps "static_parse" under adapter_source.
150
+ def parsed_schema?(schema)
151
+ [ schema[:adapter], schema[:adapter_source] ].include?("static_parse")
129
152
  end
130
153
 
131
154
  def models(ctx)
@@ -45,7 +45,7 @@ module RailsAiContext
45
45
  lines = [
46
46
  "# ActiveRecord Models (#{models.size})",
47
47
  "",
48
- "> Auto-generated by rails-ai-context. Do not edit manually.",
48
+ "> #{Install::Cleanup::GENERATED_NOTE}",
49
49
  "> Check here first for scopes, constants, associations. Read model files for business logic/methods.",
50
50
  ""
51
51
  ]
@@ -87,7 +87,7 @@ module RailsAiContext
87
87
  lines = [
88
88
  "# Controllers (#{app_controllers.size})",
89
89
  "",
90
- "> Auto-generated by rails-ai-context. Do not edit manually.",
90
+ "> #{Install::Cleanup::GENERATED_NOTE}",
91
91
  "> Read controller files directly when editing. Use MCP tools for reference only.",
92
92
  ""
93
93
  ]
@@ -274,7 +274,7 @@ module RailsAiContext
274
274
  end
275
275
 
276
276
  # Conditionally start live reload based on configuration.
277
- # :auto - try to load `listen`, skip silently with a tip if missing
277
+ # :auto - try to load `listen`, print a tip to stderr if missing
278
278
  # true - try to load `listen`, raise if missing
279
279
  # false - skip entirely
280
280
  def maybe_start_live_reload(mcp_server)
@@ -291,7 +291,7 @@ module RailsAiContext
291
291
  raise LoadError, "Live reload requires the `listen` gem. Add to your Gemfile: gem 'listen', group: :development"
292
292
  end
293
293
 
294
- # :auto mode - skip silently with a tip
294
+ # :auto mode - skip with a tip
295
295
  $stderr.puts "[rails-ai-context] Live reload unavailable (add `listen` gem for auto-refresh)"
296
296
  end
297
297
  end
@@ -77,14 +77,19 @@ def prompt_setup
77
77
  RailsAiContext::Install::Program.select_setup(install_surface)
78
78
  end unless defined?(prompt_setup)
79
79
 
80
+ def say_conflict(key, status)
81
+ _level, text = RailsAiContext::Install::SelectionRecord.conflict_message(key, status)
82
+ puts "⚠️ #{text}" if text
83
+ end unless defined?(say_conflict)
84
+
80
85
  def save_tool_mode_to_initializer(mode)
81
- RailsAiContext::Install::SelectionRecord.write_tool_mode(mode, root: Rails.root)
86
+ say_conflict(:tool_mode, RailsAiContext::Install::SelectionRecord.write_tool_mode(mode, root: Rails.root))
82
87
  rescue => e
83
88
  RailsAiContext.debug_fail(e, nil, label: "save_tool_mode_to_initializer")
84
89
  end unless defined?(save_tool_mode_to_initializer)
85
90
 
86
91
  def save_context_files_to_initializer(value)
87
- RailsAiContext::Install::SelectionRecord.write_context_files(value, root: Rails.root)
92
+ say_conflict(:context_files, RailsAiContext::Install::SelectionRecord.write_context_files(value, root: Rails.root))
88
93
  rescue => e
89
94
  RailsAiContext.debug_fail(e, nil, label: "save_context_files_to_initializer")
90
95
  end unless defined?(save_context_files_to_initializer)
@@ -100,7 +105,7 @@ rescue => e
100
105
  end unless defined?(ensure_mcp_configs)
101
106
 
102
107
  def tool_mode_configured?
103
- !RailsAiContext::Install::SelectionRecord.tool_mode(root: Rails.root).nil?
108
+ RailsAiContext::Install::SelectionRecord.tool_mode_set?(root: Rails.root)
104
109
  rescue => e
105
110
  RailsAiContext.debug_fail(e, false, label: "tool_mode_configured?")
106
111
  end unless defined?(tool_mode_configured?)
@@ -295,7 +300,10 @@ namespace :ai do
295
300
  end
296
301
 
297
302
  namespace :context do
298
- per_tool = RailsAiContext::Install::AiTool.all.to_h { |tool| [ tool.key, tool.files ] }
303
+ # The MCP config a tool's file list names is the installer's to write, not this task's.
304
+ per_tool = RailsAiContext::Install::AiTool.all.to_h { |tool|
305
+ [ tool.key, tool.files.split(" + ").reject { |file| file == tool.mcp_config[:path] }.join(" + ") ]
306
+ }
299
307
  per_tool.merge(json: ".ai-context.json").each do |fmt, file|
300
308
  desc "Generate #{file} context file"
301
309
  task fmt => :environment do
@@ -4,6 +4,8 @@
4
4
  # Bundler.require never loads it in the test environment, so this file is
5
5
  # often the first (and only) entry point required from a test suite.
6
6
  require_relative "../rails_ai_context"
7
+ # A custom tool subclasses MCP::Tool, and nothing else has loaded the SDK yet.
8
+ require "mcp"
7
9
 
8
10
  module RailsAiContext
9
11
  # Reusable test helper for verifying MCP tools - both built-in and custom.
@@ -371,7 +371,9 @@ module RailsAiContext
371
371
  # the composers do not index into `content` themselves.
372
372
  def response_text(response)
373
373
  first = response.content.first
374
- first.is_a?(Hash) ? first[:text].to_s : ""
374
+ text = first.is_a?(Hash) ? first[:text].to_s : ""
375
+ # The composing tool's own response carries the tier banner, once.
376
+ (banner = static_tier_banner) ? text.gsub(banner, "") : text
375
377
  end
376
378
 
377
379
  # One-line banner listing introspectors that failed during context
@@ -4,8 +4,8 @@ module RailsAiContext
4
4
  module Tools
5
5
  class DependencyGraph < BaseTool
6
6
  tool_name "rails_dependency_graph"
7
- description "Generates a dependency graph showing how models, services, and controllers " \
8
- "connect. Output as Mermaid diagram syntax or plain text. " \
7
+ description "Generates a dependency graph showing how models connect through their " \
8
+ "associations. Output as Mermaid diagram syntax or plain text. " \
9
9
  "Use when: understanding feature architecture, tracing data flow, planning refactors. " \
10
10
  "Key params: model (center graph on model), depth (1-3), format (mermaid/text)."
11
11
 
@@ -18,7 +18,7 @@ module RailsAiContext
18
18
  type: "string",
19
19
  description: "Filter by concern type, the name each section heading of the listing prints: model for app/models/concerns/, mailer for app/mailers/concerns/, and for a concern outside every concerns directory the root it lives in (service for app/services, lib for lib). other: app/concerns itself, or a configured directory. all: everything (default)."
20
20
  },
21
- detail: RailsAiContext::DetailLevel.schema("Detail level. summary: concern names only. standard: names + method signatures (default). full: method signatures with source code.")
21
+ detail: RailsAiContext::DetailLevel.schema("Detail level, read when `name` is given. summary and standard (default): method signatures. full: method signatures with source code.")
22
22
  }
23
23
  )
24
24
 
@@ -426,7 +426,7 @@ module RailsAiContext
426
426
  end
427
427
 
428
428
  private_class_method def self.scan_env_example(root)
429
- # Only read .env.example or .env.sample - NEVER .env or .env.local
429
+ # Only the example files - NEVER .env or .env.local
430
430
  candidates = %w[.env.example .env.sample .env.template]
431
431
  vars = []
432
432
 
@@ -4,7 +4,7 @@ module RailsAiContext
4
4
  module Tools
5
5
  class GetGems < BaseTool
6
6
  tool_name "rails_get_gems"
7
- description "Get notable gems from Gemfile.lock grouped by category: auth, jobs, frontend, API, database, testing, deploy. " \
7
+ description "Get notable gems from Gemfile.lock grouped by category (auth, jobs, frontend, API, database, testing, deploy and more). " \
8
8
  "Use when: checking what libraries are available before adding a dependency, or understanding the tech stack. " \
9
9
  "Filter with category:\"auth\" or category:\"database\". Omit for all categories."
10
10
 
@@ -12,7 +12,7 @@ module RailsAiContext
12
12
  properties: {
13
13
  category: {
14
14
  type: "string",
15
- enum: %w[auth jobs frontend api database files testing deploy all],
15
+ enum: Introspectors::GemIntrospector::NOTABLE_GEMS.values.map { |gem| gem[:category].to_s }.uniq + %w[all],
16
16
  description: "Filter by category. Default: all."
17
17
  },
18
18
  offset: {
@@ -212,6 +212,8 @@ module RailsAiContext
212
212
  end
213
213
  end
214
214
  lines << ignored_columns_line(ignored) if ignored.any?
215
+ elsif (why = Payload.missing_qualified_table(Payload.section(cached_context, :schema), data[:table_name]))
216
+ lines << "" << why
215
217
  end
216
218
  end
217
219
 
@@ -65,9 +65,9 @@ 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, table_data = Payload.schema_tables(schema).find { |_, k, _|
68
+ _, table_key, table_data, listed_as = Payload.schema_tables(schema).find { |_, k, _|
69
69
  k.downcase == table_down || k == table_as_table || k == table.underscore
70
- }
70
+ } || Payload.table_holders(schema, table).first
71
71
  databases = table_data ? Payload.schema_databases(schema, table_key) : []
72
72
  database = databases.join(", ") unless databases == [ "primary" ]
73
73
  table_key ||= table
@@ -90,10 +90,11 @@ module RailsAiContext
90
90
 
91
91
  recovery = "Call rails_get_schema(detail:\"summary\") to see all tables"
92
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")
93
+ if Payload.parsed_schema?(schema)
94
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
95
  end
96
- return not_found_response("Table", table, Payload.schema_tables(schema).map { |_, name, _| name }.uniq.sort, recovery_tool: recovery)
96
+ return not_found_response("Table", table, Payload.schema_tables(schema).map { |_, name, _| name }.uniq.sort, recovery_tool: recovery,
97
+ note: Payload.missing_qualified_table(schema, table))
97
98
  end
98
99
  # Databases holding the same table read as one; ones that differ each show their own.
99
100
  groups = Payload.schema_tables(schema).select { |_, name, _| name == table_key }
@@ -103,16 +104,16 @@ module RailsAiContext
103
104
 
104
105
  output = groups.map do |dbs, data|
105
106
  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
+ format_table_markdown(table_key, data, in_db, schema[:enum_types], schema[:search_path]).sub("\n\n", "\n\n**Database:** #{dbs.join(", ")}\n")
107
108
  end.join("\n\n")
108
109
  else
109
110
  return json_response(table_data.except(:unread_calls).merge({ database: database }.compact)) if format == "json"
110
111
 
111
- output = format_table_markdown(table_key, table_data, models_data, schema[:enum_types])
112
+ output = format_table_markdown(table_key, table_data, models_data, schema[:enum_types], schema[:search_path], listed_as: listed_as)
112
113
  output = output.sub("\n\n", "\n\n**Database:** #{database}\n") if database
113
114
  end
114
115
  # Cross-reference hint for AI: suggest next tool call
115
- model_refs = models_for_table(table_key, models_data)
116
+ model_refs = models_for_table([ table_key, listed_as ], models_data)
116
117
  if model_refs.any?
117
118
  output += "\n\n_Next: `rails_get_model_details(model:\"#{model_refs.first}\")` for associations, validations, scopes._"
118
119
  end
@@ -264,7 +265,7 @@ module RailsAiContext
264
265
  lines = [ "# Schema Full Detail (#{paginated.size} of #{Introspectors::SchemaConventions.relations_phrase(tables)})", "" ]
265
266
  lines.concat(note_lines(schema))
266
267
  paginated.each do |name|
267
- lines << format_table_markdown(name, tables[name], models_data, schema[:enum_types])
268
+ lines << format_table_markdown(name, tables[name], models_data, schema[:enum_types], schema[:search_path])
268
269
  lines << ""
269
270
  end
270
271
  coverage = model_coverage_lines(tables, models_data)
@@ -342,8 +343,8 @@ module RailsAiContext
342
343
  RailsAiContext.debug_fail(e, Set.new, label: "declared_join_tables")
343
344
  end
344
345
 
345
- private_class_method def self.models_for_table(table_name, models)
346
- models.select { |_, d| d.is_a?(Hash) && d[:table_name] == table_name }.keys
346
+ private_class_method def self.models_for_table(table_names, models)
347
+ models.select { |_, d| d.is_a?(Hash) && Array(table_names).compact.include?(d[:table_name]) }.keys
347
348
  rescue => e
348
349
  RailsAiContext.debug_fail(e, [], label: "models_for_table")
349
350
  end
@@ -377,6 +378,8 @@ module RailsAiContext
377
378
  private_class_method def self.note_lines(schema)
378
379
  lines = schema[:note].to_s.empty? ? [] : [ "_#{schema[:note]}_" ]
379
380
  lines << "**Extensions:** #{schema[:extensions].join(', ')}" if schema[:extensions]&.any?
381
+ # The context keeps a search path only when it holds more than public.
382
+ lines << "**Search path:** #{schema[:search_path].join(', ')}" if schema[:search_path].is_a?(Array)
380
383
  lines
381
384
  end
382
385
 
@@ -522,12 +525,13 @@ module RailsAiContext
522
525
  end
523
526
  end
524
527
 
525
- private_class_method def self.format_table_markdown(name, data, models, enum_types = nil)
528
+ # listed_as: the listing's name for a table asked for by its qualified name.
529
+ private_class_method def self.format_table_markdown(name, data, models, enum_types = nil, search_path = nil, listed_as: nil)
526
530
  columns = data[:columns] || []
527
531
  # Always show Nullable and Default - agents need these for migrations and validations
528
532
  has_defaults = columns.any? { |c| c.key?(:default) && !c[:default].nil? }
529
533
 
530
- model_refs = models_for_table(name, models)
534
+ model_refs = models_for_table([ name, listed_as ], models)
531
535
  lines = [ "## #{RELATION_KINDS.fetch(data[:kind].to_s, "Table")}: #{name}", "" ]
532
536
  lines << "**Models:** #{model_refs.join(', ')}" if model_refs.any?
533
537
  lines << "**Module:** #{data[:module]}" if data[:module]
@@ -600,8 +604,7 @@ module RailsAiContext
600
604
  end
601
605
  end
602
606
 
603
- used = columns.filter_map { |c| c[:enum_type] }
604
- enums = Array(enum_types).select { |enum| used.include?(enum[:name]) }
607
+ enums = RailsAiContext::Introspectors::PgNaming.enums_used(enum_types, columns.filter_map { |c| c[:enum_type]&.to_s }, search_path)
605
608
  if enums.any?
606
609
  lines << "" << "### Enum types"
607
610
  enums.each { |enum| lines << "- `#{enum[:name]}`: #{Array(enum[:values]).join(', ')}" }