rails-ai-context 5.35.0 → 5.35.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 +1024 -0
- data/CONTEXT.md +37 -7
- data/CONTRIBUTING.md +30 -1
- data/README.md +9 -9
- data/SECURITY.md +2 -2
- data/docs/ARCHITECTURE.md +12 -4
- data/docs/CLI.md +44 -15
- data/docs/COMPATIBILITY.md +34 -8
- data/docs/CONFIGURATION.md +28 -5
- data/docs/CUSTOM_TOOLS.md +8 -2
- data/docs/FAQ.md +5 -5
- data/docs/GUIDE.md +33 -65
- data/docs/INTROSPECTORS.md +4 -4
- data/docs/QUICKSTART.md +1 -1
- data/docs/RECIPES.md +5 -5
- data/docs/RELEASING.md +3 -1
- data/docs/SECURITY.md +255 -74
- data/docs/SETUP.md +1 -1
- data/docs/STANDALONE.md +2 -2
- data/docs/TOOLS.md +151 -90
- data/docs/TROUBLESHOOTING.md +123 -8
- data/docs/_config.yml +1 -1
- data/docs/adr/0008-query-runs-only-where-it-can-guard.md +38 -0
- data/docs/adr/0009-dbhub-shapes-in-ruby.md +26 -0
- data/docs/adr/0010-one-config-tool-with-sections.md +32 -0
- data/docs/index.md +2 -2
- data/exe/rails-ai-context +24 -9
- data/lib/generators/rails_ai_context/install/install_generator.rb +2 -0
- data/lib/rails_ai_context/app_databases.rb +377 -0
- data/lib/rails_ai_context/app_kind.rb +2 -0
- data/lib/rails_ai_context/cli/app_root.rb +1 -0
- data/lib/rails_ai_context/cli/tool_runner.rb +110 -99
- data/lib/rails_ai_context/configuration.rb +15 -8
- data/lib/rails_ai_context/database_family/mysql_runtime.rb +170 -0
- data/lib/rails_ai_context/database_family/postgresql_plan.rb +124 -0
- data/lib/rails_ai_context/database_family/postgresql_runtime.rb +211 -0
- data/lib/rails_ai_context/database_family/sqlite_runtime.rb +391 -0
- data/lib/rails_ai_context/database_family.rb +282 -0
- data/lib/rails_ai_context/database_file.rb +61 -7
- data/lib/rails_ai_context/database_yml.rb +7 -29
- data/lib/rails_ai_context/doctor.rb +15 -16
- data/lib/rails_ai_context/instrumentation.rb +1 -1
- data/lib/rails_ai_context/introspectors/connection_pool_introspector.rb +8 -23
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +6 -70
- data/lib/rails_ai_context/introspectors/declared_constant.rb +19 -7
- data/lib/rails_ai_context/introspectors/env_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +1 -1
- data/lib/rails_ai_context/introspectors/job_introspector.rb +7 -7
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +8 -11
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +9 -9
- data/lib/rails_ai_context/introspectors/model_introspector.rb +33 -39
- data/lib/rails_ai_context/introspectors/multi_database_introspector.rb +33 -30
- data/lib/rails_ai_context/introspectors/node_source.rb +2 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +80 -24
- data/lib/rails_ai_context/introspectors/pg_partitions.rb +1 -4
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +6 -8
- data/lib/rails_ai_context/introspectors/schema_dump_path.rb +12 -14
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +47 -27
- data/lib/rails_ai_context/introspectors/schema_reader.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -4
- data/lib/rails_ai_context/migration_status.rb +18 -46
- data/lib/rails_ai_context/payload.rb +48 -1
- data/lib/rails_ai_context/pending_migrations.rb +3 -4
- data/lib/rails_ai_context/run_cache.rb +5 -0
- data/lib/rails_ai_context/schema_adapter.rb +13 -18
- data/lib/rails_ai_context/secret_columns.rb +506 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +14 -10
- data/lib/rails_ai_context/serializers/section_facts.rb +12 -2
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +5 -2
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +35 -7
- data/lib/rails_ai_context/server.rb +143 -29
- data/lib/rails_ai_context/sql_gate.rb +1007 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -2
- data/lib/rails_ai_context/test_helper.rb +3 -1
- data/lib/rails_ai_context/tools/analyze_feature.rb +11 -7
- data/lib/rails_ai_context/tools/base_tool.rb +71 -48
- data/lib/rails_ai_context/tools/dependency_graph.rb +1 -1
- data/lib/rails_ai_context/tools/diagnose.rb +105 -42
- data/lib/rails_ai_context/tools/generate_test.rb +4 -6
- data/lib/rails_ai_context/tools/get_active_support.rb +1 -6
- data/lib/rails_ai_context/tools/get_api.rb +1 -6
- data/lib/rails_ai_context/tools/get_autoload.rb +1 -6
- data/lib/rails_ai_context/tools/get_callbacks.rb +5 -5
- data/lib/rails_ai_context/tools/get_component_catalog.rb +6 -7
- data/lib/rails_ai_context/tools/get_concern.rb +6 -6
- data/lib/rails_ai_context/tools/get_config.rb +189 -12
- data/lib/rails_ai_context/tools/get_context.rb +111 -105
- data/lib/rails_ai_context/tools/get_controllers.rb +8 -5
- data/lib/rails_ai_context/tools/get_conventions.rb +1 -6
- data/lib/rails_ai_context/tools/get_engines.rb +1 -6
- data/lib/rails_ai_context/tools/get_env.rb +1 -6
- data/lib/rails_ai_context/tools/get_env_config.rb +2 -8
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +1 -6
- data/lib/rails_ai_context/tools/get_gems.rb +1 -6
- data/lib/rails_ai_context/tools/get_helper_methods.rb +4 -5
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -7
- data/lib/rails_ai_context/tools/get_job_pattern.rb +1 -1
- data/lib/rails_ai_context/tools/get_mailers.rb +1 -1
- data/lib/rails_ai_context/tools/get_model_details.rb +5 -8
- data/lib/rails_ai_context/tools/get_partial_interface.rb +3 -4
- data/lib/rails_ai_context/tools/get_schema.rb +10 -8
- data/lib/rails_ai_context/tools/get_service_pattern.rb +60 -24
- data/lib/rails_ai_context/tools/get_turbo_map.rb +1 -1
- data/lib/rails_ai_context/tools/migration_advisor.rb +47 -32
- data/lib/rails_ai_context/tools/onboard.rb +24 -24
- data/lib/rails_ai_context/tools/performance_check.rb +15 -2
- data/lib/rails_ai_context/tools/query.rb +142 -1356
- data/lib/rails_ai_context/tools/read_logs.rb +1 -1
- data/lib/rails_ai_context/tools/review_changes.rb +6 -7
- data/lib/rails_ai_context/tools/runtime_info.rb +125 -88
- data/lib/rails_ai_context/tools/safe_call.rb +70 -28
- data/lib/rails_ai_context/tools/search_code.rb +0 -4
- data/lib/rails_ai_context/tools/search_docs.rb +3 -1
- data/lib/rails_ai_context/tools/security_scan.rb +9 -5
- data/lib/rails_ai_context/tools/session_context.rb +18 -12
- data/lib/rails_ai_context/tools/validate.rb +1 -0
- data/lib/rails_ai_context/tools/validate_semantics.rb +5 -5
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context.rb +4 -7
- data/server.json +2 -2
- metadata +14 -3
|
@@ -27,7 +27,7 @@ module RailsAiContext
|
|
|
27
27
|
)
|
|
28
28
|
|
|
29
29
|
guide_row(
|
|
30
|
-
order:
|
|
30
|
+
order: 31,
|
|
31
31
|
mcp: "rails_review_changes(ref:\"main\")",
|
|
32
32
|
cli_args: "ref=main",
|
|
33
33
|
summary: "PR/commit review: file context + warnings (missing indexes, removed validations)"
|
|
@@ -150,12 +150,11 @@ module RailsAiContext
|
|
|
150
150
|
rb_files = classified.select { |c| c[:file].end_with?(".rb") }.map { |c| c[:file] }
|
|
151
151
|
if rb_files.any?
|
|
152
152
|
file_list = rb_files.first(10).map { |f| "\"#{f}\"" }.join(", ")
|
|
153
|
-
|
|
153
|
+
hint = next_hint(pointer("rails_validate", %(files:[#{file_list}], level:"rails"), "to validate all changes."))
|
|
154
|
+
lines << hint if hint
|
|
154
155
|
end
|
|
155
156
|
|
|
156
157
|
text_response(lines.join("\n"))
|
|
157
|
-
rescue => e
|
|
158
|
-
text_response("Review error: #{e.message}")
|
|
159
158
|
end
|
|
160
159
|
|
|
161
160
|
class << self
|
|
@@ -286,7 +285,7 @@ module RailsAiContext
|
|
|
286
285
|
when :model
|
|
287
286
|
model_name = File.basename(file, ".rb").camelize
|
|
288
287
|
begin
|
|
289
|
-
result = GetModelDetails
|
|
288
|
+
result = compose(GetModelDetails, model: model_name, detail: "standard")
|
|
290
289
|
lines << "" << "**Model context:** #{model_name}" unless empty?(result)
|
|
291
290
|
rescue => e; RailsAiContext.debug_fail(e, nil, label: "review_changes context lookup"); end
|
|
292
291
|
|
|
@@ -294,7 +293,7 @@ module RailsAiContext
|
|
|
294
293
|
ctrl_name = File.basename(file, ".rb").camelize
|
|
295
294
|
snake = ctrl_name.underscore.delete_suffix("_controller")
|
|
296
295
|
begin
|
|
297
|
-
result = GetRoutes
|
|
296
|
+
result = compose(GetRoutes, controller: snake, detail: "summary")
|
|
298
297
|
lines << "" << "**Routes:**" << response_text(result) unless empty?(result)
|
|
299
298
|
rescue => e; RailsAiContext.debug_fail(e, nil, label: "review_changes context lookup"); end
|
|
300
299
|
|
|
@@ -324,7 +323,7 @@ module RailsAiContext
|
|
|
324
323
|
|
|
325
324
|
when :routes
|
|
326
325
|
begin
|
|
327
|
-
result = GetRoutes
|
|
326
|
+
result = compose(GetRoutes, detail: "summary")
|
|
328
327
|
summary = response_text(result).lines.first&.strip&.sub(/\A#+\s*/, "")
|
|
329
328
|
lines << "" << "**Current routes:** #{summary}" unless summary.to_s.empty?
|
|
330
329
|
rescue => e; RailsAiContext.debug_fail(e, nil, label: "review_changes context lookup"); end
|
|
@@ -15,12 +15,16 @@ module RailsAiContext
|
|
|
15
15
|
type: "string",
|
|
16
16
|
enum: %w[database cache jobs connections],
|
|
17
17
|
description: "Filter to one section. Omit for all sections."
|
|
18
|
+
},
|
|
19
|
+
database: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: "The app database whose table sizes and index usage to show, by its config/database.yml name. Default: the primary."
|
|
18
22
|
}
|
|
19
23
|
}
|
|
20
24
|
)
|
|
21
25
|
|
|
22
26
|
guide_row(
|
|
23
|
-
order:
|
|
27
|
+
order: 33,
|
|
24
28
|
mcp: "rails_runtime_info(detail:\"standard\")",
|
|
25
29
|
cli_args: "detail=standard",
|
|
26
30
|
summary: "Live runtime: DB pool, table sizes, cache stats, job queues, pending migrations"
|
|
@@ -28,19 +32,21 @@ module RailsAiContext
|
|
|
28
32
|
|
|
29
33
|
annotations(read_only_hint: true, destructive_hint: false, idempotent_hint: false, open_world_hint: false)
|
|
30
34
|
|
|
31
|
-
def self.call(detail: "standard", section: nil, server_context: nil)
|
|
35
|
+
def self.call(detail: "standard", section: nil, database: nil, server_context: nil)
|
|
32
36
|
if (refusal = static_tier_refusal("Live runtime state"))
|
|
33
37
|
return refusal
|
|
34
38
|
end
|
|
39
|
+
named = !database.to_s.empty?
|
|
40
|
+
sections = section ? [ section ] : %w[connections database cache jobs]
|
|
41
|
+
# The database section leases the named database itself, and refuses one it cannot reach.
|
|
42
|
+
AppDatabases.lease(rails_app.root, database) { nil } if named && !sections.include?("database")
|
|
35
43
|
|
|
36
44
|
lines = [ "# Runtime Info", "" ]
|
|
37
45
|
|
|
38
|
-
sections = section ? [ section ] : %w[connections database cache jobs]
|
|
39
|
-
|
|
40
46
|
sections.each do |s|
|
|
41
47
|
result = case s
|
|
42
48
|
when "connections" then gather_connection_pool
|
|
43
|
-
when "database" then gather_database(detail)
|
|
49
|
+
when "database" then gather_database(detail, database)
|
|
44
50
|
when "cache" then gather_cache
|
|
45
51
|
when "jobs" then gather_jobs(detail)
|
|
46
52
|
end
|
|
@@ -48,12 +54,15 @@ module RailsAiContext
|
|
|
48
54
|
end
|
|
49
55
|
|
|
50
56
|
if lines.size <= 2
|
|
51
|
-
lines << "_No runtime data available
|
|
57
|
+
lines << "_No runtime data available: no section had anything to show._"
|
|
58
|
+
end
|
|
59
|
+
if section && section != "database" && named
|
|
60
|
+
lines << "_The #{section} section does not read `database`, so #{database} was not used._"
|
|
52
61
|
end
|
|
53
62
|
|
|
54
63
|
text_response(lines.join("\n"))
|
|
55
|
-
rescue => e
|
|
56
|
-
|
|
64
|
+
rescue AppDatabases::Error => e
|
|
65
|
+
error_response(e.message)
|
|
57
66
|
end
|
|
58
67
|
|
|
59
68
|
class << self
|
|
@@ -91,17 +100,32 @@ module RailsAiContext
|
|
|
91
100
|
|
|
92
101
|
# ── Database section ─────────────────────────────────────────────
|
|
93
102
|
|
|
94
|
-
def gather_database(detail)
|
|
103
|
+
def gather_database(detail, database)
|
|
95
104
|
return [ "## Database", "", "_ActiveRecord not available._", "" ] unless defined?(ActiveRecord::Base)
|
|
96
105
|
|
|
97
|
-
conn
|
|
98
|
-
|
|
99
|
-
|
|
106
|
+
AppDatabases.lease(rails_app.root, database) { |conn| database_lines(conn, detail, database) }
|
|
107
|
+
rescue AppDatabases::Missing, AppDatabases::Unreachable => e
|
|
108
|
+
raise unless database.to_s.empty?
|
|
109
|
+
|
|
110
|
+
[ "## Database", "", "_Not available: #{e.message}_", "" ]
|
|
111
|
+
rescue AppDatabases::Error
|
|
112
|
+
raise
|
|
113
|
+
rescue => e
|
|
114
|
+
[ "## Database", "", "_Not available: #{e.message}_", "" ]
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def database_lines(conn, detail, database)
|
|
118
|
+
family = DatabaseFamily.of(conn)
|
|
119
|
+
lines = [ database.to_s.empty? ? "## Database" : "## Database: #{database}", "" ]
|
|
100
120
|
lines << "**Adapter:** #{SchemaAdapter.display(conn.adapter_name)}"
|
|
121
|
+
unless family.known?
|
|
122
|
+
lines << "" << "_Table sizes and index usage are not available for the #{family.name} adapter._"
|
|
123
|
+
end
|
|
101
124
|
|
|
102
|
-
|
|
103
|
-
sizes
|
|
104
|
-
|
|
125
|
+
sizes = gather_table_sizes(conn, family)
|
|
126
|
+
if sizes == :unread
|
|
127
|
+
lines << "" << "_Table sizes were not read: the read failed or took longer than query_timeout._"
|
|
128
|
+
elsif sizes&.any?
|
|
105
129
|
lines << "" << "### Table Sizes"
|
|
106
130
|
lines << "| Table | Size |"
|
|
107
131
|
lines << "|-------|------|"
|
|
@@ -114,21 +138,16 @@ module RailsAiContext
|
|
|
114
138
|
lines << "_#{count_phrase(sizes.size - shown, "more table")}..._" if sizes.size > shown
|
|
115
139
|
end
|
|
116
140
|
|
|
117
|
-
|
|
118
|
-
pending = gather_pending_migrations
|
|
119
|
-
if pending
|
|
120
|
-
if pending.empty?
|
|
121
|
-
lines << "" << "**Migrations:** all up to date"
|
|
122
|
-
else
|
|
123
|
-
lines << "" << "**Pending migrations:** #{pending.size}"
|
|
124
|
-
pending.each { |m| lines << "- #{m[:version]} #{m[:name]}" }
|
|
125
|
-
end
|
|
126
|
-
end
|
|
141
|
+
lines.concat(database.to_s.empty? ? pending_migration_lines : own_pending_migration_lines(conn, database))
|
|
127
142
|
|
|
128
143
|
# Index usage (full detail only)
|
|
129
144
|
if RailsAiContext::DetailLevel.full?(detail)
|
|
130
|
-
index_usage = gather_index_usage(conn,
|
|
131
|
-
if index_usage
|
|
145
|
+
index_usage = gather_index_usage(conn, family)
|
|
146
|
+
if index_usage == :unread
|
|
147
|
+
lines << "" << "_Index usage was not read: the read failed or took longer than query_timeout._"
|
|
148
|
+
elsif index_usage.nil? && family.known?
|
|
149
|
+
lines << "" << "_Index usage is not available for the #{family.name} database family._"
|
|
150
|
+
elsif index_usage&.any?
|
|
132
151
|
lines << "" << "### Index Usage"
|
|
133
152
|
unused = index_usage.select { |i| i[:scans] == 0 }
|
|
134
153
|
if unused.any?
|
|
@@ -144,8 +163,6 @@ module RailsAiContext
|
|
|
144
163
|
|
|
145
164
|
lines << ""
|
|
146
165
|
lines
|
|
147
|
-
rescue => e
|
|
148
|
-
[ "## Database", "", "_Not available: #{e.message}_", "" ]
|
|
149
166
|
end
|
|
150
167
|
|
|
151
168
|
# Rails-managed bookkeeping tables - real tables, but noise in a
|
|
@@ -153,72 +170,76 @@ module RailsAiContext
|
|
|
153
170
|
# introspector, which also skips them).
|
|
154
171
|
INTERNAL_TABLES = %w[schema_migrations ar_internal_metadata].freeze
|
|
155
172
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
# table sizes for every Trilogy app.
|
|
159
|
-
MYSQL_ADAPTER = /mysql|trilogy/i
|
|
160
|
-
|
|
161
|
-
# activerecord-postgis-adapter reports "PostGIS" for what is a PostgreSQL connection.
|
|
162
|
-
POSTGRES_ADAPTER = /postg/i
|
|
163
|
-
|
|
164
|
-
def gather_table_sizes(conn, adapter)
|
|
165
|
-
rows =
|
|
166
|
-
case adapter
|
|
167
|
-
when POSTGRES_ADAPTER
|
|
168
|
-
Introspectors::PgPartitions.table_bytes(conn).sort_by { |r| [ -r[:bytes], r[:name] ] }
|
|
169
|
-
when MYSQL_ADAPTER
|
|
170
|
-
sql = "SELECT table_name AS name, (data_length + index_length) AS bytes FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = DATABASE() ORDER BY bytes DESC"
|
|
171
|
-
conn.select_all(sql).map { |r| { name: r["name"], bytes: r["bytes"].to_i } }
|
|
172
|
-
when /sqlite/
|
|
173
|
-
gather_sqlite_table_sizes(conn)
|
|
174
|
-
end
|
|
175
|
-
return nil unless rows
|
|
173
|
+
def gather_table_sizes(conn, family)
|
|
174
|
+
return nil unless family.known?
|
|
176
175
|
|
|
177
|
-
|
|
176
|
+
AppDatabases.bounded(conn) { family.table_sizes(conn) }&.reject { |r| INTERNAL_TABLES.include?(r[:name]) }
|
|
178
177
|
rescue => e
|
|
179
|
-
RailsAiContext.debug_fail(e,
|
|
178
|
+
RailsAiContext.debug_fail(e, :unread, label: "gather_table_sizes")
|
|
180
179
|
end
|
|
181
180
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
181
|
+
# Every database Rails migrates here, each through its own connection.
|
|
182
|
+
def pending_migration_lines
|
|
183
|
+
here = AppDatabases.for(rails_app.root).here
|
|
184
|
+
primary = here.find(&:primary)
|
|
185
|
+
migrated = here.select(&:tasks)
|
|
186
|
+
return primary_pending_lines if primary.nil? || migrated == [ primary ]
|
|
187
|
+
return no_migrations_line(primary) if migrated.empty?
|
|
188
|
+
|
|
189
|
+
states = migrated.to_h { |db| [ db, db.pending_migrations ] }.reject { |_, state| state[:error].is_a?(AppDatabases::InMemory) }
|
|
190
|
+
read, unread = states.partition { |_, state| state[:pending] }
|
|
191
|
+
lines = pending_lines(read.to_h { |db, state| [ db.name, state[:pending] ] }, named: true)
|
|
192
|
+
unread.each { |db, state| lines << "" << "_Pending migrations could not be read for #{db.name}: #{state[:error].message.lines.first.to_s.strip}_" }
|
|
193
|
+
lines
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# The app's one migrated database, read through the app's own connection.
|
|
197
|
+
def primary_pending_lines
|
|
198
|
+
dirs = RailsAiContext::PendingMigrations.migrate_dirs_for(rails_app.root)
|
|
199
|
+
pending = begin
|
|
200
|
+
AppDatabases.bounded(ActiveRecord::Base.connection) { RailsAiContext::PendingMigrations.live(dirs) }
|
|
197
201
|
rescue => e
|
|
198
|
-
RailsAiContext.debug_fail(e, label: "
|
|
202
|
+
RailsAiContext.debug_fail(e, nil, label: "primary_pending_lines")
|
|
199
203
|
end
|
|
200
|
-
|
|
201
|
-
# Fallback: whole-DB size split across user tables (dbstat unavailable).
|
|
202
|
-
page_count = conn.select_value("PRAGMA page_count").to_i
|
|
203
|
-
page_size = conn.select_value("PRAGMA page_size").to_i
|
|
204
|
-
total = page_count * page_size
|
|
205
|
-
tables = app_tables.to_a.sort
|
|
206
|
-
tables.map { |t| { name: t, bytes: total / [ tables.size, 1 ].max } }
|
|
204
|
+
pending ? pending_lines({ "primary" => pending }) : []
|
|
207
205
|
end
|
|
208
206
|
|
|
209
|
-
|
|
210
|
-
|
|
207
|
+
# A named database's own, read through the connection the section leased.
|
|
208
|
+
def own_pending_migration_lines(conn, name)
|
|
209
|
+
db = AppDatabases.for(rails_app.root).fetch(name)
|
|
210
|
+
return no_migrations_line(db) unless db.tasks
|
|
211
|
+
|
|
212
|
+
dirs = db.primary ? RailsAiContext::PendingMigrations.migrate_dirs_for(rails_app.root) : db.migrate_dirs
|
|
213
|
+
pending_lines({ db.name => AppDatabases.bounded(conn) { RailsAiContext::MigrationStatus.pending_on(conn.pool, dirs) } })
|
|
214
|
+
rescue AppDatabases::Error
|
|
215
|
+
raise
|
|
216
|
+
rescue => e
|
|
217
|
+
[ "", "_Pending migrations could not be read for #{name}: #{e.message.lines.first.to_s.strip}_" ]
|
|
211
218
|
end
|
|
212
219
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
220
|
+
# found: each database that was read, by name, with its pending migrations. named: says which database each is in.
|
|
221
|
+
def pending_lines(found, named: false)
|
|
222
|
+
return [] if found.empty?
|
|
223
|
+
|
|
224
|
+
pending = found.flat_map { |name, list| list.map { |m| "- #{m[:version]} #{m[:name]}#{" (#{name})" if named}" } }
|
|
225
|
+
return [ "", "**Migrations:** all up to date#{" in #{found.keys.join(', ')}" if named}" ] if pending.empty?
|
|
226
|
+
|
|
227
|
+
counts = found.reject { |_, list| list.empty? }.map { |name, list| "#{list.size} in #{name}" }
|
|
228
|
+
total = if !named then pending.size
|
|
229
|
+
else counts.one? ? counts.first : "#{pending.size} (#{counts.join(', ')})"
|
|
219
230
|
end
|
|
231
|
+
[ "", "**Pending migrations:** #{total}", *pending ]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def no_migrations_line(db)
|
|
235
|
+
reason = db.replica ? "it is a replica" : "its database.yml entry turns `database_tasks` off"
|
|
236
|
+
[ "", "_Rails runs no migrations on #{db.name}: #{reason}._" ]
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def gather_index_usage(conn, family)
|
|
240
|
+
AppDatabases.bounded(conn) { family.index_usage(conn) } if family.known?
|
|
220
241
|
rescue => e
|
|
221
|
-
RailsAiContext.debug_fail(e,
|
|
242
|
+
RailsAiContext.debug_fail(e, :unread, label: "gather_index_usage")
|
|
222
243
|
end
|
|
223
244
|
|
|
224
245
|
# ── Cache section ────────────────────────────────────────────────
|
|
@@ -271,17 +292,33 @@ module RailsAiContext
|
|
|
271
292
|
store = (env[:notable] || {})["cache_store"]
|
|
272
293
|
next unless store && !store.match?(PROCESS_STORES)
|
|
273
294
|
|
|
274
|
-
# The store's name; its options are
|
|
295
|
+
# The store's name; its options are the env_config section's to show, unshortened.
|
|
275
296
|
"- #{env[:name]}: `#{store[/\A:\w+/] || store}` (`#{env[:file]}`)"
|
|
276
297
|
end
|
|
277
298
|
end
|
|
278
299
|
|
|
300
|
+
# Solid Cache answers #stats from its databases, one per shard, and
|
|
301
|
+
# connecting to a SQLite file that is not there creates it.
|
|
302
|
+
def cache_database_missing(cache)
|
|
303
|
+
return unless defined?(::SolidCache::Store) && cache.is_a?(::SolidCache::Store)
|
|
304
|
+
|
|
305
|
+
cache.with_each_connection do
|
|
306
|
+
config = ::SolidCache::Record.connection_db_config
|
|
307
|
+
AppDatabases.present!(config.name, config)
|
|
308
|
+
end
|
|
309
|
+
nil
|
|
310
|
+
rescue AppDatabases::Missing, AppDatabases::Unreachable => e
|
|
311
|
+
e.message
|
|
312
|
+
end
|
|
313
|
+
|
|
279
314
|
def gather_cache
|
|
280
315
|
cache = Rails.cache
|
|
281
316
|
lines = [ "## Cache", "" ]
|
|
282
|
-
lines << "**Store:** #{cache.class.name.
|
|
317
|
+
lines << "**Store:** #{cache.class.name.delete_prefix("ActiveSupport::Cache::")}, this MCP server process's `Rails.cache` (#{rails_env_name})"
|
|
283
318
|
|
|
284
|
-
if cache
|
|
319
|
+
if (missing = cache_database_missing(cache))
|
|
320
|
+
lines << "_#{missing}_"
|
|
321
|
+
elsif cache.respond_to?(:stats)
|
|
285
322
|
cache_stat_lines(cache.stats).each { |line| lines << line }
|
|
286
323
|
elsif cache.respond_to?(:redis)
|
|
287
324
|
begin
|
|
@@ -309,8 +346,8 @@ module RailsAiContext
|
|
|
309
346
|
lines << "" << cache_scope(cache)
|
|
310
347
|
elsewhere = configured_elsewhere
|
|
311
348
|
if elsewhere.any?
|
|
312
|
-
|
|
313
|
-
|
|
349
|
+
options = pointer("rails_get_env_config", %(environment:"..."), "shows each one's options.")
|
|
350
|
+
lines.push("", "**The app's cache in other environments:**", *elsewhere, *("_#{options}_" if options))
|
|
314
351
|
end
|
|
315
352
|
|
|
316
353
|
lines << ""
|
|
@@ -13,6 +13,22 @@ module RailsAiContext
|
|
|
13
13
|
# model's context instead, with enough detail to retry or route around
|
|
14
14
|
# the failure.
|
|
15
15
|
module SafeCall
|
|
16
|
+
# The content with the note ending its first text block, or the content
|
|
17
|
+
# itself when there is no note or no text to end.
|
|
18
|
+
def self.noted(content, note)
|
|
19
|
+
first = Array(content).first
|
|
20
|
+
return content unless note && first.is_a?(Hash) && first[:type] == "text"
|
|
21
|
+
|
|
22
|
+
body = first[:text].to_s
|
|
23
|
+
[ first.merge(text: body.strip.empty? ? note.lstrip : "#{body}#{note}") ] + content[1..].to_a
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The parameters tools/list shows, which for rails_get_config leaves out those of a section turned off.
|
|
27
|
+
def self.published_params(tool)
|
|
28
|
+
schema = tool.respond_to?(:input_schema_value) ? tool.input_schema_value : tool.input_schema
|
|
29
|
+
(schema&.to_h&.dig(:properties) || {}).keys.map(&:to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
16
32
|
def call(**kwargs)
|
|
17
33
|
# An MCP client sends whatever keys it likes. Reaching the tool with
|
|
18
34
|
# one it does not declare answers with an unknown-keyword ArgumentError
|
|
@@ -27,10 +43,6 @@ module RailsAiContext
|
|
|
27
43
|
# have its note consumed by the inner one.
|
|
28
44
|
discarded = discarded_detail(kwargs)
|
|
29
45
|
kwargs = listify_arrays(normalize_detail(kwargs))
|
|
30
|
-
# Before the call reads anything: an edit since the last call drops
|
|
31
|
-
# the caches and reloads the app's code (see the method).
|
|
32
|
-
RailsAiContext::Tools::BaseTool.refresh_if_files_changed!
|
|
33
|
-
|
|
34
46
|
answer = -> { answer_call(discarded) { super(**kwargs) } }
|
|
35
47
|
|
|
36
48
|
# Only the call the client made is its query. A composite tool answers
|
|
@@ -39,14 +51,27 @@ module RailsAiContext
|
|
|
39
51
|
# first inner call took the outer one's params with it.
|
|
40
52
|
return answer.call if Thread.current[:rails_ai_context_call]
|
|
41
53
|
|
|
42
|
-
|
|
54
|
+
# Before the call reads anything: an edit since the last call drops
|
|
55
|
+
# the caches and reloads the app's code (see the method).
|
|
56
|
+
RailsAiContext::Tools::BaseTool.refresh_if_files_changed!
|
|
57
|
+
call = Thread.current[:rails_ai_context_call] = { tool: self, params: session_params(kwargs) }
|
|
43
58
|
begin
|
|
44
|
-
answer.call
|
|
59
|
+
with_crashes(answer.call, call[:crashed])
|
|
45
60
|
ensure
|
|
46
61
|
Thread.current[:rails_ai_context_call] = nil
|
|
47
62
|
end
|
|
48
63
|
end
|
|
49
64
|
|
|
65
|
+
def unknown_param_response(unknown, known = SafeCall.published_params(self))
|
|
66
|
+
lines = unknown.map do |name|
|
|
67
|
+
suggestion = RailsAiContext::Tools::BaseTool.find_closest_match(name, known)
|
|
68
|
+
suggestion ? "'#{name}' - did you mean '#{suggestion}'?" : "'#{name}'"
|
|
69
|
+
end
|
|
70
|
+
valid = known.any? ? "Valid params: #{known.join(', ')}" : "This tool takes no params."
|
|
71
|
+
text = "Unknown param#{"s" if unknown.size > 1}: #{lines.join(', ')}\n#{valid}"
|
|
72
|
+
MCP::Tool::Response.new([ { type: "text", text: text } ], error: true)
|
|
73
|
+
end
|
|
74
|
+
|
|
50
75
|
private
|
|
51
76
|
|
|
52
77
|
def answer_call(discarded)
|
|
@@ -61,16 +86,40 @@ module RailsAiContext
|
|
|
61
86
|
RailsAiContext::CodeReloader.with_app_code do
|
|
62
87
|
begin
|
|
63
88
|
# One call is one run: what it reads per controller or per route is read once.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
#
|
|
68
|
-
|
|
69
|
-
|
|
89
|
+
response, left_out = with_left_out_frame { RailsAiContext::RunCache.around { yield } }
|
|
90
|
+
response = append_note(response, RailsAiContext::Tools::BaseTool.left_out_note(left_out))
|
|
91
|
+
append_note(response, invalid_detail_note(discarded))
|
|
92
|
+
# An app file that cannot load, a stack overflow or a SecurityError costs that call,
|
|
93
|
+
# where a StandardError rescue alone would let it end the stdio server and every tool with it.
|
|
94
|
+
rescue StandardError, ScriptError, SystemStackError, SecurityError => e
|
|
95
|
+
failure_response(e).tap do |failure|
|
|
96
|
+
call = Thread.current[:rails_ai_context_call]
|
|
97
|
+
(call[:crashed] ||= []) << failure.content.first[:text] if call
|
|
98
|
+
end
|
|
70
99
|
end
|
|
71
100
|
end
|
|
72
101
|
end
|
|
73
102
|
|
|
103
|
+
# A tool this call ran crashed: the answer is an error, and it says so where its text left the crash out.
|
|
104
|
+
def with_crashes(response, crashes)
|
|
105
|
+
return response unless crashes && response.respond_to?(:error?) && !response.error?
|
|
106
|
+
|
|
107
|
+
text = response.content.first&.dig(:text).to_s
|
|
108
|
+
unsaid = crashes.uniq.reject { |failure| text.include?(failure.lines.first.strip) }
|
|
109
|
+
rebuilt(response, SafeCall.noted(response.content, ("\n\n#{unsaid.join("\n\n")}" if unsaid.any?)), error: true)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The tools this call's composed sections left out, kept apart from
|
|
113
|
+
# those of any tool it calls.
|
|
114
|
+
def with_left_out_frame
|
|
115
|
+
stack = (Thread.current[RailsAiContext::Tools::BaseTool::LEFT_OUT] ||= [])
|
|
116
|
+
stack.push([])
|
|
117
|
+
response = yield
|
|
118
|
+
[ response, stack.last.uniq ]
|
|
119
|
+
ensure
|
|
120
|
+
stack.pop
|
|
121
|
+
end
|
|
122
|
+
|
|
74
123
|
# The declared params are the contract, so a tool whose method takes
|
|
75
124
|
# **kwargs refuses the same keys as one that names them.
|
|
76
125
|
def unknown_params(kwargs)
|
|
@@ -80,17 +129,6 @@ module RailsAiContext
|
|
|
80
129
|
RailsAiContext::Tools::BaseTool.unknown_param_names(kwargs.keys, properties)
|
|
81
130
|
end
|
|
82
131
|
|
|
83
|
-
def unknown_param_response(unknown)
|
|
84
|
-
known = (input_schema.to_h[:properties] || {}).keys.map(&:to_s)
|
|
85
|
-
lines = unknown.map do |name|
|
|
86
|
-
suggestion = RailsAiContext::Tools::BaseTool.find_closest_match(name, known)
|
|
87
|
-
suggestion ? "'#{name}' - did you mean '#{suggestion}'?" : "'#{name}'"
|
|
88
|
-
end
|
|
89
|
-
valid = known.any? ? "Valid params: #{known.join(', ')}" : "This tool takes no params."
|
|
90
|
-
text = "Unknown param#{"s" if unknown.size > 1}: #{lines.join(', ')}\n#{valid}"
|
|
91
|
-
MCP::Tool::Response.new([ { type: "text", text: text } ], error: true)
|
|
92
|
-
end
|
|
93
|
-
|
|
94
132
|
# A string where the schema declares an array is a list of one, and a
|
|
95
133
|
# comma-separated one is the list the CLI documents (`a.rb,b.rb`): an MCP
|
|
96
134
|
# client can send either, and review_changes answered a string with
|
|
@@ -108,6 +146,9 @@ module RailsAiContext
|
|
|
108
146
|
|
|
109
147
|
def failure_response(error)
|
|
110
148
|
label = respond_to?(:tool_name) ? tool_name : name
|
|
149
|
+
# A section renderer answers inside rails_get_config, the tool the caller named.
|
|
150
|
+
section = RailsAiContext::Tools::GetConfig.retired_section(label)
|
|
151
|
+
label = "rails_get_config (section #{section.name})" if section && section.renderer.equal?(self)
|
|
111
152
|
origin = portable_origin(Array(error.backtrace).first.to_s)
|
|
112
153
|
text = +"Tool #{label} failed: #{error.class}: #{error.message.to_s.lines.first&.strip}\n"
|
|
113
154
|
text << "At: #{origin}\n" unless origin.empty?
|
|
@@ -161,12 +202,13 @@ module RailsAiContext
|
|
|
161
202
|
def append_note(response, note)
|
|
162
203
|
return response unless note && response.respond_to?(:content)
|
|
163
204
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return response unless first.is_a?(Hash) && first[:type] == "text"
|
|
205
|
+
noted = SafeCall.noted(response.content, note)
|
|
206
|
+
return response if noted.equal?(response.content)
|
|
167
207
|
|
|
168
|
-
noted
|
|
208
|
+
rebuilt(response, noted, error: response.error?)
|
|
209
|
+
end
|
|
169
210
|
|
|
211
|
+
def rebuilt(response, content, error:)
|
|
170
212
|
# `meta` arrived in mcp 0.13, which is the gemspec floor, but the
|
|
171
213
|
# reader is still asked for rather than assumed: getting it wrong
|
|
172
214
|
# turns a good answer into a NoMethodError that SafeCall's own rescue
|
|
@@ -174,7 +216,7 @@ module RailsAiContext
|
|
|
174
216
|
extra = { structured_content: response.structured_content }
|
|
175
217
|
extra[:meta] = response.meta if response.respond_to?(:meta)
|
|
176
218
|
|
|
177
|
-
MCP::Tool::Response.new(
|
|
219
|
+
MCP::Tool::Response.new(content, error: error, **extra)
|
|
178
220
|
end
|
|
179
221
|
end
|
|
180
222
|
end
|
|
@@ -395,8 +395,6 @@ module RailsAiContext
|
|
|
395
395
|
.reject { |r| sensitive_file?(r[:file]) || sensitive_in_tree?(r[:file], trees, root) }
|
|
396
396
|
.first(max_results)
|
|
397
397
|
[ rows, failed ]
|
|
398
|
-
rescue => e
|
|
399
|
-
[ [ { file: "error", line_number: 0, content: e.message } ], false ]
|
|
400
398
|
end
|
|
401
399
|
|
|
402
400
|
# rg opens sensitive files too, and one it cannot read is not an error
|
|
@@ -862,8 +860,6 @@ module RailsAiContext
|
|
|
862
860
|
return definition_missing_response(lines.join("\n")) if def_results.empty?
|
|
863
861
|
|
|
864
862
|
text_response(lines.join("\n"))
|
|
865
|
-
rescue => e
|
|
866
|
-
text_response("Trace error: #{e.message}")
|
|
867
863
|
end
|
|
868
864
|
|
|
869
865
|
SCOPE_WRAPPERS = %w[scope lambda proc].freeze
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
3
5
|
require "net/http"
|
|
4
6
|
require "uri"
|
|
5
7
|
|
|
@@ -37,7 +39,7 @@ module RailsAiContext
|
|
|
37
39
|
)
|
|
38
40
|
|
|
39
41
|
guide_row(
|
|
40
|
-
order:
|
|
42
|
+
order: 26,
|
|
41
43
|
mcp: "rails_search_docs(query:\"X\")",
|
|
42
44
|
cli_args: "query=X",
|
|
43
45
|
summary: "Bundled topic index with weighted keyword search, on-demand GitHub fetch"
|
|
@@ -36,7 +36,7 @@ module RailsAiContext
|
|
|
36
36
|
)
|
|
37
37
|
|
|
38
38
|
guide_row(
|
|
39
|
-
order:
|
|
39
|
+
order: 21,
|
|
40
40
|
mcp: "rails_security_scan",
|
|
41
41
|
summary: "Brakeman static analysis: SQL injection, XSS, mass assignment"
|
|
42
42
|
)
|
|
@@ -94,8 +94,12 @@ module RailsAiContext
|
|
|
94
94
|
# can still answer - from outside the bundle, which is where it is.
|
|
95
95
|
unbundled_scan(min_confidence, resolved_checks)
|
|
96
96
|
end
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
if scan.nil? || scan.key?(:unavailable)
|
|
98
|
+
# A run that said why it failed did start; one that could not start is brakeman not being there.
|
|
99
|
+
message = unavailable_message(scan&.dig(:unavailable))
|
|
100
|
+
return scan&.dig(:unavailable) ? error_response(message) : text_response(message)
|
|
101
|
+
end
|
|
102
|
+
return error_response("Brakeman scan failed: #{portable_error(scan[:error])}") if scan[:error]
|
|
99
103
|
|
|
100
104
|
warnings = scan[:warnings]
|
|
101
105
|
checks_run = scan[:checks_run]
|
|
@@ -147,7 +151,7 @@ module RailsAiContext
|
|
|
147
151
|
|
|
148
152
|
report, failure = run_brakeman_unbundled(min_confidence, resolved_checks)
|
|
149
153
|
unless report.is_a?(Hash) && report["warnings"].is_a?(Array)
|
|
150
|
-
failure = echo_checks(failure, resolved_checks)
|
|
154
|
+
failure = echo_checks(report.nil? ? failure : "its report could not be read", resolved_checks)
|
|
151
155
|
# A check name brakeman does not know is the caller's to fix, and
|
|
152
156
|
# is answered as the in-process scan answers it: the Gemfile advice
|
|
153
157
|
# an unavailable scan gets would send them to change nothing wrong.
|
|
@@ -311,7 +315,7 @@ module RailsAiContext
|
|
|
311
315
|
|
|
312
316
|
_out, err = with_unbundled_env { capture_with_timeout(command) }
|
|
313
317
|
next [ nil, "no report after #{SCAN_TIMEOUT} seconds" ] if err.nil?
|
|
314
|
-
next [ nil, brakeman_error_line(err) ] unless File.file?(report_path) && File.size?(report_path)
|
|
318
|
+
next [ nil, brakeman_error_line(err) || "it wrote no report and printed nothing" ] unless File.file?(report_path) && File.size?(report_path)
|
|
315
319
|
|
|
316
320
|
[ JSON.parse(File.read(report_path)), nil ]
|
|
317
321
|
end
|