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
@@ -22,7 +22,9 @@ module RailsAiContext
22
22
  # relative: the caller's path, relative to `under`. root: the directory
23
23
  # the sensitive patterns are matched against (the app root for most tools).
24
24
  # listed: the path came from a listing of `under`, so it is spelled as on disk.
25
- def locate(relative, under:, root: under, max_size: nil, listed: false)
25
+ # links: a path through a directory linked in from the repository holding
26
+ # `root` (linked_in) is one of `under`'s; off, only `under` itself holds.
27
+ def locate(relative, under:, root: under, max_size: nil, listed: false, links: true)
26
28
  relative = relative.to_s
27
29
  return refuse(:traversal) if traversal?(relative)
28
30
  return refuse(:sensitive) if sensitive?(relative)
@@ -30,22 +32,31 @@ module RailsAiContext
30
32
  path = File.join(under.to_s, relative)
31
33
  real = listed ? real_file(path) : File.realpath(path)
32
34
  real_under = real_base(under)
33
- return refuse(:outside) unless contained?(real, real_under)
35
+ linked = links && !contained?(real, real_under) ? linked_in(path, real, under, root) : nil
36
+ return refuse(:outside) unless linked || contained?(real, real_under)
34
37
 
35
38
  real_root = real_base(root)
39
+ # `under` can itself be a linked-in directory, a pack's app/views, so the
40
+ # root's spelling of the path is asked for the link too.
41
+ linked ||= links && !contained?(real, real_root) ? linked_in(path, real, root, root) : nil
36
42
  root_relative = if real == real_root then ""
37
43
  elsif contained?(real, real_root) then real.delete_prefix(dir_prefix(real_root))
44
+ # Named the way the app spells it: packs/billing/app/models/invoice.rb.
45
+ elsif linked then spelled_relative(path, root)
38
46
  # A directory the caller trusts outside the root, such as the engine around a test/dummy.
39
47
  else Pathname.new(real).relative_path_from(Pathname.new(real_root)).to_s
40
48
  end
41
49
  return refuse(:sensitive) if sensitive?(root_relative)
50
+ # Read where the file is too: a name inside the linked directory.
51
+ return refuse(:sensitive) if linked && sensitive?(real.delete_prefix(dir_prefix(linked)))
42
52
  return refuse(:missing) unless File.file?(real)
43
53
 
44
54
  limit = max_size || RailsAiContext.configuration.max_file_size
45
55
  return refuse(:too_large, realpath: real, relative: root_relative) if File.size(real) > limit
46
56
 
47
57
  Resolution.new(realpath: real, relative: root_relative, refusal: nil)
48
- rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, Errno::ENAMETOOLONG, Errno::ENOTDIR
58
+ # EINVAL: Windows refuses a name holding `:`, `<`, `|` and the like.
59
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, Errno::ENAMETOOLONG, Errno::ENOTDIR, Errno::EINVAL
49
60
  refuse(:missing)
50
61
  end
51
62
 
@@ -63,6 +74,55 @@ module RailsAiContext
63
74
  File.join(real_base(File.dirname(path)), File.basename(path))
64
75
  end
65
76
 
