rails-ai-context 5.32.2 → 5.34.0

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 (203) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1491 -0
  3. data/CONTEXT.md +27 -3
  4. data/README.md +16 -12
  5. data/Rakefile +6 -0
  6. data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
  7. data/config/routes.rb +2 -3
  8. data/docs/ARCHITECTURE.md +7 -5
  9. data/docs/CLI.md +79 -6
  10. data/docs/COMPATIBILITY.md +75 -1
  11. data/docs/CONFIGURATION.md +30 -11
  12. data/docs/CUSTOM_TOOLS.md +2 -1
  13. data/docs/FAQ.md +16 -6
  14. data/docs/GUIDE.md +117 -71
  15. data/docs/INTROSPECTORS.md +10 -8
  16. data/docs/QUICKSTART.md +3 -3
  17. data/docs/RECIPES.md +1 -1
  18. data/docs/SECURITY.md +109 -25
  19. data/docs/SETUP.md +30 -6
  20. data/docs/STANDALONE.md +10 -3
  21. data/docs/TOOLS.md +152 -34
  22. data/docs/TROUBLESHOOTING.md +90 -12
  23. data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
  24. data/docs/adr/0005-workspace-mcp-entries.md +39 -0
  25. data/exe/rails-ai-context +733 -104
  26. data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
  27. data/lib/rails_ai_context/app_kind.rb +18 -5
  28. data/lib/rails_ai_context/ast_cache.rb +7 -0
  29. data/lib/rails_ai_context/base_mixins.rb +8 -8
  30. data/lib/rails_ai_context/boot_manager.rb +22 -3
  31. data/lib/rails_ai_context/change_watch.rb +14 -11
  32. data/lib/rails_ai_context/cli/app_root.rb +401 -0
  33. data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
  34. data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
  35. data/lib/rails_ai_context/code_reloader.rb +115 -1
  36. data/lib/rails_ai_context/concern_membership.rb +3 -1
  37. data/lib/rails_ai_context/concern_paths.rb +8 -3
  38. data/lib/rails_ai_context/configuration.rb +41 -9
  39. data/lib/rails_ai_context/context_file_report.rb +19 -2
  40. data/lib/rails_ai_context/database_file.rb +26 -0
  41. data/lib/rails_ai_context/doctor.rb +1122 -184
  42. data/lib/rails_ai_context/engine.rb +18 -1
  43. data/lib/rails_ai_context/erb_source.rb +19 -0
  44. data/lib/rails_ai_context/executable.rb +21 -0
  45. data/lib/rails_ai_context/facts_formatter.rb +5 -2
  46. data/lib/rails_ai_context/file_walk.rb +61 -10
  47. data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
  48. data/lib/rails_ai_context/fingerprinter.rb +40 -12
  49. data/lib/rails_ai_context/gem_lock.rb +186 -27
  50. data/lib/rails_ai_context/git_ignore.rb +22 -0
  51. data/lib/rails_ai_context/install/ai_tool.rb +31 -5
  52. data/lib/rails_ai_context/install/cleanup.rb +21 -0
  53. data/lib/rails_ai_context/install/program.rb +209 -35
  54. data/lib/rails_ai_context/install/selection_record.rb +258 -97
  55. data/lib/rails_ai_context/install/surface.rb +6 -1
  56. data/lib/rails_ai_context/install/validation_hook.rb +282 -0
  57. data/lib/rails_ai_context/install/workspace.rb +166 -0
  58. data/lib/rails_ai_context/install_mode.rb +71 -13
  59. data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
  60. data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
  61. data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
  62. data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
  63. data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
  64. data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
  65. data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
  66. data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
  67. data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
  68. data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
  69. data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
  70. data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
  71. data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
  72. data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
  73. data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
  74. data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
  75. data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
  76. data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
  77. data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
  78. data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
  79. data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
  80. data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
  81. data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
  82. data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
  83. data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
  84. data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
  85. data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
  86. data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
  87. data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
  88. data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
  89. data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
  90. data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
  91. data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
  92. data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
  93. data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
  94. data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
  95. data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
  96. data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
  97. data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
  98. data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
  99. data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
  100. data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
  101. data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
  102. data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
  103. data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
  104. data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
  105. data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
  106. data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
  107. data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
  108. data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
  109. data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
  110. data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
  111. data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
  112. data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
  113. data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
  114. data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
  115. data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
  116. data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
  117. data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
  118. data/lib/rails_ai_context/live_reload.rb +20 -20
  119. data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
  120. data/lib/rails_ai_context/mcp_edge.rb +182 -32
  121. data/lib/rails_ai_context/middleware.rb +5 -1
  122. data/lib/rails_ai_context/migration_status.rb +88 -25
  123. data/lib/rails_ai_context/output_guard.rb +48 -2
  124. data/lib/rails_ai_context/package_json.rb +1 -1
  125. data/lib/rails_ai_context/path_resolver.rb +46 -13
  126. data/lib/rails_ai_context/payload.rb +92 -8
  127. data/lib/rails_ai_context/pending_migrations.rb +5 -1
  128. data/lib/rails_ai_context/portable_path.rb +3 -2
  129. data/lib/rails_ai_context/presets.rb +15 -11
  130. data/lib/rails_ai_context/redaction.rb +262 -51
  131. data/lib/rails_ai_context/resources.rb +55 -20
  132. data/lib/rails_ai_context/route_coverage.rb +8 -0
  133. data/lib/rails_ai_context/safe_file.rb +14 -1
  134. data/lib/rails_ai_context/safe_path.rb +101 -4
  135. data/lib/rails_ai_context/schema_adapter.rb +8 -0
  136. data/lib/rails_ai_context/schema_version.rb +9 -0
  137. data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
  138. data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
  139. data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
  140. data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
  141. data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
  142. data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
  143. data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
  144. data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
  145. data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
  146. data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
  147. data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
  148. data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
  149. data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
  150. data/lib/rails_ai_context/server.rb +304 -71
  151. data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
  152. data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
  153. data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
  154. data/lib/rails_ai_context/tools/base_tool.rb +367 -46
  155. data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
  156. data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
  157. data/lib/rails_ai_context/tools/diagnose.rb +218 -11
  158. data/lib/rails_ai_context/tools/generate_test.rb +339 -39
  159. data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
  160. data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
  161. data/lib/rails_ai_context/tools/get_concern.rb +91 -27
  162. data/lib/rails_ai_context/tools/get_config.rb +13 -4
  163. data/lib/rails_ai_context/tools/get_context.rb +71 -14
  164. data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
  165. data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
  166. data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
  167. data/lib/rails_ai_context/tools/get_env.rb +18 -9
  168. data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
  169. data/lib/rails_ai_context/tools/get_gems.rb +1 -1
  170. data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
  171. data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
  172. data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
  173. data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
  174. data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
  175. data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
  176. data/lib/rails_ai_context/tools/get_routes.rb +29 -8
  177. data/lib/rails_ai_context/tools/get_schema.rb +52 -19
  178. data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
  179. data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
  180. data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
  181. data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
  182. data/lib/rails_ai_context/tools/get_view.rb +14 -14
  183. data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
  184. data/lib/rails_ai_context/tools/onboard.rb +52 -8
  185. data/lib/rails_ai_context/tools/performance_check.rb +5 -3
  186. data/lib/rails_ai_context/tools/query.rb +914 -86
  187. data/lib/rails_ai_context/tools/read_logs.rb +69 -15
  188. data/lib/rails_ai_context/tools/review_changes.rb +215 -53
  189. data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
  190. data/lib/rails_ai_context/tools/safe_call.rb +35 -14
  191. data/lib/rails_ai_context/tools/search_code.rb +243 -67
  192. data/lib/rails_ai_context/tools/search_docs.rb +4 -4
  193. data/lib/rails_ai_context/tools/security_scan.rb +143 -33
  194. data/lib/rails_ai_context/tools/session_context.rb +39 -5
  195. data/lib/rails_ai_context/tools/validate.rb +259 -40
  196. data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
  197. data/lib/rails_ai_context/version.rb +1 -1
  198. data/lib/rails_ai_context/vfs.rb +67 -40
  199. data/lib/rails_ai_context/view_file.rb +41 -3
  200. data/lib/rails_ai_context/watcher.rb +51 -13
  201. data/lib/rails_ai_context.rb +21 -0
  202. data/server.json +1 -1
  203. metadata +14 -1
