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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +138 -0
- data/CONTEXT.md +10 -8
- data/CONTRIBUTING.md +2 -2
- data/README.md +14 -11
- data/SECURITY.md +4 -4
- data/docs/ARCHITECTURE.md +11 -10
- data/docs/CLI.md +33 -7
- data/docs/COMPATIBILITY.md +69 -32
- data/docs/CONFIGURATION.md +10 -8
- data/docs/CUSTOM_TOOLS.md +8 -8
- data/docs/FAQ.md +6 -5
- data/docs/GUIDE.md +154 -80
- data/docs/INTROSPECTORS.md +25 -17
- data/docs/QUICKSTART.md +14 -3
- data/docs/RECIPES.md +50 -40
- data/docs/SECURITY.md +23 -13
- data/docs/SETUP.md +23 -26
- data/docs/STANDALONE.md +7 -3
- data/docs/TOOLS.md +79 -45
- data/docs/TROUBLESHOOTING.md +12 -11
- data/exe/rails-ai-context +8 -4
- data/lib/generators/rails_ai_context/install/install_generator.rb +1 -1
- data/lib/rails_ai_context/configuration.rb +2 -2
- data/lib/rails_ai_context/database_yml.rb +52 -2
- data/lib/rails_ai_context/install/ai_tool.rb +11 -2
- data/lib/rails_ai_context/install/cleanup.rb +69 -23
- data/lib/rails_ai_context/install/program.rb +4 -1
- data/lib/rails_ai_context/install/selection_record.rb +20 -2
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +30 -9
- data/lib/rails_ai_context/introspectors/model_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/pg_naming.rb +133 -0
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +0 -6
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +188 -51
- data/lib/rails_ai_context/introspectors/schema_reader.rb +98 -12
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +56 -33
- data/lib/rails_ai_context/payload.rb +25 -2
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +2 -2
- data/lib/rails_ai_context/server.rb +2 -2
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +12 -4
- data/lib/rails_ai_context/test_helper.rb +2 -0
- data/lib/rails_ai_context/tools/base_tool.rb +3 -1
- data/lib/rails_ai_context/tools/dependency_graph.rb +2 -2
- data/lib/rails_ai_context/tools/get_concern.rb +1 -1
- data/lib/rails_ai_context/tools/get_env.rb +1 -1
- data/lib/rails_ai_context/tools/get_gems.rb +2 -2
- data/lib/rails_ai_context/tools/get_model_details.rb +2 -0
- data/lib/rails_ai_context/tools/get_schema.rb +17 -14
- data/lib/rails_ai_context/tools/query.rb +107 -39
- data/lib/rails_ai_context/tools/search_code.rb +20 -16
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
36
|
+
creates.each do |qualified, body, inherits, trailer|
|
|
23
37
|
name = qualified_name(qualified)
|
|
24
|
-
shown =
|
|
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
|
-
|
|
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(
|
|
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[
|
|
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(
|
|
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
|
-
|
|
92
|
-
|
|
112
|
+
shown = local.(qualified_name(partition))
|
|
113
|
+
(shown.include?(".") ? qualified_tables : tables).delete(shown)
|
|
93
114
|
end
|
|
94
115
|
|
|
95
|
-
{ dialect: dialect, tables: tables,
|
|
96
|
-
views: found_views, virtual_tables: virtual_tables(
|
|
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
|
|
100
|
-
def extensions(content)
|
|
101
|
-
content.scan(/^CREATE EXTENSION (?:IF NOT EXISTS )?("[^"]+"|\w+)(?: WITH SCHEMA ("[^"]+"|\w+))?/).map do |name, schema|
|
|
102
|
-
[
|
|
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(
|
|
108
|
-
|
|
109
|
-
name = qualified_name(name)
|
|
110
|
-
found[
|
|
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(
|
|
115
|
-
|
|
116
|
-
[
|
|
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
|
|
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
|
-
[
|
|
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 <<
|
|
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 =
|
|
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)&.
|
|
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
|
-
">
|
|
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
|
-
">
|
|
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`,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
"
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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: {
|
|
@@ -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
|
|
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(
|
|
346
|
-
models.select { |_, d| d.is_a?(Hash) && d[:table_name]
|
|
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
|
-
|
|
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
|
-
|
|
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(', ')}" }
|