77
+ # Where a directory link inside the app may lead and still be the app's:
78
+ # the git work tree holding `root`, so a monorepo's shared package counts
79
+ # and a home directory does not. Outside any repository nothing marks
80
+ # where the project ends, so only `root` itself.
81
+ def link_bound(root)
82
+ RunCache.fetch([ :safe_path_link_bound, root.to_s ]) do
83
+ real_root = real_base(root)
84
+ repo = git_root(real_root)
85
+ repo ? File.realpath(repo) : real_root
86
+ end
87
+ end
88
+
89
+ # The real path of the linked-in directory that holds `real`: a directory
90
+ # link on `path`'s spelling below `base` that leads inside link_bound(root).
91
+ # Nil when no such link leads to it, as for a file linked out on its own.
92
+ # A spelled `..` is read lexically, so it cannot climb out through a link.
93
+ def linked_in(path, real, base, root)
94
+ spelled = File.expand_path(path.to_s)
95
+ prefix = dir_prefix(File.expand_path(base.to_s))
96
+ return nil unless spelled.start_with?(prefix)
97
+
98
+ bound = link_bound(root)
99
+ segments = spelled.delete_prefix(prefix).split(File::SEPARATOR)
100
+ segments.size.downto(1) do |depth|
101
+ linked = linked_dir(File.join(prefix, *segments.first(depth)), bound)
102
+ return linked if linked && contained?(real, linked)
103
+ end
104
+ nil
105
+ end
106
+
107
+ # A directory link's real path when it leads inside `bound`, else nil.
108
+ def linked_dir(dir, bound)
109
+ RunCache.fetch([ :safe_path_linked_dir, dir, bound ]) do
110
+ real = File.symlink?(dir) && File.directory?(dir) && File.realpath(dir)
111
+ real if real && contained?(real, bound)
112
+ end
113
+ rescue SystemCallError
114
+ nil
115
+ end
116
+
117
+ # `path` relative to `root` as both are spelled.
118
+ def spelled_relative(path, root)
119
+ spelled = File.expand_path(path.to_s)
120
+ spelled_root = File.expand_path(root.to_s)
121
+ return spelled.delete_prefix(dir_prefix(spelled_root)) if spelled.start_with?(dir_prefix(spelled_root))
122
+
123
+ Pathname.new(spelled).relative_path_from(Pathname.new(spelled_root)).to_s
124
+ end
125
+
66
126
  def read(relative, under:, root: under, max_size: nil)
67
127
  resolution = locate(relative, under: under, root: root, max_size: max_size)
68
128
  return [ nil, resolution ] unless resolution.ok?
@@ -75,7 +135,21 @@ module RailsAiContext
75
135
  # that resolve a directory rather than a file and so cannot use `locate`.
76
136
  def traversal?(relative)
77
137
  relative = relative.to_s
78
- relative.include?("..") || relative.start_with?("/") || relative.include?("\0")
138
+ relative.include?("\0") || relative.include?("..") || absolute?(relative)
139
+ end
140
+
141
+ # Whether a path names its place whatever directory it is joined to:
142
+ # `/x` everywhere, and on Windows `C:/x`, `C:\x` and `\\server\share`,
143
+ # which File.absolute_path? answers there, as well as `\x` (the current
144
+ # drive's root) and `C:x` (that drive's current directory). A NUL byte
145
+ # names no file at all, and File.absolute_path? raises on one.
146
+ def absolute?(path)
147
+ path = path.to_s
148
+ return true if path.start_with?("/")
149
+ return false if path.include?("\0")
150
+ return true if File.absolute_path?(path)
151
+
152
+ !File::ALT_SEPARATOR.nil? && path.match?(/\A(?:\\|[A-Za-z]:)/)
79
153
  end
80
154
 
81
155
  PLACEHOLDER_SUFFIXES = %w[.example .sample .template .dist].freeze
@@ -102,6 +176,29 @@ module RailsAiContext
102
176
  real == real_dir || real.start_with?(dir_prefix(real_dir))
103
177
  end
104
178
 
179
+ # One spelling of a place whatever links lead to it, for comparing two
180
+ # paths: the real path, or for a path not there yet its nearest existing
181
+ # ancestor's real path with the rest appended. Stdlib only, so it serves
182
+ # before the gem entry loads too.
183
+ #
184
+ # Worked in bytes: a name that is not UTF-8 arrives as a broken UTF-8
185
+ # string, which Ruby 3.1's delete_prefix leaves whole, and realpath
186
+ # answers it as bytes, which File.join will not mix with a UTF-8 rest.
187
+ def canonical(path)
188
+ path = File.expand_path(path.to_s)
189
+ existing = path
190
+ existing = File.dirname(existing) until File.exist?(existing) || File.dirname(existing) == existing
191
+ rest = path.b.delete_prefix(existing.b)
192
+ real = File.realpath(existing)
193
+ return real if rest.empty?
194
+
195
+ joined = File.join(real.b, rest)
196
+ text = joined.dup.force_encoding(Encoding::UTF_8)
197
+ text.valid_encoding? ? text : joined
198
+ rescue SystemCallError
199
+ path
200
+ end
201
+
105
202
  # The nearest directory at or above `dir` that holds `.git` (a directory, or a
