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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +159 -0
  3. data/docs/ARCHITECTURE.md +5 -1
  4. data/docs/CLI.md +1 -1
  5. data/docs/COMPATIBILITY.md +7 -1
  6. data/docs/CONFIGURATION.md +2 -2
  7. data/docs/FAQ.md +1 -1
  8. data/docs/GUIDE.md +1 -1
  9. data/exe/rails-ai-context +3 -4
  10. data/lib/rails_ai_context/action_filters.rb +69 -6
  11. data/lib/rails_ai_context/boot_manager.rb +21 -5
  12. data/lib/rails_ai_context/cli/entry_boot.rb +3 -2
  13. data/lib/rails_ai_context/cli/tool_runner.rb +22 -3
  14. data/lib/rails_ai_context/concern_paths.rb +2 -1
  15. data/lib/rails_ai_context/configuration.rb +6 -6
  16. data/lib/rails_ai_context/erb_source.rb +22 -3
  17. data/lib/rails_ai_context/introspector.rb +13 -2
  18. data/lib/rails_ai_context/introspectors/controller_introspector.rb +43 -16
  19. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +10 -15
  20. data/lib/rails_ai_context/introspectors/eager_load.rb +22 -10
  21. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +4 -3
  22. data/lib/rails_ai_context/introspectors/job_introspector.rb +24 -10
  23. data/lib/rails_ai_context/introspectors/listeners/associations_listener.rb +12 -0
  24. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +9 -1
  25. data/lib/rails_ai_context/introspectors/listeners/mount_listener.rb +13 -6
  26. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +83 -27
  27. data/lib/rails_ai_context/introspectors/listeners/with_options_scope.rb +49 -12
  28. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +15 -0
  29. data/lib/rails_ai_context/introspectors/migration_replay.rb +2 -0
  30. data/lib/rails_ai_context/introspectors/model_introspector.rb +14 -3
  31. data/lib/rails_ai_context/introspectors/pg_partitions.rb +73 -0
  32. data/lib/rails_ai_context/introspectors/route_introspector.rb +62 -4
  33. data/lib/rails_ai_context/introspectors/schema_conventions.rb +17 -2
  34. data/lib/rails_ai_context/introspectors/schema_introspector.rb +13 -7
  35. data/lib/rails_ai_context/introspectors/schema_reader.rb +28 -2
  36. data/lib/rails_ai_context/introspectors/structure_sql_reader.rb +8 -4
  37. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +18 -16
  38. data/lib/rails_ai_context/migration_status.rb +6 -1
  39. data/lib/rails_ai_context/pending_migrations.rb +3 -2
  40. data/lib/rails_ai_context/portable_path.rb +14 -0
  41. data/lib/rails_ai_context/schema_adapter.rb +6 -1
  42. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +1 -1
  43. data/lib/rails_ai_context/serializers/context_file_serializer.rb +2 -2
  44. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +5 -0
  45. data/lib/rails_ai_context/tasks/rails_ai_context.rake +1 -1
  46. data/lib/rails_ai_context/tools/base_tool.rb +9 -8
  47. data/lib/rails_ai_context/tools/diagnose.rb +4 -0
  48. data/lib/rails_ai_context/tools/generate_test.rb +19 -0
  49. data/lib/rails_ai_context/tools/get_model_details.rb +1 -1
  50. data/lib/rails_ai_context/tools/get_partial_interface.rb +18 -4
  51. data/lib/rails_ai_context/tools/get_routes.rb +19 -9
  52. data/lib/rails_ai_context/tools/get_schema.rb +24 -5
  53. data/lib/rails_ai_context/tools/get_service_pattern.rb +44 -25
  54. data/lib/rails_ai_context/tools/get_test_info.rb +8 -0
  55. data/lib/rails_ai_context/tools/migration_advisor.rb +1 -1
  56. data/lib/rails_ai_context/tools/query.rb +8 -7
  57. data/lib/rails_ai_context/tools/runtime_info.rb +13 -10
  58. data/lib/rails_ai_context/tools/validate_semantics.rb +56 -14
  59. data/lib/rails_ai_context/version.rb +1 -1
  60. data/server.json +1 -1
  61. 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 do |path|
891
- refusal = RailsAiContext::SafePath.locate(path.to_s, under: rails_app.root.to_s).refusal
892
- [ path, refusal ] if %i[sensitive traversal outside].include?(refusal)
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
- error_response(refused.map { |path, refusal|
897
- refusal == :sensitive ? "Path not allowed: #{path} (sensitive file)" : "Path not allowed: #{path}"
898
- }.join("\n"))
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.each_line.with_index(1) do |line, line_num|
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: line.strip
406
+ snippet: snippet
393
407
  }
394
408
  matched_line = true
395
- break # one match per line is enough
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: line.strip
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
- groups = Array(engine_routes)
79
- return [] if groups.empty?
80
-
81
- lines = [ "", "## Routes drawn into mounted engines" ]
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]}**#{at}: #{count_phrase(Array(group[:routes]).size, "route")}#{whole}#{unexpanded}, not in the count above"
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
- if declared_not_connected(schema).include?(table_key.to_s.underscore)
80
+ declared = table_key.to_s.underscore
81
+ if declared_not_connected(schema).include?(declared)
81
82
  return text_response(
82
- "Table '#{table_key.to_s.underscore}' is declared in db/schema.rb and missing from the connected database. " \
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
- page = names.to_h { |name| [ name, tables[name] ] }
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]}` → `#{fk[:to_table]}.#{fk[:primary_key]}`"
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
- matches = match_service_files(service, service_files, service_dirs)
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 { |f| (source = safe_read(f)) && name_and_superclass(source, f, service_dirs).first }.uniq
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
- source = safe_read(file)
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, = name_and_superclass(source, file, service_dirs)
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
- private_class_method def self.format_service_listing(service_files, service_dirs, root, detail, lookup)
182
- service_data = []
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
- service_files.each do |file|
185
- source = safe_read(file)
186
- next unless source
187
- next if Introspectors::ServiceClasses.concern?(nil, source)
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
- class_name, superclass = name_and_superclass(source, file, service_dirs)
190
- next if Introspectors::ServiceClasses.mailer?(source, class_name, superclass, lookup)
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
- service_data << {
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: public_methods,
202
- init_params: 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: Introspectors::ServiceClasses.entryless_module?(source, class_name)
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
@@ -573,7 +573,7 @@ module RailsAiContext
573
573
  end
574
574
 
575
575
  def postgresql_adapter?
576
- current_adapter.match?(/postgres/i)
576
+ current_adapter.match?(/postg/i)
577
577
  end
578
578
  end
579
579
  end
@@ -80,9 +80,7 @@ module RailsAiContext
80
80
  load_file | load_extension
81
81
  )\b/ix
82
82
 
83
- # MySQL `SELECT ... INTO OUTFILE 'path'` / `INTO DUMPFILE 'path'`
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 /postgresql/
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 /postgresql/
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
- sizes.first(RailsAiContext::DetailLevel.summary?(detail) ? 5 : 30).each do |row|
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 - 30, "more table")}..._" if detail != "summary" && sizes.size > 30
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| -(i[:scans] || 0) }.first(5)
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 /postgresql/
159
- sql = "SELECT relname AS name, pg_total_relation_size(relid) AS bytes FROM pg_stat_user_tables ORDER BY bytes DESC"
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 /postgresql/
208
- sql = "SELECT relname AS table, indexrelname AS index, idx_scan AS scans FROM pg_stat_user_indexes ORDER BY idx_scan ASC"
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