@@ -23,11 +23,12 @@ module RailsAiContext
23
23
  },
24
24
  column: {
25
25
  type: "string",
26
- description: "Column name (e.g., 'email', 'status')"
26
+ description: "Column name (e.g., 'email', 'status'). For add_association, the reference (e.g., 'user', 'parent')."
27
27
  },
28
28
  type: {
29
29
  type: "string",
30
- description: "Column type (e.g., 'string', 'integer', 'boolean', 'references')"
30
+ description: "Column type (e.g., 'string', 'integer', 'boolean', 'references'). " \
31
+ "For add_association with column, the table the reference points at (e.g., 'users')."
31
32
  },
32
33
  new_name: {
33
34
  type: "string",
@@ -57,11 +58,24 @@ module RailsAiContext
57
58
  # mode, so the tool says so in its output when it falls back here.
58
59
  SUPPORTED_RAILS_FLOOR = "7.0"
59
60
 
61
+ # Room for every option a column takes, a long `comment:` included.
62
+ MAX_OPTIONS_LENGTH = 1_024
63
+
60
64
  def self.call(action: nil, table: nil, column: nil, type: nil, new_name: nil, options: nil, server_context: nil)
61
65
  action = action.to_s.strip
62
66
  table = table.to_s.strip
63
67
  column = column.to_s.strip.presence if column
64
68
 
69
+ # No database takes a name this long (PostgreSQL stops at 63 bytes,
70
+ # MySQL at 64), and the generated code would carry it whole.
71
+ names = { "table" => table, "type" => type, "new_name" => new_name }
72
+ names["column"] = column unless action == "create_table"
73
+ long_name = names.find { |_, value| name_too_long?(value) }
74
+ if long_name || options.to_s.length > MAX_OPTIONS_LENGTH
75
+ param, value = long_name || [ "options", options ]
76
+ return text_response("**Error:** `#{param}` #{echo_input(value)} is longer than any migration takes.")
77
+ end
78
+
65
79
  # Normalize model names to table names: "Post" → "posts", and
66
80
  # "Admin::ActionLog" → the table its model recorded, not the
67
81
  # `admin/action_logs` an underscore would build.
@@ -74,17 +88,17 @@ module RailsAiContext
74
88
 
75
89
  # Validate identifier characters to produce valid migration code
76
90
  unless table.match?(/\A[a-z_][a-z0-9_]*\z/)
77
- return text_response("**Error:** Invalid table name `#{table}`. Use lowercase letters, digits, and underscores only.")
91
+ return text_response("**Error:** Invalid table name `#{echo_input(table)}`. Use lowercase letters, digits, and underscores only.")
78
92
  end
79
93
  # create_table uses column param as a comma-separated column:type definition string
80
94
  if action != "create_table" && column && !column.empty? && !column.match?(/\A[a-z_][a-z0-9_]*\z/)
81
- return text_response("**Error:** Invalid column name `#{column}`. Use lowercase letters, digits, and underscores only.")
95
+ return text_response("**Error:** Invalid column name `#{echo_input(column)}`. Use lowercase letters, digits, and underscores only.")
82
96
  end
83
97
 
84
98
  unless VALID_ACTIONS.include?(action)
85
99
  suggestion = VALID_ACTIONS.find { |a| a.start_with?(action) || a.include?(action) }
86
100
  hint = suggestion ? " Did you mean `#{suggestion}`?" : ""
87
- return text_response("**Error:** Unknown action `#{action}`.#{hint} Valid actions: #{VALID_ACTIONS.join(', ')}")
101
+ return text_response("**Error:** Unknown action `#{echo_input(action)}`.#{hint} Valid actions: #{VALID_ACTIONS.join(', ')}")
88
102
  end
89
103
 
90
104
  schema = Payload.section(cached_context, :schema)
@@ -103,27 +117,35 @@ module RailsAiContext
103
117
  database_lines, target = database_option(table, databases)
104
118
  lines.concat(database_lines)
105
119
 
106
- case action
107
- when "add_column"
108
- lines.concat(generate_add_column(table, column, type, options, table_exists, target))
109
- when "remove_column"
110
- lines.concat(generate_remove_column(table, column, type, schema, models, target))
111
- when "rename_column"
112
- rename_to = new_name&.to_s&.strip
113
- rename_to = type if rename_to.nil? || rename_to.empty?
114
- lines.concat(generate_rename_column(table, column, rename_to))
115
- when "add_index"
116
- lines.concat(generate_add_index(table, column, options))
117
- when "add_association"
118
- lines.concat(generate_add_association(table, column, type, options, schema))
119
- when "change_type"
120
- lines.concat(generate_change_type(table, column, type, options))
121
- when "create_table"
122
- lines.concat(generate_create_table(table, column, options))
123
- end
120
+ rename_to = new_name.to_s.strip.presence || type.to_s.strip.presence if action == "rename_column"
121
+ if (unknown = unknown_type(action, column, type))
122
+ return text_response((lines + [ unknown ]).join("\n"))
123
+ end
124
+ # What the app's migrations already hold: a file the generator's name
125
+ # collides with, and a pending one that makes the same change.
126
+ prior = prior_migration_lines(action, table, column, rename_to, type, migration_files_for(target), pending_versions(schema, databases))
127
+ lines.concat(prior)
128
+ # The generator refuses a name another migration has, so its command is not offered.
129
+ target = false if prior.any? { |line| line.start_with?("**Warning:** Another migration is already named") }
130
+
131
+ generated = case action
132
+ when "add_column" then generate_add_column(table, column, type, options, table_exists, target)
133
+ when "remove_column" then generate_remove_column(table, column, type, schema, models, target)
134
+ when "rename_column" then generate_rename_column(table, column, rename_to)
135
+ when "add_index" then generate_add_index(table, column, options)
136
+ when "add_association" then generate_add_association(table, column, type, options, schema)
137
+ when "change_type" then generate_change_type(table, column, type, options)
138
+ when "create_table" then generate_create_table(table, column, options, table_exists)
139
+ end
140
+ lines.concat(generated)
141
+ # A missing param writes no migration, so nothing below applies to one.
142
+ return text_response(lines.join("\n")) if generated.first.to_s.start_with?("**Error:**")
124
143
 
125
144
  # Show affected models
126
- lines.concat(show_affected_models(table, models))
145
+ lines.concat(show_affected_models(table, models, action: action, column: column))
146
+ # A column change breaks the code that reads the column, which no
147
+ # association names: that code is what the reader has to update.
148
+ lines.concat(column_mention_lines(column)) if %w[remove_column rename_column change_type].include?(action) && column
127
149
 
128
150
  # Strong Migrations warnings (only when the gem is present in the project).
129
151
  # add_association accepts the associated table via either `column` or
@@ -143,6 +165,162 @@ module RailsAiContext
143
165
  parts.join
144
166
  end
145
167
 
168
+ # The class each action's migration is named, which the generator
169
+ # turns into its file name; nil when a needed param is missing.
170
+ def migration_name(action, table, column, rename_to, type)
171
+ case action
172
+ when "add_column" then column && migration_class_name("add", table, column)
173
+ when "remove_column" then column && migration_class_name("remove", table, column)
174
+ when "rename_column" then column && rename_to && "Rename#{column.camelize}To#{rename_to.camelize}In#{table.camelize}"
175
+ when "add_index" then column && "AddIndexTo#{table.camelize}On#{column.camelize}"
176
+ when "add_association" then (reference = column || type) && "Add#{reference.camelize}To#{table.camelize}"
177
+ when "change_type" then column && "Change#{column.camelize}TypeIn#{table.camelize}"
178
+ when "create_table" then "Create#{table.camelize}"
179
+ end
180
+ end
181
+
182
+ # The migration files of the database the migration goes to; none when
183
+ # there is no app root to read them from.
184
+ def migration_files_for(target)
185
+ root = rails_app.root.to_s
186
+ dirs = target.is_a?(Hash) && target[:dirs] ? target[:dirs] : RailsAiContext::PendingMigrations.migrate_dirs_for(root)
187
+ RailsAiContext::PendingMigrations.migration_files(dirs, root: root)
188
+ rescue StandardError => e
189
+ RailsAiContext.debug_fail(e, [], label: "migration_files_for")
190
+ end
191
+
192
+ # The versions not yet run on that database; nil when unknown.
193
+ def pending_versions(schema, databases)
194
+ pending = if databases.empty? || databases.include?("primary")
195
+ schema&.dig(:pending_migrations)
196
+ else
197
+ schema&.dig(:secondary_databases, databases.first, :pending_migrations)
198
+ end
199
+ pending.is_a?(Array) ? pending.map { |m| m[:version].to_s } : nil
200
+ end
201
+
202
+ # A file already named what the generator would name this one, and a
203
+ # pending migration that already makes this change. Rails' generator
204
+ # stops with "Another migration is already named ..." on the first;
205
+ # the second means the change is written and only waits to run.
206
+ def prior_migration_lines(action, table, column, rename_to, type, files, pending)
207
+ lines = []
208
+ name = migration_name(action, table, column, rename_to, type)
209
+ if name && (clash = files.find { |m| migration_file_name(m[:path]) == name.underscore })
210
+ waiting = pending&.include?(clash[:version].to_s)
211
+ lines << "**Warning:** Another migration is already named `#{name.underscore}` " \
212
+ "(`#{RailsAiContext::PortablePath.relativize(clash[:path], rails_app.root.to_s)}`#{", not yet run" if waiting}), " \
213
+ "so `bin/rails generate migration #{name}` stops with \"Another migration is already named #{name.underscore}\". " \
214
+ "#{waiting ? "Edit that migration before it runs, or give this one another name." : "Give this one another name."}"
215
+ lines << ""
216
+ end
217
+
218
+ changes = pending_changes(files, pending)
219
+ wanted = case action
220
+ when "add_column" then [ table, column ]
221
+ when "add_association" then [ table, "#{(column || type).to_s.singularize}_id" ]
222
+ when "create_table" then [ table, nil ]
223
+ end
224
+ if wanted && (found = changes.find { |c| c[:table] == wanted.first && c[:column] == wanted.last })
225
+ what = wanted.last ? "adds `#{wanted.last}`#{" (#{found[:type]})" if found[:type]} to `#{table}`" : "creates `#{table}`"
226
+ lines << "**Warning:** Pending migration `#{found[:version]} #{found[:name]}` already #{what}: " \
227
+ "run `bin/rails db:migrate` rather than generating another, or change that migration before it runs."
228
+ lines << ""
229
+ end
230
+ lines
231
+ end
232
+
233
+ def migration_file_name(path)
234
+ File.basename(path, ".rb").sub(/\A\d+_/, "").split(".", 2).first
235
+ end
236
+
237
+ # [{ table:, column:, type:, version:, name: }] for each column a pending
238
+ # migration adds, and each table it creates (column nil).
239
+ def pending_changes(files, pending)
240
+ return [] unless pending
241
+
242
+ files.select { |m| pending.include?(m[:version].to_s) }.flat_map do |m|
243
+ source = RailsAiContext::SafeFile.read(m[:path]) or next []
244
+ changes = []
245
+ collect_changes(RailsAiContext::AstCache.parse_string(source).value, nil, changes)
246
+ changes.map { |change| change.merge(version: m[:version], name: m[:name]) }
247
+ end
248
+ rescue => e
249
+ RailsAiContext.debug_fail(e, [], label: "pending_changes")
250
+ end
251
+
252
+ TABLE_BLOCKS = %i[create_table change_table].freeze
253
+
254
+ # What a migration does when it runs, not what it takes back on a
255
+ # rollback: `def down`, a reversible's `dir.down` and a `revert`
256
+ # block hold the undo.
257
+ def undo?(node)
258
+ return node.name == :down if node.is_a?(Prism::DefNode)
259
+ return false unless node.is_a?(Prism::CallNode) && node.block
260
+
261
+ node.receiver ? node.name == :down : node.name == :revert
262
+ end
263
+
264
+ def collect_changes(node, block_table, changes)
265
+ return unless node.is_a?(Prism::Node)
266
+ return if undo?(node)
267
+
268
+ if node.is_a?(Prism::CallNode)
269
+ args = Array(node.arguments&.arguments)
270
+ literal = ->(arg) { arg.unescaped.to_s if arg.is_a?(Prism::SymbolNode) || arg.is_a?(Prism::StringNode) }
271
+ if node.receiver.nil? && %i[add_column add_reference add_belongs_to].include?(node.name) && literal.(args[0]) && literal.(args[1])
272
+ column = node.name == :add_column ? literal.(args[1]) : "#{literal.(args[1])}_id"
273
+ changes << { table: literal.(args[0]), column: column, type: (literal.(args[2]) if node.name == :add_column) }
274
+ elsif node.receiver.nil? && TABLE_BLOCKS.include?(node.name) && literal.(args[0])
275
+ changes << { table: literal.(args[0]), column: nil } if node.name == :create_table
276
+ return collect_changes(node.block, literal.(args[0]), changes)
277
+ elsif block_table && node.receiver.is_a?(Prism::LocalVariableReadNode) && literal.(args[0])
278
+ case node.name
279
+ when :references, :belongs_to then changes << { table: block_table, column: "#{literal.(args[0])}_id" }
280
+ when :column then changes << { table: block_table, column: literal.(args[0]), type: literal.(args[1]) }
281
+ when :index, :remove, :rename, :change, :change_default, :change_null, :remove_references, :remove_belongs_to, :timestamps then nil
282
+ else changes << { table: block_table, column: literal.(args[0]), type: node.name.to_s }
283
+ end
284
+ end
285
+ end
286
+ node.compact_child_nodes.each { |child| collect_changes(child, block_table, changes) }
287
+ end
288
+
289
+ # Migration column types: Rails' own, the adapters', and the
290
+ # connection's when the app is booted.
291
+ COLUMN_TYPES = %w[
292
+ string text integer bigint float decimal numeric datetime timestamp time date binary blob boolean
293
+ json virtual primary_key references belongs_to
294
+ jsonb uuid inet cidr macaddr hstore citext ltree tsvector tsquery money point line lseg box path polygon
295
+ circle bit bit_varying xml interval oid enum timestamptz int4range int8range numrange tsrange tstzrange daterange
296
+ tinytext mediumtext longtext tinyblob mediumblob longblob unsigned_integer unsigned_bigint
297
+ unsigned_float unsigned_decimal set year
298
+ ].freeze
299
+
300
+ def column_types
301
+ types = COLUMN_TYPES.dup
302
+ if defined?(ActiveRecord::Base) && !RailsAiContext.static_tier?
303
+ types |= ActiveRecord::Base.connection.native_database_types.keys.map(&:to_s)
304
+ end
305
+ types
306
+ rescue StandardError
307
+ COLUMN_TYPES
308
+ end
309
+
310
+ # A type no adapter knows writes a migration that fails when it runs.
311
+ def unknown_type(action, column, type)
312
+ written = case action
313
+ when "add_column", "change_type" then [ type ]
314
+ when "create_table" then column.to_s.split(",").map { |definition| definition.split(":")[1]&.strip }
315
+ end
316
+ bad = Array(written).compact.reject(&:empty?).find { |t| !column_types.include?(t) }
317
+ return nil unless bad
318
+
319
+ suggestion = find_closest_match(bad, column_types)
320
+ "**Error:** Unknown column type `#{echo_input(bad)}`.#{" Did you mean `#{suggestion}`?" if suggestion} " \
321
+ "Migration types include string, text, integer, bigint, decimal, boolean, date, datetime, json and references."
322
+ end
323
+
146
324
  # A table only a secondary database holds needs its migration in that database's migrations_paths.
147
325
  # [lines, target]: target is nil for the primary, false when no environment configures the
148
326
  # database, else the generator's --database flag and the RAILS_ENV that configures it.
@@ -160,8 +338,10 @@ module RailsAiContext
160
338
 
161
339
  names = found.keys
162
340
  env_name = found.values.first.first
163
- target = { flag: " --database #{names.first}", env: env_name }
164
- paths = Array(found.values.first.last["migrations_paths"]).join(", ")
341
+ configured = Array(found.values.first.last["migrations_paths"])
342
+ dirs = configured.any? ? configured.map { |path| File.expand_path(path, root.to_s) } : [ File.join(root.to_s, "db", "#{names.first}_migrate") ]
343
+ target = { flag: " --database #{names.first}", env: env_name, dirs: dirs }
344
+ paths = configured.join(", ")
165
345
  note = "**Database:** `#{table}` is in #{[ names[0..-2].join(", "), names.last ].reject(&:empty?).join(" and ")}," \
166
346
  "#{" which #{env_name} configures and this environment does not," if env_name} not the primary database: generate with #{"`RAILS_ENV=#{env_name}` and " if env_name}`#{target[:flag].strip}` " \
167
347
  "so the migration lands in #{env_name && !paths.empty? ? paths : "that database's migrations_paths"} and " \
@@ -193,6 +373,11 @@ module RailsAiContext
193
373
  lines << ""
194
374
  end
195
375
 
376
+ if table_exists && options.to_s.match?(/\bnull:\s*false\b/) && !options.to_s.match?(/\bdefault:/)
377
+ lines << not_null_warning(table, column)
378
+ lines << ""
379
+ end
380
+
196
381
  opts = options ? ", #{options}" : ""
197
382
  class_name = migration_class_name("add", table, column)
198
383
 
@@ -212,6 +397,22 @@ module RailsAiContext
212
397
  lines
213
398
  end
214
399
 
400
+ # NOT NULL with no default has no value for the rows already there, so
401
+ # the migration fails on a table that holds any. The row count is the
402
+ # database's own estimate when the app is booted.
403
+ def not_null_warning(table, column)
404
+ stats = Array(Payload.section(cached_context, :database_stats)&.dig(:tables)).find { |t| t[:table].to_s == table }
405
+ rows = stats&.dig(:approximate_rows)
406
+ held = if rows.nil? then "if `#{table}` holds any rows"
407
+ elsif stats[:at_least] then "as `#{table}` holds at least #{count_phrase(rows, "row")}"
408
+ else "as `#{table}` holds about #{count_phrase(rows, "row")}"
409
+ end
410
+ return "**Note:** `#{table}` is empty by the database's estimate, so `null: false` without a default runs; on a table with rows it fails." if rows&.zero?
411
+
412
+ "**Warning:** `null: false` without a default fails #{held}: add a `default:`, or add `#{column}` nullable, " \
413
+ "backfill it, then `change_column_null :#{table}, :#{column}, false`."
414
+ end
415
+
215
416
  def generate_remove_column(table, column, type, schema, models, target = nil)
216
417
  return [ "**Error:** column name is required for remove_column" ] unless column
217
418
 
@@ -255,7 +456,7 @@ module RailsAiContext
255
456
  end
256
457
 
257
458
  def generate_rename_column(table, column, new_name)
258
- return [ "**Error:** column (old name) and type (new name) are required" ] unless column && new_name
459
+ return [ "**Error:** `column` (the old name) and `new_name` are required for rename_column" ] unless column && new_name
259
460
 
260
461
  lines = []
261
462
 
@@ -312,20 +513,42 @@ module RailsAiContext
312
513
  lines
313
514
  end
314
515
 
516
+ # `column` names the reference and `type`, when given with it, the
517
+ # table it points at; otherwise that table is the reference pluralized.
518
+ # A name with no table of its own is a role: `parent` on `comments` is
519
+ # a self-reference, which needs `to_table:`, and any other role needs
520
+ # the table named before a foreign key can be written.
315
521
  def generate_add_association(table, column, type, options, schema = nil)
316
- foreign_table = column || type
317
- return [ "**Error:** Specify the associated table in column param (e.g., column: 'users')" ] unless foreign_table
522
+ written = column || type
523
+ return [ "**Error:** Specify the associated table in column param (e.g., column: 'users')" ] unless written
318
524
 
525
+ reference = written.to_s.singularize
319
526
  lines = []
320
- fk_column = "#{foreign_table.singularize}_id"
527
+ fk_column = "#{reference}_id"
321
528
  if column_exists?(table, fk_column)
322
529
  lines << "**Warning:** Column `#{fk_column}` already exists on `#{table}`. This migration will fail. Use `add_index` if you only need an index."
323
530
  lines << ""
324
531
  end
325
532
 
326
- to_table = foreign_table.singularize.pluralize
533
+ to_table = column && type ? type.to_s : reference.pluralize
534
+ if schema_known?(schema) && !table_in_schema?(schema, to_table)
535
+ if column && type
536
+ lines << "**Warning:** No `#{to_table}` table in the schema, so a foreign key to it fails; the reference below has none."
537
+ to_table = nil
538
+ elsif reference == "parent"
539
+ lines << "**Note:** No `#{to_table}` table: `parent` on `#{table}` reads as a reference to `#{table}` itself, " \
540
+ "written with `to_table:`. Pass the table it points at as `type` to point it elsewhere."
541
+ to_table = table
542
+ else
543
+ lines << "**Warning:** No `#{to_table}` table: `#{reference}` reads as a role. Pass the table it points at as `type` " \
544
+ "(e.g. `type:\"users\"`); the reference below has no foreign key until then."
545
+ to_table = nil
546
+ end
547
+ lines << ""
548
+ end
549
+
327
550
  own = RailsAiContext::Payload.schema_databases(schema, table)
328
- theirs = RailsAiContext::Payload.schema_databases(schema, to_table)
551
+ theirs = to_table ? RailsAiContext::Payload.schema_databases(schema, to_table) : []
329
552
  across = own.any? && theirs.any? && !own.intersect?(theirs)
330
553
  if across
331
554
  lines << "**Foreign key:** `#{to_table}` is in #{theirs.join(' and ')} and `#{table}` in #{own.join(' and ')}; " \
@@ -333,24 +556,58 @@ module RailsAiContext
333
556
  lines << ""
334
557
  end
335
558
 
559
+ foreign_key = if across || to_table.nil? then ""
560
+ elsif to_table == reference.pluralize then ", foreign_key: true"
561
+ else ", foreign_key: { to_table: :#{to_table} }"
562
+ end
336
563
  lines << "```ruby"
337
- lines << "class Add#{foreign_table.camelize}To#{table.camelize} < ActiveRecord::Migration[#{rails_version}]"
564
+ lines << "class Add#{written.camelize}To#{table.camelize} < ActiveRecord::Migration[#{rails_version}]"
338
565
  lines << " def change"
339
- lines << " add_reference :#{table}, :#{foreign_table.singularize}#{", foreign_key: true" unless across}"
566
+ lines << " add_reference :#{table}, :#{reference}#{foreign_key}"
340
567
  lines << " end"
341
568
  lines << "end"
342
569
  lines << "```"
343
570
  lines << ""
344
571
  lines << "**Reversible:** Yes"
345
- lines << "**Also add to models:**"
346
- lines << "```ruby"
347
- lines << "# app/models/#{table.singularize}.rb"
348
- lines << "belongs_to :#{foreign_table.singularize}"
349
- lines << ""
350
- lines << "# app/models/#{foreign_table.singularize}.rb"
351
- lines << "has_many :#{table}, dependent: :destroy"
572
+ lines.concat(association_model_lines(table, reference, to_table))
573
+ end
574
+
575
+ # The belongs_to and has_many the reference wants, in the models that
576
+ # read each table: a role names its class, and a self-reference is
577
+ # optional (a root has no parent) and names its children.
578
+ def association_model_lines(table, reference, to_table)
579
+ models = Payload.models(cached_context)
580
+ owner = models_for_table(table, models).first || table.singularize.camelize
581
+ lines = [ "**Also add to models:**", "```ruby", "# #{model_path(owner)}" ]
582
+ unless to_table
583
+ lines << "belongs_to :#{reference}, class_name: \"...\" # the model of the table it points at"
584
+ return lines << "```"
585
+ end
586
+
587
+ target = models_for_table(to_table, models).first || to_table.singularize.camelize
588
+ class_option = reference.camelize == target ? "" : ", class_name: \"#{target}\""
589
+ if to_table == table
590
+ lines << "belongs_to :#{reference}#{class_option}, optional: true"
591
+ lines << "has_many :children, class_name: \"#{target}\", foreign_key: :#{reference}_id, inverse_of: :#{reference}, dependent: :destroy"
592
+ else
593
+ lines << "belongs_to :#{reference}#{class_option}"
594
+ lines << "" << "# #{model_path(target)}"
595
+ inverse = class_option.empty? ? "" : ", foreign_key: :#{reference}_id, inverse_of: :#{reference}"
596
+ lines << "has_many :#{table}#{inverse}, dependent: :destroy"
597
+ end
352
598
  lines << "```"
353
- lines
599
+ end
600
+
601
+ def model_path(model)
602
+ Payload.model_file(cached_context, model) || "app/models/#{model.underscore}.rb"
603
+ end
604
+
605
+ def schema_known?(schema)
606
+ schema.is_a?(Hash) && Payload.schema_tables(schema).any?
607
+ end
608
+
609
+ def table_in_schema?(schema, name)
610
+ Payload.schema_tables(schema).any? { |_, table, _| table.to_s == name.to_s }
354
611
  end
355
612
 
356
613
  def generate_change_type(table, column, type, options)
@@ -366,7 +623,8 @@ module RailsAiContext
366
623
  opts = options ? ", #{options}" : ""
367
624
 
368
625
  # Detect original column type from schema for a reversible down method
369
- original_type = find_column_type(table, column, cached_context[:schema]) || "string"
626
+ known_type = find_column_type(table, column, cached_context[:schema])
627
+ original_type = known_type || "string"
370
628
 
371
629
  lines << "**Warning:** Changing column type may cause data loss if types are incompatible."
372
630
  lines << ""
@@ -382,12 +640,23 @@ module RailsAiContext
382
640
  lines << "end"
383
641
  lines << "```"
384
642
  lines << ""
385
- lines << "**Reversible:** No (requires explicit `down` method with original type)"
643
+ # change_column has no inverse of its own; the `down` above is what
644
+ # rolls it back, and it is only right when the schema knew the type.
645
+ lines << if known_type
646
+ "**Reversible:** Yes, through the `down` above, which restores `#{known_type}` (`change_column` alone cannot be reversed)"
647
+ else
648
+ "**Reversible:** Only if `#{column}` was a `string`: the schema does not list its type, so the `down` above guesses"
649
+ end
386
650
  lines
387
651
  end
388
652
 
389
- def generate_create_table(table, columns_str, options)
653
+ def generate_create_table(table, columns_str, options, table_exists = false)
390
654
  lines = []
655
+ if table_exists
656
+ lines << "**Warning:** Table `#{table}` already exists. `create_table` fails on it unless it passes `if_not_exists: true`, " \
657
+ "or `force: :cascade`, which drops the table and every row in it first. Use `add_column` to change it."
658
+ lines << ""
659
+ end
391
660
  lines << "```ruby"
392
661
  lines << "class Create#{table.camelize} < ActiveRecord::Migration[#{rails_version}]"
393
662
  lines << " def change"
@@ -499,18 +768,56 @@ module RailsAiContext
499
768
  Payload.model_file(cached_context, name)
500
769
  end
501
770
 
502
- def show_affected_models(table, models)
503
- rows = affected_model_rows(table, models)
771
+ # The models on the table, and, when the change can touch a relation,
772
+ # the associations that read it. Renaming or dropping a plain column
773
+ # moves no association, so those list the models alone.
774
+ def show_affected_models(table, models, action: nil, column: nil)
775
+ relations = !%w[remove_column rename_column change_type].include?(action) || column.to_s.end_with?("_id")
776
+ rows = affected_model_rows(table, models, relations: relations)
504
777
  return [] if rows.empty?
505
778
 
506
779
  [ "", "## Affected Models", "" ] + rows
507
780
  end
508
781
 
509
- def affected_model_rows(table, models)
782
+ MENTION_CAP = 15
783
+
784
+ # Every line in the app's Ruby and views that names the column, word
785
+ # for word: the code a rename or a drop breaks. A name the code also
786
+ # uses for something else shows up too, which is why each is listed
787
+ # rather than counted.
788
+ def column_mention_lines(column)
789
+ root = rails_app.root.to_s
790
+ pattern = /\b#{Regexp.escape(column)}\b/
791
+ found = []
792
+ %w[app lib config].each do |dir|
793
+ base = File.join(root, dir)
794
+ next unless Dir.exist?(base)
795
+
796
+ safe_glob(base, "**/*.{rb,erb,haml,slim,jbuilder}", File.realpath(root)).sort.each do |path|
797
+ next if sensitive_file?(path.delete_prefix("#{File.realpath(root)}/"))
798
+
799
+ source = RailsAiContext::SafeFile.read(path) or next
800
+ next unless source.match?(pattern)
801
+
802
+ source.each_line.with_index(1) do |line, number|
803
+ found << "- `#{path.delete_prefix("#{File.realpath(root)}/")}:#{number}` #{line.strip.truncate(100)}" if line.match?(pattern)
804
+ end
805
+ end
806
+ end
807
+ return [ "", "## Code that names `#{column}`", "", "_No line in app/, lib/ or config/ names it._" ] if found.empty?
808
+
809
+ more = found.size > MENTION_CAP ? [ "- _...and #{found.size - MENTION_CAP} more; `rails_search_code(pattern:\"#{column}\", exact_match:true)` lists them all_" ] : []
810
+ [ "", "## Code that names `#{column}` (#{count_phrase(found.size, "line")})", "" ] + found.first(MENTION_CAP) + more
811
+ rescue => e
812
+ RailsAiContext.debug_fail(e, [], label: "column_mention_lines")
813
+ end
814
+
815
+ def affected_model_rows(table, models, relations: true)
510
816
  return [] if models.empty?
511
817
 
512
818
  owners = models_for_table(table, models)
513
819
  rows = owners.map { |name| "- **#{name}** - directly affected (table: #{table})" }
820
+ return rows unless relations
514
821
 
515
822
  models.each do |name, data|
516
823
  next unless data.is_a?(Hash)