106
203
  # file in a worktree or submodule), or nil outside any repository.
107
204
  def git_root(dir)
@@ -96,6 +96,14 @@ module RailsAiContext
96
96
  database_label(primary)
97
97
  end
98
98
 
99
+ # A secondary database's adapter: its database.yml entry, else the dialect
100
+ # its structure.sql is in. nil when neither says; schema.rb does not.
101
+ def secondary_label(context, name, schema)
102
+ multi = context.is_a?(Hash) ? context[:multi_database] : nil
103
+ databases = Tools::SectionFetch.usable?(multi) ? Array(multi[:databases]) : []
104
+ database_label(databases.find { |db| db.is_a?(Hash) && db[:name].to_s == name.to_s }) || from_dialect(schema)
105
+ end
106
+
99
107
  # These are ActiveRecord adapter names (`postgresql`), not gem names; the two
100
108
  # overlap only at sqlite3/mysql2/trilogy.
101
109
  #
@@ -51,6 +51,15 @@ module RailsAiContext
51
51
  format, path = Introspectors::SchemaDumpPath.present(root)
52
52
  return nil unless format == :sql
53
53
 
54
+ recorded(format, path)
55
+ end
56
+
57
+ # What one dump records as applied: structure.sql's whole set, schema.rb's
58
+ # newest version. A secondary database's dump answers for that database.
59
+ def self.recorded(format, path)
60
+ return nil unless path && File.exist?(path)
61
+ return from_schema_rb(path) unless format == :sql
62
+
54
63
  content = RailsAiContext::SafeFile.read(path, max_size: RailsAiContext.configuration.max_schema_file_size)
55
64
  return nil unless content
56
65
 
@@ -16,6 +16,9 @@ module RailsAiContext
16
16
  "rails-components.md" => { renderer: :render_components_reference, reason: "no view components" }
17
17
  }.freeze
18
18
 
19
+ # Per database; the tools have the rest.
20
+ TABLES_SHOWN = 30
21
+
19
22
  # @param output_dir [String] Rails root path
20
23
  # @return [Hash] { written: [paths], skipped: [paths], not_applicable: { path => reason } }
21
24
  def call(output_dir)
@@ -36,44 +39,78 @@ module RailsAiContext
36
39
  lines.concat(overview_lines(gems: false, architecture: false, app_dirs: false))
37
40
 
38
41
  lines << ""
39
- lines << "ALWAYS use MCP tools for context - do NOT read reference files directly."
40
- lines << "Start with `detail:\"summary\"`. Read files ONLY when you will Edit them."
42
+ lines << "ALWAYS use #{tools_noun} for context - do NOT read reference files directly."
43
+ lines << "Start with `#{param_text("detail", "summary")}`. Read files ONLY when you will Edit them."
41
44
 
42
45
  lines.join("\n")
43
46
  end
44
47
 
48
+ # Every database's tables, the primary's first. With more than one, each
49
+ # gets a heading, since a table name can be in two of them. A database
50
+ # a Rails framework keeps to itself (Rails 8's queue, cache and cable)
51
+ # is no part of it: its tables are the framework's, CLAUDE.md names it,
52
+ # and its dump says nothing this listing would help with.
45
53
  def render_schema_reference
46
54
  schema = Payload.section(context, :schema)
47
55
  return nil unless schema
48
- tables = schema[:tables] || {}
49
- return nil if tables.empty?
50
56
 
