rails-ai-context 5.30.0 → 5.30.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +159 -0
- data/docs/ARCHITECTURE.md +5 -1
- data/docs/CLI.md +1 -1
- data/docs/COMPATIBILITY.md +7 -1
- data/docs/CONFIGURATION.md +2 -2
- data/docs/FAQ.md +1 -1
- data/docs/GUIDE.md +1 -1
- data/exe/rails-ai-context +3 -4
- data/lib/rails_ai_context/action_filters.rb +69 -6
- data/lib/rails_ai_context/boot_manager.rb +21 -5
- data/lib/rails_ai_context/cli/entry_boot.rb +3 -2
- data/lib/rails_ai_context/cli/tool_runner.rb +22 -3
- data/lib/rails_ai_context/concern_paths.rb +2 -1
- data/lib/rails_ai_context/configuration.rb +6 -6
- data/lib/rails_ai_context/erb_source.rb +22 -3
- data/lib/rails_ai_context/introspector.rb +13 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +43 -16
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +10 -15
- data/lib/rails_ai_context/introspectors/eager_load.rb +22 -10
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +4 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +24 -10
- data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +12 -0
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +9 -1
- data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +13 -6
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +83 -27
- data/lib/rails_ai_context/introspectors/listeners/with_options_scope.rb +49 -12
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +15 -0
- data/lib/rails_ai_context/introspectors/migration_replay.rb +2 -0
- data/lib/rails_ai_context/introspectors/model_introspector.rb +14 -3
- data/lib/rails_ai_context/introspectors/pg_partitions.rb +73 -0
- data/lib/rails_ai_context/introspectors/route_introspector.rb +62 -4
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +17 -2
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +13 -7
- data/lib/rails_ai_context/introspectors/schema_reader.rb +28 -2
- data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +8 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +18 -16
- data/lib/rails_ai_context/migration_status.rb +6 -1
- data/lib/rails_ai_context/pending_migrations.rb +3 -2
- data/lib/rails_ai_context/portable_path.rb +14 -0
- data/lib/rails_ai_context/schema_adapter.rb +6 -1
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +2 -2
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -0
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -1
- data/lib/rails_ai_context/tools/base_tool.rb +9 -8
- data/lib/rails_ai_context/tools/diagnose.rb +4 -0
- data/lib/rails_ai_context/tools/generate_test.rb +19 -0
- data/lib/rails_ai_context/tools/get_model_details.rb +1 -1
- data/lib/rails_ai_context/tools/get_partial_interface.rb +18 -4
- data/lib/rails_ai_context/tools/get_routes.rb +19 -9
- data/lib/rails_ai_context/tools/get_schema.rb +24 -5
- data/lib/rails_ai_context/tools/get_service_pattern.rb +44 -25
- data/lib/rails_ai_context/tools/get_test_info.rb +8 -0
- data/lib/rails_ai_context/tools/migration_advisor.rb +1 -1
- data/lib/rails_ai_context/tools/query.rb +8 -7
- data/lib/rails_ai_context/tools/runtime_info.rb +13 -10
- data/lib/rails_ai_context/tools/validate_semantics.rb +56 -14
- data/lib/rails_ai_context/version.rb +1 -1
- data/server.json +1 -1
- metadata +2 -1
|
@@ -49,7 +49,7 @@ module RailsAiContext
|
|
|
49
49
|
context = {} unless context.is_a?(Hash)
|
|
50
50
|
schema = context[:schema]
|
|
51
51
|
observed = schema.is_a?(Hash) ? schema[:adapter] : nil
|
|
52
|
-
return observed unless placeholder?(observed)
|
|
52
|
+
return display(observed) unless placeholder?(observed)
|
|
53
53
|
|
|
54
54
|
# Best first: the app's own database config, which needs no connection.
|
|
55
55
|
# Then what the schema proves, then the example database files (often SQLite for
|
|
@@ -58,6 +58,11 @@ module RailsAiContext
|
|
|
58
58
|
from_examples(context) || from_gems(context) || "unknown"
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# A connection's adapter_name as the other surfaces name it (PostGIS is PostgreSQL).
|
|
62
|
+
def display(adapter_name)
|
|
63
|
+
DIALECTS.fetch(adapter_name.to_s.downcase, adapter_name)
|
|
64
|
+
end
|
|
65
|
+
|
|
61
66
|
def label_with_reason(context)
|
|
62
67
|
shown = label(context)
|
|
63
68
|
undecided?(shown) ? "#{shown}, #{undecided_reason(context, shown)}" : shown
|
|
@@ -104,7 +104,7 @@ module RailsAiContext
|
|
|
104
104
|
col_str = col_sample.any? ? " - #{col_sample.join(', ')}" : ""
|
|
105
105
|
|
|
106
106
|
# Foreign keys
|
|
107
|
-
fks = (data[:foreign_keys] || []).map { |f| "#{f[:column]}→#{f[:to_table]}" }
|
|
107
|
+
fks = (data[:foreign_keys] || []).map { |f| "#{Introspectors::SchemaConventions.key_text(f[:column])}→#{f[:to_table]}" }
|
|
108
108
|
fk_str = fks.any? ? " | FK: #{fks.join(', ')}" : ""
|
|
109
109
|
|
|
110
110
|
# Key indexes (unique or composite)
|
|
@@ -103,8 +103,8 @@ module RailsAiContext
|
|
|
103
103
|
filepath = File.join(output_dir, filename)
|
|
104
104
|
|
|
105
105
|
# generate_root_files = false is a deliberate omission, so it is
|
|
106
|
-
# reported rather than dropped.
|
|
107
|
-
unless generate_root
|
|
106
|
+
# reported rather than dropped. The JSON dump is no tool's root file.
|
|
107
|
+
unless generate_root || fmt == :json
|
|
108
108
|
not_applicable[filepath] = "root files disabled"
|
|
109
109
|
next
|
|
110
110
|
end
|
|
@@ -42,6 +42,11 @@ module RailsAiContext
|
|
|
42
42
|
# user content above/below the gem-managed block survives every
|
|
43
43
|
# `rails ai:context` regeneration.
|
|
44
44
|
cursorrules_path = File.join(output_dir, ".cursorrules")
|
|
45
|
+
unless RailsAiContext.configuration.generate_root_files
|
|
46
|
+
result[:not_applicable][cursorrules_path] = "root files disabled"
|
|
47
|
+
return result
|
|
48
|
+
end
|
|
49
|
+
|
|
45
50
|
case SectionMarkerWriter.write_with_markers(cursorrules_path, render_cursorrules_legacy)
|
|
46
51
|
when :written then result[:written] << cursorrules_path
|
|
47
52
|
when :skipped then result[:skipped] << cursorrules_path
|
|
@@ -25,7 +25,7 @@ def print_result(result)
|
|
|
25
25
|
end unless defined?(print_result)
|
|
26
26
|
|
|
27
27
|
def abort_boot_failure(result, timeout)
|
|
28
|
-
$stderr.puts "Error: Rails app failed to boot: #{result.failure_summary}"
|
|
28
|
+
$stderr.puts "Error: Rails app failed to boot: #{result.failure_summary(full: true)}"
|
|
29
29
|
if result.error.is_a?(RailsAiContext::BootManager::BootTimeoutError)
|
|
30
30
|
$stderr.puts " If the app is healthy but slow, raise RAILS_AI_CONTEXT_BOOT_TIMEOUT (seconds, current: #{timeout})."
|
|
31
31
|
end
|
|
@@ -887,15 +887,16 @@ module RailsAiContext
|
|
|
887
887
|
#
|
|
888
888
|
# @return [MCP::Tool::Response, nil] the error result, or nil to carry on
|
|
889
889
|
def refuse_unsafe_paths(paths)
|
|
890
|
-
refused = Array(paths).compact.reject { |path| path.to_s.strip.empty? }.filter_map
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
end
|
|
894
|
-
return nil if refused.empty?
|
|
890
|
+
refused = Array(paths).compact.reject { |path| path.to_s.strip.empty? }.filter_map { |path| unsafe_path_message(path) }
|
|
891
|
+
error_response(refused.join("\n")) if refused.any?
|
|
892
|
+
end
|
|
895
893
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
894
|
+
# @return [String, nil] the refusal for a path the caller may not read
|
|
895
|
+
def unsafe_path_message(path)
|
|
896
|
+
case RailsAiContext::SafePath.locate(path.to_s, under: rails_app.root.to_s).refusal
|
|
897
|
+
when :traversal, :outside then "Path not allowed: #{path}"
|
|
898
|
+
when :sensitive then "Path not allowed: #{path} (sensitive file)"
|
|
899
|
+
end
|
|
899
900
|
end
|
|
900
901
|
|
|
901
902
|
# Resolve a Dir.glob result to a realpath that is:
|
|
@@ -114,6 +114,9 @@ module RailsAiContext
|
|
|
114
114
|
|
|
115
115
|
parsed = parse_error(error)
|
|
116
116
|
classification = classify_error(parsed)
|
|
117
|
+
# A refused path is said once and then read, traced and suggested nowhere.
|
|
118
|
+
refusal = file && unsafe_path_message(file)
|
|
119
|
+
file = nil if refusal
|
|
117
120
|
|
|
118
121
|
lines = [ "# Error Diagnosis", "" ]
|
|
119
122
|
lines << "**Error:** `#{parsed[:exception_class] || 'Unknown'}`"
|
|
@@ -135,6 +138,7 @@ module RailsAiContext
|
|
|
135
138
|
|
|
136
139
|
# Gather context based on parameters and error type
|
|
137
140
|
context_sections = gather_context(parsed, classification, file, line, action)
|
|
141
|
+
context_sections = [ "## Code Context", refusal, "" ] + context_sections if refusal
|
|
138
142
|
|
|
139
143
|
# Recent git changes
|
|
140
144
|
git_section = gather_git_context(file, parsed[:file_refs])
|
|
@@ -393,6 +393,25 @@ module RailsAiContext
|
|
|
393
393
|
end
|
|
394
394
|
when "format"
|
|
395
395
|
lines << " @#{setup_var}.#{attr} = \"invalid-format\""
|
|
396
|
+
when "absence"
|
|
397
|
+
lines << " @#{setup_var}.#{attr} = \"present\""
|
|
398
|
+
when "confirmation"
|
|
399
|
+
lines << " @#{setup_var}.#{attr} = \"secret\""
|
|
400
|
+
lines << " @#{setup_var}.#{attr}_confirmation = \"different\""
|
|
401
|
+
when "acceptance"
|
|
402
|
+
lines << " @#{setup_var}.#{attr} = \"0\""
|
|
403
|
+
when "exclusion"
|
|
404
|
+
excluded = v.dig(:options, :in)
|
|
405
|
+
if excluded.is_a?(Array) && excluded.any?
|
|
406
|
+
lines << " @#{setup_var}.#{attr} = #{excluded.first.inspect}"
|
|
407
|
+
else
|
|
408
|
+
lines.push(" skip \"set #{attr} to a value the exclusion list holds\"", " end", "")
|
|
409
|
+
next
|
|
410
|
+
end
|
|
411
|
+
else
|
|
412
|
+
# With no value known to break it, the assert would run on a valid record.
|
|
413
|
+
lines.push(" skip \"implement #{v[:kind]} validation test\"", " end", "")
|
|
414
|
+
next
|
|
396
415
|
end
|
|
397
416
|
lines << " assert_not @#{setup_var}.valid?#{"(:#{validation_context(v)})" if validation_context(v)}"
|
|
398
417
|
lines << " end"
|
|
@@ -213,7 +213,7 @@ module RailsAiContext
|
|
|
213
213
|
detail += " [polymorphic]" if a[:polymorphic]
|
|
214
214
|
detail += (a[:optional] == true ? " [optional]" : " [optional: #{a[:optional]}]") if a[:optional]
|
|
215
215
|
detail += " dependent: #{a[:dependent]}" if a[:dependent]
|
|
216
|
-
detail += " (fk: #{a[:foreign_key]})" if a[:foreign_key] && a[:type] == "belongs_to"
|
|
216
|
+
detail += " (fk: #{Introspectors::SchemaConventions.key_text(a[:foreign_key])})" if a[:foreign_key] && a[:type] == "belongs_to"
|
|
217
217
|
detail += " [UNAVAILABLE: #{a[:unavailable]}]" if a[:unavailable]
|
|
218
218
|
lines << detail
|
|
219
219
|
end
|
|
@@ -369,7 +369,21 @@ module RailsAiContext
|
|
|
369
369
|
|
|
370
370
|
relative = file.sub("#{root}/", "")
|
|
371
371
|
|
|
372
|
-
content.
|
|
372
|
+
lines = content.lines
|
|
373
|
+
# Per call, not per line: `render(` and a call split over lines are
|
|
374
|
+
# one call whose arguments the line alone does not hold.
|
|
375
|
+
covered = 0
|
|
376
|
+
Introspectors::ViewTemplateIntrospector.render_calls(content).each do |at, args|
|
|
377
|
+
args = args.split("%>", 2).first
|
|
378
|
+
# A render inside another's arguments is already part of that call's site.
|
|
379
|
+
next if at < covered
|
|
380
|
+
|
|
381
|
+
covered = at + args.length
|
|
382
|
+
line_num = content[0...at].count("\n") + 1
|
|
383
|
+
line = "render #{args.gsub(/\s+/, " ").strip}"
|
|
384
|
+
spanned = lines[(line_num - 1)..(line_num - 1 + args.chomp.count("\n"))]
|
|
385
|
+
snippet = spanned.size > 1 ? spanned.join(" ").squish : lines[line_num - 1].strip
|
|
386
|
+
|
|
373
387
|
matched_line = false
|
|
374
388
|
search_patterns.each do |search_name|
|
|
375
389
|
# Match render "partial_name" or render partial: "partial_name"
|
|
@@ -389,10 +403,10 @@ module RailsAiContext
|
|
|
389
403
|
file: relative,
|
|
390
404
|
line: line_num,
|
|
391
405
|
locals: locals_passed,
|
|
392
|
-
snippet:
|
|
406
|
+
snippet: snippet
|
|
393
407
|
}
|
|
394
408
|
matched_line = true
|
|
395
|
-
break # one match per
|
|
409
|
+
break # one match per call is enough
|
|
396
410
|
end
|
|
397
411
|
|
|
398
412
|
next if matched_line
|
|
@@ -408,7 +422,7 @@ module RailsAiContext
|
|
|
408
422
|
file: relative,
|
|
409
423
|
line: line_num,
|
|
410
424
|
locals: [],
|
|
411
|
-
snippet:
|
|
425
|
+
snippet: snippet
|
|
412
426
|
}
|
|
413
427
|
break
|
|
414
428
|
end
|
|
@@ -75,26 +75,36 @@ module RailsAiContext
|
|
|
75
75
|
# Routes in an engine's table are counted apart from the app's on both tiers. Booted
|
|
76
76
|
# reads the engine's whole table; static reads what the app's route files draw into it.
|
|
77
77
|
private_class_method def self.engine_routes_lines(engine_routes)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
groups.each do |group|
|
|
83
|
-
at = group[:mount] ? " at `#{group[:mount]}`" : " (not mounted by the app's routes)"
|
|
84
|
-
at += " (also #{group[:also_mounted_at].map { |path| "`#{path}`" }.join(', ')})" if group[:also_mounted_at]
|
|
78
|
+
unread, drawn = Array(engine_routes).partition { |group| group[:unavailable] }
|
|
79
|
+
lines = []
|
|
80
|
+
lines << "" << "## Routes drawn into mounted engines" if drawn.any?
|
|
81
|
+
drawn.each do |group|
|
|
85
82
|
whole = group[:whole_table] ? " in the engine's whole table, gem-drawn ones included" : ""
|
|
86
83
|
unexpanded = group[:dynamic_routes].to_i.positive? ? ", #{count_phrase(group[:dynamic_routes], "dynamic construct")} not expanded" : ""
|
|
87
|
-
lines << "- **#{group[:engine]}**#{
|
|
84
|
+
lines << "- **#{group[:engine]}**#{mounted_at(group)}: #{count_phrase(Array(group[:routes]).size, "route")}#{whole}#{unexpanded}, not in the count above"
|
|
85
|
+
end
|
|
86
|
+
lines << "" << "## Mounted engines whose routes were not read" if unread.any?
|
|
87
|
+
unread.each do |group|
|
|
88
|
+
lines << "- **#{group[:engine]}**#{mounted_at(group)}: #{RailsAiContext::Confidence.unavailable(group[:unavailable])}"
|
|
88
89
|
end
|
|
89
90
|
lines
|
|
90
91
|
end
|
|
91
92
|
|
|
93
|
+
private_class_method def self.mounted_at(group)
|
|
94
|
+
at = if group[:mount] then " at `#{group[:mount]}`"
|
|
95
|
+
elsif group[:mount_computed] then " at a path the routes compute"
|
|
96
|
+
else " (not mounted by the app's routes)"
|
|
97
|
+
end
|
|
98
|
+
at += " (also #{group[:also_mounted_at].map { |path| "`#{path}`" }.join(', ')})" if group[:also_mounted_at]
|
|
99
|
+
at
|
|
100
|
+
end
|
|
101
|
+
|
|
92
102
|
# The routes a controller filter matches in the engines' tables, one
|
|
93
103
|
# group per controller labelled with its engine, listed like the app's.
|
|
94
104
|
private_class_method def self.engine_controller_groups(engine_routes, needles)
|
|
95
105
|
Array(engine_routes).each_with_object({}) do |group, found|
|
|
96
106
|
matches = Array(group[:routes]).select { |r| needles.any? { |n| path_segments_match?(r[:controller].to_s, n, tail: true) } }
|
|
97
|
-
where = group[:mount] ? "" : ", not mounted by the app's routes"
|
|
107
|
+
where = group[:mount] || group[:mount_computed] ? "" : ", not mounted by the app's routes"
|
|
98
108
|
matches.group_by { |r| r[:controller] }.each do |ctrl, rows|
|
|
99
109
|
found["#{ctrl} (in #{group[:engine]}'s table#{where})"] = rows
|
|
100
110
|
end
|
|
@@ -77,9 +77,11 @@ module RailsAiContext
|
|
|
77
77
|
# The key is the caller's own string when nothing matched, and
|
|
78
78
|
# the declared names are table names: `OrderComments` and
|
|
79
79
|
# `order_comments` are the same request.
|
|
80
|
-
|
|
80
|
+
declared = table_key.to_s.underscore
|
|
81
|
+
if declared_not_connected(schema).include?(declared)
|
|
81
82
|
return text_response(
|
|
82
|
-
"Table '#{
|
|
83
|
+
"Table '#{declared}' is declared in db/schema.rb and missing from the connected database. " \
|
|
84
|
+
"#{pending_for_table(schema, declared)}" \
|
|
83
85
|
"Run `rails db:migrate`, or pass `--no-boot` to read the declaration instead."
|
|
84
86
|
)
|
|
85
87
|
end
|
|
@@ -87,7 +89,7 @@ module RailsAiContext
|
|
|
87
89
|
return not_found_response("Table", table, tables.keys.sort,
|
|
88
90
|
recovery_tool: "Call rails_get_schema(detail:\"summary\") to see all tables")
|
|
89
91
|
end
|
|
90
|
-
return json_response(table_data) if format == "json"
|
|
92
|
+
return json_response(table_data.except(:unread_calls)) if format == "json"
|
|
91
93
|
|
|
92
94
|
output = format_table_markdown(table_key, table_data, models_data)
|
|
93
95
|
# Cross-reference hint for AI: suggest next tool call
|
|
@@ -326,6 +328,19 @@ module RailsAiContext
|
|
|
326
328
|
RailsAiContext.debug_fail(e, [], label: "models_for_table")
|
|
327
329
|
end
|
|
328
330
|
|
|
331
|
+
# The pending migrations named after the table, or all of them when none
|
|
332
|
+
# is: a migration that adds a table is not always named for it.
|
|
333
|
+
private_class_method def self.pending_for_table(schema, table)
|
|
334
|
+
pending = Array(schema[:pending_migrations])
|
|
335
|
+
return "" if pending.empty?
|
|
336
|
+
|
|
337
|
+
named = pending.select { |m| m[:name].to_s.underscore.include?(table) }
|
|
338
|
+
named = pending if named.empty?
|
|
339
|
+
shown = named.first(5).map { |m| "#{m[:version]} #{m[:name]}".strip }.join(", ")
|
|
340
|
+
more = named.size > 5 ? " (+#{named.size - 5} more)" : ""
|
|
341
|
+
"Pending: #{shown}#{more}. "
|
|
342
|
+
end
|
|
343
|
+
|
|
329
344
|
# The tables db/schema.rb declares that the connected database does not
|
|
330
345
|
# have. Empty unless both sides are known, which is the booted tier
|
|
331
346
|
# with a schema file to read.
|
|
@@ -390,7 +405,8 @@ module RailsAiContext
|
|
|
390
405
|
# with :tables cut down to the page the same pagination produced for
|
|
391
406
|
# markdown. `detail` still decides how many tables a page holds.
|
|
392
407
|
private_class_method def self.json_page_response(schema, tables, names, models = {})
|
|
393
|
-
|
|
408
|
+
# unread_calls is the static reader's note to validate; the booted tier has none.
|
|
409
|
+
page = names.to_h { |name| [ name, tables[name].is_a?(Hash) ? tables[name].except(:unread_calls) : tables[name] ] }
|
|
394
410
|
coverage = model_coverage(tables, models)
|
|
395
411
|
json_response(schema.merge(tables: page, gem_owned_tables: coverage[:gems], tables_without_model_file: coverage[:unclaimed]))
|
|
396
412
|
end
|
|
@@ -402,6 +418,8 @@ module RailsAiContext
|
|
|
402
418
|
private_class_method def self.model_coverage(tables, models)
|
|
403
419
|
unclaimed = tables.keys.sort.select { |name| models_for_table(name, models).empty? } - habtm_join_tables(models).to_a
|
|
404
420
|
unclaimed -= declared_join_tables(models).to_a if unclaimed.any?
|
|
421
|
+
# A file the walk could not read claims no table, but it is still a model file.
|
|
422
|
+
unclaimed -= models.filter_map { |_, d| d[:table_name] || Introspectors::TableName.stem(d[:file]) if d.is_a?(Hash) && d[:error] && d[:file] }
|
|
405
423
|
gems = gem_owned(unclaimed)
|
|
406
424
|
{ gems: gems, unclaimed: unclaimed - gems.values.flatten }
|
|
407
425
|
end
|
|
@@ -469,7 +487,8 @@ module RailsAiContext
|
|
|
469
487
|
if data[:foreign_keys]&.any?
|
|
470
488
|
lines << "" << "### Foreign keys"
|
|
471
489
|
data[:foreign_keys].each do |fk|
|
|
472
|
-
lines << "- `#{fk[:column]}` →
|
|
490
|
+
lines << "- `#{RailsAiContext::Introspectors::SchemaConventions.key_text(fk[:column])}` → " \
|
|
491
|
+
"`#{fk[:to_table]}.#{RailsAiContext::Introspectors::SchemaConventions.key_text(fk[:primary_key])}`"
|
|
473
492
|
end
|
|
474
493
|
end
|
|
475
494
|
|
|
@@ -69,7 +69,9 @@ module RailsAiContext
|
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
private_class_method def self.format_single_service(service, service_files, service_dirs, root, lookup)
|
|
72
|
-
|
|
72
|
+
records = without_mixins(match_service_files(service, service_files, service_dirs)
|
|
73
|
+
.filter_map { |f| service_record(f, service_dirs, lookup) }, root)
|
|
74
|
+
matches = records.map { |r| r[:path] }
|
|
73
75
|
|
|
74
76
|
if matches.size > 1
|
|
75
77
|
# Paths are printed from the app root, not re-prefixed with
|
|
@@ -101,19 +103,20 @@ module RailsAiContext
|
|
|
101
103
|
unless file
|
|
102
104
|
# The constants the files declare, as the listing names them: the
|
|
103
105
|
# camelized path spells Activitypub:: where Mastodon writes ActivityPub::.
|
|
104
|
-
available = service_files.filter_map { |
|
|
106
|
+
available = service_records(service_files, service_dirs, root, lookup).filter_map { |r| r[:class_name] }.uniq
|
|
105
107
|
return not_found_response("Service", service, available.sort,
|
|
106
108
|
recovery_tool: "Call rails_get_service_pattern(detail:\"summary\") to see all services")
|
|
107
109
|
end
|
|
108
110
|
|
|
109
111
|
return text_response("Service file too large to analyze.") if File.size(file) > max_file_size
|
|
110
112
|
|
|
111
|
-
|
|
113
|
+
record = records.find { |r| r[:path] == file }
|
|
114
|
+
source = record[:source]
|
|
112
115
|
return text_response("Could not read service file.") unless source
|
|
113
116
|
|
|
114
117
|
relative = file.sub("#{root}/", "")
|
|
115
118
|
line_count = source.lines.size
|
|
116
|
-
class_name
|
|
119
|
+
class_name = record[:class_name]
|
|
117
120
|
|
|
118
121
|
lines = [ "# #{class_name}", "" ]
|
|
119
122
|
lines << "**File:** `#{relative}` (#{count_phrase(line_count, "line")})"
|
|
@@ -178,40 +181,56 @@ module RailsAiContext
|
|
|
178
181
|
text_response(lines.join("\n"))
|
|
179
182
|
end
|
|
180
183
|
|
|
181
|
-
|
|
182
|
-
|
|
184
|
+
# The classes the listing counts before it sets the bases apart. The by-name
|
|
185
|
+
# lookup reads the same list, so it never answers what the listing leaves out.
|
|
186
|
+
private_class_method def self.service_records(service_files, service_dirs, root, lookup)
|
|
187
|
+
without_mixins(service_files.filter_map { |file| service_record(file, service_dirs, lookup) }, root)
|
|
188
|
+
end
|
|
183
189
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
190
|
+
private_class_method def self.service_record(file, service_dirs, lookup)
|
|
191
|
+
source = safe_read(file)
|
|
192
|
+
# Kept unread so a by-name lookup can say the file is too large.
|
|
193
|
+
return { path: file } unless source
|
|
194
|
+
return if Introspectors::ServiceClasses.concern?(nil, source)
|
|
195
|
+
|
|
196
|
+
class_name, superclass = name_and_superclass(source, file, service_dirs)
|
|
197
|
+
return if Introspectors::ServiceClasses.mailer?(source, class_name, superclass, lookup)
|
|
188
198
|
|
|
189
|
-
|
|
190
|
-
|
|
199
|
+
{ path: file, source: source, class_name: class_name, superclass: superclass,
|
|
200
|
+
entryless: Introspectors::ServiceClasses.entryless_module?(source, class_name) }
|
|
201
|
+
end
|
|
191
202
|
|
|
203
|
+
# A module other classes mix in is a concern in all but directory, and
|
|
204
|
+
# the generated files' Services line leaves it out by the same rule.
|
|
205
|
+
private_class_method def self.without_mixins(records, root)
|
|
206
|
+
entryless = records.select { |r| r[:entryless] }.map { |r| r[:class_name] }
|
|
207
|
+
return records if entryless.empty?
|
|
208
|
+
|
|
209
|
+
mixins = Introspectors::ServiceClasses.mixed_in(root, entryless)
|
|
210
|
+
records.reject { |r| mixins.include?(r[:class_name]) }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
private_class_method def self.format_service_listing(service_files, service_dirs, root, detail, lookup)
|
|
214
|
+
service_data = service_records(service_files, service_dirs, root, lookup).filter_map do |record|
|
|
215
|
+
next unless record[:source]
|
|
216
|
+
|
|
217
|
+
file, source = record.values_at(:path, :source)
|
|
192
218
|
owned = owned_methods(source, constant_for(file, service_dirs))
|
|
193
|
-
public_methods = extract_public_methods(owned)
|
|
194
|
-
init_params = extract_initialize_params(owned)
|
|
195
219
|
|
|
196
|
-
|
|
220
|
+
{
|
|
197
221
|
file: file.sub("#{root}/", ""),
|
|
198
|
-
class_name: class_name,
|
|
199
|
-
superclass: superclass,
|
|
222
|
+
class_name: record[:class_name],
|
|
223
|
+
superclass: record[:superclass],
|
|
200
224
|
line_count: source.lines.size,
|
|
201
|
-
public_methods:
|
|
202
|
-
init_params:
|
|
225
|
+
public_methods: extract_public_methods(owned),
|
|
226
|
+
init_params: extract_initialize_params(owned),
|
|
203
227
|
class_method_call: owned.any? { |m| m[:scope] == :class && m[:name] == "call" },
|
|
204
228
|
result_object: source.match?(/Result\.new|OpenStruct\.new|Struct\.new|\.success|\.failure/),
|
|
205
229
|
active_interaction: Introspectors::Interaction.interaction?(source, lookup: lookup),
|
|
206
|
-
entryless:
|
|
230
|
+
entryless: record[:entryless]
|
|
207
231
|
}
|
|
208
232
|
end
|
|
209
233
|
|
|
210
|
-
# A module other classes mix in is a concern in all but directory, and
|
|
211
|
-
# the generated files' Services line leaves it out by the same rule.
|
|
212
|
-
mixins = Introspectors::ServiceClasses.mixed_in(root, service_data.select { |s| s[:entryless] }.map { |s| s[:class_name] })
|
|
213
|
-
service_data = service_data.reject { |s| mixins.include?(s[:class_name]) }
|
|
214
|
-
|
|
215
234
|
bases = base_class_names(service_data)
|
|
216
235
|
service_data = service_data.reject { |s| bases.include?(s[:class_name]) }
|
|
217
236
|
# A directory of base classes alone has no service anybody calls, and
|
|
@@ -188,9 +188,11 @@ module RailsAiContext
|
|
|
188
188
|
exercising = type == :controller ? exercising_tests(snake, candidates) : []
|
|
189
189
|
|
|
190
190
|
contained = []
|
|
191
|
+
too_large = nil
|
|
191
192
|
candidates.each do |rel|
|
|
192
193
|
content, resolution = RailsAiContext::SafePath.read(rel, under: rails_app.root.to_s, max_size: max_test_file_size)
|
|
193
194
|
contained << rel unless ESCAPING_REFUSALS.include?(resolution.refusal)
|
|
195
|
+
too_large ||= resolution if resolution.refusal == :too_large
|
|
194
196
|
next unless content
|
|
195
197
|
|
|
196
198
|
# Summary/standard: return just test names (saves 2000+ tokens vs full source)
|
|
@@ -203,6 +205,12 @@ module RailsAiContext
|
|
|
203
205
|
return text_response(answer + exercising_section(exercising, "## Also exercised by"))
|
|
204
206
|
end
|
|
205
207
|
|
|
208
|
+
if too_large
|
|
209
|
+
return text_response("# #{too_large.relative}\n\n_The test file exists but was not read: #{human_size(File.size(too_large.realpath))} is over " \
|
|
210
|
+
"`max_test_file_size` (#{human_size(max_test_file_size)})._" +
|
|
211
|
+
exercising_section(exercising, "## Also exercised by"))
|
|
212
|
+
end
|
|
213
|
+
|
|
206
214
|
if exercising.any?
|
|
207
215
|
return text_response("# Tests that exercise #{name}" + exercising_section(exercising, nil, names: true))
|
|
208
216
|
end
|
|
@@ -80,9 +80,7 @@ module RailsAiContext
|
|
|
80
80
|
load_file | load_extension
|
|
81
81
|
)\b/ix
|
|
82
82
|
|
|
83
|
-
#
|
|
84
|
-
# are caught by SELECT_INTO already, but make an explicit pattern so
|
|
85
|
-
# the error message is accurate.
|
|
83
|
+
# Checked before SELECT_INTO, which also matches, so the refusal names the disk write.
|
|
86
84
|
BLOCKED_OUTPUT = /\bINTO\s+(OUTFILE|DUMPFILE)\b/i
|
|
87
85
|
|
|
88
86
|
# Defense against the column-aliasing redaction bypass:
|
|
@@ -124,6 +122,9 @@ module RailsAiContext
|
|
|
124
122
|
# with no READ ONLY transaction and no statement timeout.
|
|
125
123
|
MYSQL_ADAPTER = /mysql|trilogy/i
|
|
126
124
|
|
|
125
|
+
# activerecord-postgis-adapter reports "PostGIS" for what is a PostgreSQL connection.
|
|
126
|
+
POSTGRES_ADAPTER = /postg/i
|
|
127
|
+
|
|
127
128
|
# SHOW/DESCRIBE/EXPLAIN return schema metadata (a table's column list,
|
|
128
129
|
# an EXPLAIN plan), never application data rows. Two consequences:
|
|
129
130
|
# 1. They're inherently bounded - appending `LIMIT n` to them is
|
|
@@ -204,7 +205,7 @@ module RailsAiContext
|
|
|
204
205
|
rescue ActiveRecord::ConnectionNotEstablished, ActiveRecord::NoDatabaseError => e
|
|
205
206
|
text_response("Database unavailable: #{clean_error_message(e.message)}\n\n**Troubleshooting:**\n- Check `config/database.yml` for correct host/port/credentials\n- Try `RAILS_ENV=test` if the development DB is remote\n- Run `bin/rails db:create` if the database doesn't exist yet")
|
|
206
207
|
rescue ActiveRecord::StatementInvalid => e
|
|
207
|
-
if e.message.match?(/timeout|statement_timeout|MAX_EXECUTION_TIME/i)
|
|
208
|
+
if e.message.match?(/timeout|statement_timeout|MAX_EXECUTION_TIME|maximum statement execution time exceeded/i)
|
|
208
209
|
text_response("Query exceeded #{config.query_timeout} second timeout. Simplify the query or add indexes.")
|
|
209
210
|
# Only a missing DATABASE. Postgres words a missing column and a
|
|
210
211
|
# missing table the same way ("... does not exist"), and matching
|
|
@@ -262,8 +263,8 @@ module RailsAiContext
|
|
|
262
263
|
return [ false, "Blocked: multiple statements (no semicolons)" ] if cleaned.match?(MULTI_STATEMENT)
|
|
263
264
|
return [ false, "Blocked: FOR UPDATE/SHARE clause" ] if cleaned.match?(BLOCKED_CLAUSES)
|
|
264
265
|
return [ false, "Blocked: sensitive SHOW command" ] if cleaned.match?(BLOCKED_SHOWS)
|
|
265
|
-
return [ false, "Blocked: SELECT INTO creates a table" ] if cleaned.match?(SELECT_INTO)
|
|
266
266
|
return [ false, "Blocked: SELECT INTO OUTFILE / DUMPFILE writes to disk" ] if cleaned.match?(BLOCKED_OUTPUT)
|
|
267
|
+
return [ false, "Blocked: SELECT INTO creates a table" ] if cleaned.match?(SELECT_INTO)
|
|
267
268
|
|
|
268
269
|
# Block database functions that give a filesystem/network primitive.
|
|
269
270
|
# pg_read_file, lo_import, dblink, LOAD_FILE, load_extension, etc.
|
|
@@ -335,7 +336,7 @@ module RailsAiContext
|
|
|
335
336
|
# the plan, so `explain: true` must not hold a connection past query_timeout.
|
|
336
337
|
private_class_method def self.run_guarded(conn, adapter, sql, timeout)
|
|
337
338
|
case adapter
|
|
338
|
-
when
|
|
339
|
+
when POSTGRES_ADAPTER
|
|
339
340
|
execute_postgresql(conn, sql, timeout)
|
|
340
341
|
when MYSQL_ADAPTER
|
|
341
342
|
execute_mysql(conn, sql, timeout)
|
|
@@ -418,7 +419,7 @@ module RailsAiContext
|
|
|
418
419
|
adapter = conn.adapter_name.downcase
|
|
419
420
|
|
|
420
421
|
explain_sql, parser = case adapter
|
|
421
|
-
when
|
|
422
|
+
when POSTGRES_ADAPTER
|
|
422
423
|
[ "EXPLAIN (FORMAT JSON, ANALYZE) #{sql}", :parse_pg_explain ]
|
|
423
424
|
when MYSQL_ADAPTER
|
|
424
425
|
# MySQL 8.3+ and 9.x default explain_format to TREE, which
|
|
@@ -93,7 +93,7 @@ module RailsAiContext
|
|
|
93
93
|
conn = ActiveRecord::Base.connection
|
|
94
94
|
adapter = conn.adapter_name.downcase
|
|
95
95
|
lines = [ "## Database", "" ]
|
|
96
|
-
lines << "**Adapter:** #{conn.adapter_name}"
|
|
96
|
+
lines << "**Adapter:** #{SchemaAdapter.display(conn.adapter_name)}"
|
|
97
97
|
|
|
98
98
|
# Table sizes
|
|
99
99
|
sizes = gather_table_sizes(conn, adapter)
|
|
@@ -101,12 +101,13 @@ module RailsAiContext
|
|
|
101
101
|
lines << "" << "### Table Sizes"
|
|
102
102
|
lines << "| Table | Size |"
|
|
103
103
|
lines << "|-------|------|"
|
|
104
|
-
|
|
104
|
+
shown = RailsAiContext::DetailLevel.summary?(detail) ? 5 : 30
|
|
105
|
+
sizes.first(shown).each do |row|
|
|
105
106
|
lines << "| #{row[:name]} | #{human_size(row[:bytes])} |"
|
|
106
107
|
end
|
|
107
108
|
total = sizes.sum { |r| r[:bytes] }
|
|
108
109
|
lines << "| **Total** | **#{human_size(total)}** |"
|
|
109
|
-
lines << "_#{count_phrase(sizes.size -
|
|
110
|
+
lines << "_#{count_phrase(sizes.size - shown, "more table")}..._" if sizes.size > shown
|
|
110
111
|
end
|
|
111
112
|
|
|
112
113
|
# Pending migrations
|
|
@@ -129,8 +130,9 @@ module RailsAiContext
|
|
|
129
130
|
if unused.any?
|
|
130
131
|
lines << "**Unused indexes (0 scans):**"
|
|
131
132
|
unused.first(10).each { |i| lines << "- `#{i[:index]}` on `#{i[:table]}`" }
|
|
133
|
+
lines << "_#{count_phrase(unused.size - 10, "more unused index", plural: "more unused indexes")}..._" if unused.size > 10
|
|
132
134
|
end
|
|
133
|
-
hot = index_usage.sort_by { |i| -
|
|
135
|
+
hot = index_usage.sort_by { |i| [ -i[:scans].to_i, i[:table].to_s, i[:index].to_s ] }.first(5)
|
|
134
136
|
lines << "" << "**Most used indexes:**"
|
|
135
137
|
hot.each { |i| lines << "- `#{i[:index]}` on `#{i[:table]}` - #{count_phrase(i[:scans], "scan")}" }
|
|
136
138
|
end
|
|
@@ -152,12 +154,14 @@ module RailsAiContext
|
|
|
152
154
|
# table sizes for every Trilogy app.
|
|
153
155
|
MYSQL_ADAPTER = /mysql|trilogy/i
|
|
154
156
|
|
|
157
|
+
# activerecord-postgis-adapter reports "PostGIS" for what is a PostgreSQL connection.
|
|
158
|
+
POSTGRES_ADAPTER = /postg/i
|
|
159
|
+
|
|
155
160
|
def gather_table_sizes(conn, adapter)
|
|
156
161
|
rows =
|
|
157
162
|
case adapter
|
|
158
|
-
when
|
|
159
|
-
|
|
160
|
-
conn.select_all(sql).map { |r| { name: r["name"], bytes: r["bytes"].to_i } }
|
|
163
|
+
when POSTGRES_ADAPTER
|
|
164
|
+
Introspectors::PgPartitions.table_bytes(conn).sort_by { |r| [ -r[:bytes], r[:name] ] }
|
|
161
165
|
when MYSQL_ADAPTER
|
|
162
166
|
sql = "SELECT table_name AS name, (data_length + index_length) AS bytes FROM INFORMATION_SCHEMA.TABLES WHERE table_schema = DATABASE() ORDER BY bytes DESC"
|
|
163
167
|
conn.select_all(sql).map { |r| { name: r["name"], bytes: r["bytes"].to_i } }
|
|
@@ -204,9 +208,8 @@ module RailsAiContext
|
|
|
204
208
|
|
|
205
209
|
def gather_index_usage(conn, adapter)
|
|
206
210
|
case adapter
|
|
207
|
-
when
|
|
208
|
-
|
|
209
|
-
conn.select_all(sql).map { |r| { table: r["table"], index: r["index"], scans: r["scans"].to_i } }
|
|
211
|
+
when POSTGRES_ADAPTER
|
|
212
|
+
Introspectors::PgPartitions.index_stats(conn).sort_by { |i| [ i[:scans], i[:table], i[:index] ] }
|
|
210
213
|
else
|
|
211
214
|
nil
|
|
212
215
|
end
|