51
- lines = [
52
- "---",
53
- "paths:",
54
- # The glob has to name the dump the app committed, or the rule never
55
- # triggers on a :sql app: db/schema.rb is never opened there.
56
- " - \"#{schema_dump_path}\"",
57
- ' - "db/migrate/**"',
58
- "---",
59
- "",
60
- "# Database Tables (#{tables.size})",
61
- ""
62
- ]
63
- lines.concat(SectionFacts.static_notice_lines(context))
64
- lines << "_Snapshot - may be stale after migrations. Use `rails_get_schema(table:\"name\")` for live data._"
57
+ secondary = Payload.app_databases(schema)
58
+ databases = [ [ nil, schema[:tables] || {} ] ] + secondary.map { |name, db| [ name, db[:tables] ] }
59
+ databases.reject! { |_, tables| tables.empty? }
60
+ return nil if databases.empty?
61
+
62
+ lines = [ "---", "paths:", *schema_rule_paths(secondary).map { |path| " - \"#{path}\"" }, "---", "" ]
63
+ lines << "# Database Tables (#{databases.sum { |_, tables| tables.size }})"
65
64
  lines << ""
65
+ lines.concat(SectionFacts.static_notice_lines(context))
66
+ live = " Use #{tool_ref("rails_get_schema", 'table:"name"', "table=name")} for live data." if served?("rails_get_schema")
67
+ lines << "_Snapshot - may be stale after migrations.#{live}_"
68
+
69
+ databases.each do |name, tables|
70
+ lines << ""
71
+ lines.concat(database_heading_lines(name, tables, secondary)) if secondary.any?
72
+ lines.concat(table_reference_lines(tables, name, schema))
73
+ end
74
+
75
+ lines.join("\n")
76
+ end
77
+
78
+ # The dumps and migrations the tables are read from, each database's, so
79
+ # opening any of them brings the rule in. The primary's has to name the
80
+ # dump the app committed, or the rule never triggers on a :sql app:
81
+ # db/schema.rb is never opened there.
82
+ def schema_rule_paths(secondary)
83
+ paths = [ schema_dump_path, "db/migrate/**" ]
84
+ secondary.each_value do |db|
85
+ paths << db[:dump] if db[:dump]
86
+ Array(db[:migrations_paths]).each { |dir| paths << "#{dir}/**" }
87
+ end
88
+ paths.uniq
89
+ end
66
90
 
91
+ def database_heading_lines(name, tables, secondary)
92
+ relations = Introspectors::SchemaConventions.relations_phrase(tables)
93
+ return [ "## primary (#{SchemaAdapter.label(context)}, #{relations})", "" ] unless name
94
+
95
+ db = secondary[name]
96
+ adapter = SchemaAdapter.secondary_label(context, name, db)
97
+ lines = [ "## #{name} (#{[ adapter, relations ].compact.join(', ')})", "" ]
98
+ # Where the snapshot comes from: a booted run reads the primary live, this from its files.
99
+ lines.push("_#{db[:note]}._", "") if db[:note]
100
+ lines
101
+ end
102
+
103
+ def table_reference_lines(tables, database, schema)
104
+ lines = []
67
105
  skip_cols = %w[id created_at updated_at]
68
106
  keep_cols = %w[type deleted_at discarded_at]
69
107
  # Get enum values from models introspection if available
70
108
  models = Payload.models(context)
71
109
 
72
- tables.keys.sort.first(30).each do |name|
110
+ tables.keys.sort.first(TABLES_SHOWN).each do |name|
73
111
  data = tables[name]
74
112
  columns = data[:columns] || []
75
113
  col_count = columns.size
76
- pk_display = Introspectors::SchemaConventions.primary_key_label(data[:primary_key])
77
114
 
78
115
  # Show column names WITH types for key columns
79
116
  # Skip standard Rails FK columns (like user_id, account_id) but keep
@@ -118,8 +155,7 @@ module RailsAiContext
118
155
  lines << "- **#{name}** (#{count_phrase(col_count, 'col')})#{col_str}#{fk_str}#{idx_str}"
119
156
 
120
157
  # Include enum values if model has them
121
- model_name = name.classify
122
- model_data = models[model_name]
158
+ model_data = table_model(models, name, database, schema)
123
159
  if model_data.is_a?(Hash) && model_data[:enums]&.any?
124
160
  model_data[:enums].each do |attr, values|
125
161
  lines << " #{attr}: #{SectionFacts.enum_values(values)}"
@@ -127,11 +163,24 @@ module RailsAiContext
127
163
  end
128
164
  end
129
165
 
130
- if tables.size > 30
131
- lines << "- ...#{count_phrase(tables.size - 30, "more table")} (use `rails_get_schema` MCP tool)"
166
+ if tables.size > TABLES_SHOWN
167
+ use = " (use #{tool_named("rails_get_schema")})" if served?("rails_get_schema")
168
+ lines << "- ...#{count_phrase(tables.size - TABLES_SHOWN, "more table")}#{use}"
132
169
  end
133
170
 
134
- lines.join("\n")
171
+ lines
172
+ end
173
+
174
+ # The model named after the table. Another database's table of that name
175
+ # is some other model's: the one that reads from that database.
176
+ def table_model(models, table, database, schema)
177
+ named = models[table.classify]
178
+ return named unless database
179
+ return named if named.is_a?(Hash) && Payload.model_databases(schema, named).include?(database)
180
+
181
+ models.keys.sort.map { |name| models[name] }.find do |data|
182
+ data.is_a?(Hash) && data[:table_name] == table && Payload.model_databases(schema, data).include?(database)
183
+ end
135
184
  end
136
185
 
137
186
  def render_models_reference
@@ -148,7 +197,11 @@ module RailsAiContext
148
197
  ""
149
198
  ]
150
199
  lines.concat(SectionFacts.static_notice_lines(context))
151
- lines << "_Quick reference - use `rails_get_model_details(model:\"Name\")` for live data with resolved concerns and callbacks._"
200
+ lines << if served?("rails_get_model_details")
201
+ "_Quick reference - use #{tool_ref("rails_get_model_details", 'model:"Name"', "model=Name")} for live data with resolved concerns and callbacks._"
202
+ else
203
+ "_Quick reference._"
204
+ end
152
205
  lines << ""
153
206
 
154
207
  models.keys.sort.each do |name|
@@ -161,8 +214,9 @@ module RailsAiContext
161
214
  assocs = (data[:associations] || []).size
162
215
  vals = (data[:validations] || []).size
163
216
  table = data[:table_name]
217
+ note = SectionFacts.table_note(Payload.section(context, :schema), data)
164
218
  line = "- #{name}"
165
- line += " (table: #{table})" if table
219
+ line += note ? " (table #{note})" : (" (table: #{table})" if table).to_s
166
220
  line += " - #{count_phrase(assocs, "assoc")}, #{count_phrase(vals, "validation")}"
167
221
  lines << line
168
222
 
@@ -218,11 +272,9 @@ module RailsAiContext
218
272
  ""
219
273
  ]
220
274
  lines.concat(SectionFacts.static_notice_lines(context))
221
- lines.concat([
222
- "ViewComponent and Phlex components available for reuse.",
223
- "Use `rails_get_component_catalog(component:\"Name\")` for full details.",
224
- ""
225
- ])
275
+ lines << "ViewComponent and Phlex components available for reuse."
276
+ lines << "Use #{tool_ref("rails_get_component_catalog", 'component:"Name"', "component=Name")} for full details." if served?("rails_get_component_catalog")
277
+ lines << ""
226
278
 
227
279
  components.each do |c|
228
280
  slots = (c[:slots] || []).map { |s| s[:name] }
@@ -235,8 +287,11 @@ module RailsAiContext
235
287
  lines.join("\n")
236
288
  end
237
289
 
290
+ # Always loaded beside CLAUDE.md, which carries the guide whenever root
291
+ # files are on; without CLAUDE.md this is the only place for the guide.
238
292
  def render_mcp_tools_reference
239
- render_tools_guide.join("\n")
293
+ guide = RailsAiContext.configuration.generate_root_files ? render_tools_reference("CLAUDE.md") : render_tools_guide
294
+ guide.join("\n")
240
295
  end
241
296
  end
242
297
  end
@@ -4,9 +4,10 @@ module RailsAiContext
4
4
  module Serializers
5
5
  # Shared rendering methods for compact-mode serializers (Claude, OpenCode).
6
6
  # Include in any serializer that has a `context` reader and includes
7
- # StackOverviewHelper, ToolGuideHelper, and TestCommandDetection.
7
+ # StackOverviewHelper and TestCommandDetection.
8
8
  module CompactSerializerHelper
9
9
  include CountPhrase
10
+ include ToolGuideHelper
10
11
 
11
12
  private
12
13
 
@@ -40,11 +41,7 @@ module RailsAiContext
40
41
  end
41
42
 
42
43
  routes = Payload.section(context, :routes)
43
- if routes
44
- lines << "- Routes: #{count_phrase(RouteCoverage.app_route_count(routes), "app route")} across " \
45
- "#{RouteCoverage.controller_phrase(routes)} " \
46
- "(#{routes[:total_routes]} total incl. framework#{RouteCoverage.suffix(routes)})"
47
- end
44
+ lines << "- Routes: #{RouteCoverage.summary(routes)}" if routes
48
45
 
49
46
  jobs = Payload.section(context, :jobs)
50
47
  if jobs
@@ -60,11 +57,8 @@ module RailsAiContext
60
57
  lines << "- Async: #{parts.join(', ')}" if parts.any?
61
58
  end
62
59
 
63
- migrations = Payload.section(context, :migrations)
64
- if migrations
65
- line = "- Migrations: #{migrations[:total]} total"
66
- line += ", #{Payload.pending_migrations(context).size} pending" if migrations.key?(:pending)
67
- lines << line
60
+ if (migrations_line = SectionFacts.migrations_line(context))
61
+ lines << migrations_line
68
62
  end
69
63
 
70
64
  lines.concat(full_preset_stack_lines)
@@ -97,7 +91,10 @@ module RailsAiContext
97
91
  extras = model_extras_line(data)
98
92
  lines << extras if extras
99
93
  end
100
- lines << "- _...#{models.size - max_show} more (use `rails_get_model_details` tool)_" if models.size > max_show
94
+ if models.size > max_show
95
+ use = " (use #{tool_named("rails_get_model_details")})" if served?("rails_get_model_details")
96
+ lines << "- _...#{models.size - max_show} more#{use}_"
97
+ end
101
98
  lines << ""
102
99
  lines
103
100
  end
@@ -130,23 +127,28 @@ module RailsAiContext
130
127
  SectionFacts.warnings(context)
131
128
  end
132
129
 
133
- TRIM_NOTE = "_Context trimmed. Use MCP tools for full details._"
130
+ # Where the cut detail is: the tools, as the mode reaches them.
131
+ def trim_note
132
+ "_Context trimmed. Use #{tools_noun} for full details._"
133
+ end
134
134
 
135
- # Counts non-blank physical lines. `keep` holds the trailing how-to-behave sections, so an
136
- # over-budget file loses the data above them, which the MCP tools answer in full.
137
- def enforce_max_lines(lines, keep: [])
135
+ # Counts non-blank physical lines. `head` (the title block) and `keep`
136
+ # (the trailing how-to-behave sections: commands, warnings, rules, the
137
+ # tools guide and its protocol) are never cut. An over-budget file
138
+ # loses the data between them, which the tools answer in full, down to
139
+ # none of it, so a budget smaller than the kept sections leaves just
140
+ # them: the guide is what makes a compact file worth reading.
141
+ def enforce_max_lines(lines, head: [], keep: [])
138
142
  # The BEGIN/END markers the writer adds count against the budget.
139
143
  max = RailsAiContext.configuration.claude_max_lines - SectionMarkerWriter::MARKER_LINES
144
+ head = physical(head)
140
145
  body = physical(lines)
141
146
  tail = physical(keep)
142
- return (body + tail).join("\n") if content_count(body) + content_count(tail) <= max
143
-
144
- # A hard cap: a tail too big to fit is trimmed from the end like any other overflow.
145
- budget = max - 1 - content_count(tail)
146
- return truncate(body + tail, max - 1).join("\n") if budget <= 0
147
+ fixed = content_count(head) + content_count(tail)
148
+ return (head + body + tail).join("\n") if fixed + content_count(body) <= max
147
149
 
148
150
  separator = tail.empty? ? [] : [ "" ]
149
- (truncate(body, budget) + separator + tail).join("\n")
151
+ (head + truncate(body, max - 1 - fixed) + separator + tail).join("\n")
150
152
  end
151
153
 
152
154
  def physical(lines)
@@ -176,7 +178,7 @@ module RailsAiContext
176
178
  # nested headings and separators included.
177
179
  kept.pop while kept.any? && (kept.last.strip.empty? || heading?(kept.last))
178
180
  kept << "" if kept.any?
179
- kept << TRIM_NOTE
181
+ kept << trim_note
180
182
  end
181
183
 
182
184
  def heading?(line)
@@ -202,11 +204,17 @@ module RailsAiContext
202
204
 
203
205
  # The scan decides, so services under a configured extra path show wherever
204
206
  # the conventions walk looked.
207
+ names = []
205
208
  services = service_names
206
- lines << "" << "**Services:** #{services.join(', ')}" if services.any?
207
-
209
+ names << "**Services:** #{services.join(', ')}" if services.any?
208
210
  jobs = job_names
209
- lines << "**Jobs:** #{jobs.join(', ')}" if jobs.any?
211
+ names << "**Jobs:** #{jobs.join(', ')}" if jobs.any?
212
+
213
+ # The blank line sets the names apart from a pattern list. With no list
214
+ # above them - the static tier reads no conventions - it left the
215
+ # heading standing over nothing.
216
+ lines << "" if lines.any? && names.any?
217
+ lines.concat(names)
210
218
  return [] if lines.empty?
211
219
 
212
220
  [ "## Architecture", *lines, "" ]
@@ -246,15 +254,9 @@ module RailsAiContext
246
254
 
247
255
  # The compact pipeline behind CLAUDE.md, AGENTS.md, .cursorrules and copilot-instructions.md.
248
256
  def render_compact_rules
249
- lines = []
250
- lines.concat(render_header)
251
- lines.concat(render_stack_overview)
252
- lines.concat(render_key_models)
253
- lines.concat(render_notable_gems)
254
- lines.concat(render_architecture)
255
-
257
+ data = render_stack_overview + render_key_models + render_notable_gems + render_architecture
256
258
  instructions = render_commands + render_warnings + render_footer + render_tools_guide_compact
257
- enforce_max_lines(lines, keep: instructions)
259
+ enforce_max_lines(data, head: render_header, keep: instructions)
258
260
  end
259
261
  end
260
262
  end
@@ -55,14 +55,19 @@ module RailsAiContext
55
55
  end
56
56
 
57
57
  # Callers that pay for an introspection before writing check the format
58
- # first, so a name nothing can write is refused before the work.
58
+ # first, so a name nothing can write is refused before the work. The
59
+ # names come in the order the CLI's help lists them, and a single name
60
+ # (a command's --format or argument) may also be `all`; a list, which
61
+ # is config.ai_tools, may not.
59
62
  def self.validate_format!(format)
60
63
  return if format.nil? || format == :all
61
64
 
62
65
  unknown = Array(format).reject { |fmt| ALL_FORMATS.include?(fmt) }
63
66
  return if unknown.empty?
64
67
 
65
- raise ArgumentError, "Unknown format: #{unknown.first}. Valid formats: #{ALL_FORMATS.map(&:to_s).join(', ')}"
68
+ valid = (Install::AiTool.all.map(&:key) & ALL_FORMATS) | ALL_FORMATS
69
+ valid += [ :all ] unless format.is_a?(Array)
70
+ raise ArgumentError, "Unknown format: #{unknown.first}. Valid formats: #{valid.join(', ')}"
66
71
  end
67
72
 
68
73
  # Write context files, skipping unchanged ones.
@@ -85,9 +90,7 @@ module RailsAiContext
85
90
  # - a different path from a boot that started and failed.
86
91
  output_dir = RailsAiContext.configuration.output_dir_for(RailsAiContext.default_app)
87
92
  generate_root = RailsAiContext.configuration.generate_root_files
88
- written = []
89
- skipped = []
90
- not_applicable = {}
93
+ result = { written: [], skipped: [], not_applicable: {} }
91
94
 
92
95
  seen_root_files = Set.new
93
96
 
@@ -105,7 +108,7 @@ module RailsAiContext
105
108
  # generate_root_files = false is a deliberate omission, so it is
106
109
  # reported rather than dropped. The JSON dump is no tool's root file.
107
110
  unless generate_root || fmt == :json
108
- not_applicable[filepath] = "root files disabled"
111
+ result[:not_applicable][filepath] = "root files disabled"
109
112
  next
110
113
  end
111
114
 
@@ -113,16 +116,16 @@ module RailsAiContext
113
116
  content = serialize(fmt)
114
117
 
115
118
  if fmt == :json
116
- write_plain(filepath, content, written, skipped)
119
+ write_plain(filepath, content, result)
117
120
  else
118
- write_with_markers(filepath, content, written, skipped)
121
+ SectionMarkerWriter.report(SectionMarkerWriter.write_with_markers(filepath, content), filepath, result)
119
122
  end
120
123
  end
121
124
 
122
125
  # Split rules are always generated regardless of generate_root_files
123
- generate_split_rules(formats, output_dir, written, skipped, not_applicable)
126
+ generate_split_rules(formats, output_dir, result)
124
127
 
125
- { written: written, skipped: skipped, not_applicable: not_applicable }
128
+ result
126
129
  end
127
130
 
128
131
  private
@@ -132,12 +135,12 @@ module RailsAiContext
132
135
  end
133
136
 
134
137
  # JSON and other formats that don't support HTML comments
135
- def write_plain(filepath, content, written, skipped)
138
+ def write_plain(filepath, content, result)
136
139
  if File.exist?(filepath) && same_payload?(File.read(filepath), content)
137
- skipped << filepath
140
+ result[:skipped] << filepath
138
141
  else
139
142
  RailsAiContext::SafeFile.atomic_write(filepath, content)
140
- written << filepath
143
+ result[:written] << filepath
141
144
  end
142
145
  end
143
146
 
@@ -154,25 +157,14 @@ module RailsAiContext
154
157
  false
155
158
  end
156
159
 
157
- # Wrap content in section markers so user content is preserved.
158
- # Delegates to SectionMarkerWriter (also used by CursorRulesSerializer
159
- # for .cursorrules) so the marker contract is implemented in exactly
160
- # one place.
161
- def write_with_markers(filepath, content, written, skipped)
162
- case SectionMarkerWriter.write_with_markers(filepath, content)
163
- when :written then written << filepath
164
- when :skipped then skipped << filepath
165
- end
166
- end
167
-
168
- def generate_split_rules(formats, output_dir, written, skipped, not_applicable)
160
+ def generate_split_rules(formats, output_dir, result)
169
161
  serializers = formats.filter_map { |fmt| RULES_SERIALIZERS[fmt] }.uniq
170
162
 
171
163
  serializers.each do |serializer|
172
- result = serializer.new(context).call(output_dir)
173
- written.concat(result[:written])
174
- skipped.concat(result[:skipped])
175
- not_applicable.merge!(result[:not_applicable] || {})
164
+ rules = serializer.new(context).call(output_dir)
165
+ result[:written].concat(rules[:written])
166
+ result[:skipped].concat(rules[:skipped])
167
+ result[:not_applicable].merge!(rules[:not_applicable] || {})
176
168
  end
177
169
  end
178
170
  end