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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "open3"
4
+ require "set"
4
5
 
5
6
  module RailsAiContext
6
7
  module Tools
@@ -34,6 +35,9 @@ module RailsAiContext
34
35
  # one on a receiver (`def self.x`, `def Widget.x`) are definitions too.
35
36
  DEF_HEAD = "^\\s*(?:[a-z_]\\w*\\s+)*def\\s+(?:(?:self|[A-Z]\\w*(?:::[A-Z]\\w*)*)\\.)?"
36
37
  DEF_LINE = /\A\s*(?:[a-z_]\w*\s+)*def\s/
38
+ # A method or constant name, receiver or namespace and all, or one of
39
+ # Ruby's operator method names.
40
+ PLAIN_NAME = %r{\A(?:(?:self|[A-Z]\w*(?:::[A-Z]\w*)*)\.)?(?:(?:::)?[A-Za-z_]\w*(?:::[A-Za-z_]\w*)*[?!=]?|\[\]=?|[-+]@|\*\*|<=>|===?|[=!]~|!=|<<|>>|[<>]=|[-+*/%<>&|^~!`])\z}
37
41
  # Constant forms that define a class without the keyword.
38
42
  CLASS_BUILDERS = "(?:::)?(?:Data\\.define|Struct\\.new|Class\\.new|Module\\.new)\\b"
39
43
  NOT_A_CALL_LINE = {
@@ -58,7 +62,7 @@ module RailsAiContext
58
62
  properties: {
59
63
  pattern: {
60
64
  type: "string",
61
- description: "Search pattern (regex supported)."
65
+ description: "Search pattern (regex supported). For definition and class, a plain method or constant name matches literally (`valid?`), anything else as a regex (`generate_.*`)."
62
66
  },
63
67
  path: {
64
68
  type: "string",
@@ -71,7 +75,7 @@ module RailsAiContext
71
75
  match_type: {
72
76
  type: "string",
73
77
  enum: %w[any definition class call trace],
74
- description: "any: all matches (default). definition: `def` lines only. class: `class/module` lines. call: call sites only (excludes definitions). trace: FULL PICTURE - shows definition + source code + all callers + what it calls internally."
78
+ description: "any: all matches (default). definition: `def` lines whose method name contains the pattern. class: `class/module` lines whose name contains it. call: call sites only (excludes definitions). trace: FULL PICTURE - shows definition + source code + all callers + what it calls internally."
75
79
  },
76
80
  exact_match: {
77
81
  type: "boolean",
@@ -121,25 +125,31 @@ module RailsAiContext
121
125
 
122
126
  # Trace mode: definition + source + callers + internal calls in one response
123
127
  if match_type == "trace"
124
- return trace_method(pattern.strip, root, path, exclude_tests)
128
+ search_path, answer = resolve_search_path(path, root)
129
+ return answer || trace_method(pattern.strip, root, search_path, exclude_tests)
125
130
  end
126
131
 
127
132
  # Validate match_type
128
133
  valid_match_types = %w[any definition class call trace]
129
134
  unless valid_match_types.include?(match_type)
130
- return text_response("Unknown match_type: '#{match_type}'. Valid values: #{valid_match_types.join(', ')}")
135
+ return text_response("Unknown match_type: '#{echo_input(match_type)}'. Valid values: #{valid_match_types.join(', ')}")
131
136
  end
132
137
 
133
138
  # Apply match_type filter to pattern (exact_match word boundaries applied per-type)
134
139
  search_pattern = case match_type
135
140
  when "definition"
136
141
  cleaned = pattern.sub(/\A\s*def\s+/, "")
137
- escaped = literal(cleaned)
138
- # `def\s+` already anchors the left edge, so only a trailing boundary.
139
- exact_match ? "#{DEF_HEAD}#{escaped}#{RailsAiContext::MethodName.definition_end(cleaned)}" : "#{DEF_HEAD}#{escaped}"
142
+ if exact_match
143
+ # `def\s+` already anchors the left edge, so only a trailing boundary.
144
+ "#{DEF_HEAD}#{literal(cleaned)}#{RailsAiContext::MethodName.definition_end(cleaned)}"
145
+ else
146
+ # Anywhere in the name, as `class` takes a prefix: `slug` finds
147
+ # `def generate_slug` as well as `def slug_source`.
148
+ "#{DEF_HEAD}\\w*#{name_source(cleaned)}"
149
+ end
140
150
  when "class"
141
151
  cleaned = pattern.sub(/\A\s*(class|module)\s+/, "")
142
- name = "(?:\\w+::)*\\w*#{literal(cleaned)}"
152
+ name = "(?:\\w+::)*\\w*#{exact_match ? literal(cleaned) : name_source(cleaned)}"
143
153
  # `\w*` stays unbounded so a CamelCase prefix still resolves.
144
154
  tail = exact_match ? trailing_boundary(cleaned) : "\\w*"
145
155
  "^\\s*(?:(?:class|module)\\s+(?:::)?#{name}#{exact_match ? tail : ""}|#{name}#{tail}\\s*=\\s*#{CLASS_BUILDERS})"
@@ -164,38 +174,21 @@ module RailsAiContext
164
174
  context_lines = [ [ context_lines.to_i, 0 ].max, 5 ].min
165
175
  offset = [ offset.to_i, 0 ].max
166
176
 
167
- # Before the join, which would turn an absolute path into a subpath of
168
- # the root and leave it looking merely absent.
169
- return error_response("Path not allowed: #{path}") if path && RailsAiContext::SafePath.traversal?(path)
170
-
171
- search_path = path ? File.join(root, path) : root
172
- return error_response("Path not allowed: #{path}") if path && sensitive_file?(path)
173
-
174
- # A symlink under the root can still resolve outside it; that check is
175
- # on the realpath below.
176
- unless File.exist?(search_path)
177
- top_dirs = Dir.glob(File.join(root, "*")).select { |f| File.directory?(f) }.map { |f| File.basename(f) }.sort
178
- return text_response("Path not found: #{path}. Top-level directories: #{top_dirs.first(15).join(', ')}")
179
- end
180
-
181
- begin
182
- real_root = File.realpath(root)
183
- real_search = File.realpath(search_path)
184
- rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, Errno::ENAMETOOLONG
185
- return text_response("Path not found: #{path}")
186
- end
187
- return error_response("Path not allowed: #{path}") unless RailsAiContext::SafePath.contained?(real_search, real_root)
188
- # A file named as the path is read whatever it is linked to.
189
- return error_response("Path not allowed: #{path}") if File.file?(real_search) && sensitive_file?(real_search.delete_prefix("#{real_root}/"))
177
+ search_path, answer = resolve_search_path(path, root)
178
+ return answer if answer
190
179
 
180
+ # The cap counts context lines too, so one below the first match's
181
+ # leading context cut the match itself and the answer read "No results
182
+ # found". It always holds that much.
183
+ line_cap = [ max_results_cap, context_lines + 1 ].max
191
184
  # One row past the cap, so a cut list is knowable rather than silent.
192
- fetch_limit = max_results_cap + 1
185
+ fetch_limit = line_cap + 1
193
186
  fetched, search_error = if ripgrep_available?
194
187
  search_with_ripgrep(search_pattern, search_path, file_type, fetch_limit, root, context_lines, exclude_tests: exclude_tests)
195
188
  else
196
189
  search_with_ruby(search_pattern, search_path, file_type, fetch_limit, root, context_lines, exclude_tests: exclude_tests)
197
190
  end
198
- all_results, truncated = cap_results(fetched)
191
+ all_results, truncated = cap_results(fetched, line_cap)
199
192
 
200
193
  # A definition or comment line stays as context, never as a call site.
201
194
  if match_type == "call"
@@ -205,7 +198,9 @@ module RailsAiContext
205
198
  all_results = confirmed_rows(all_results, root, build_regexp(search_pattern, timeout: 1), context_lines)
206
199
 
207
200
  if all_results.empty?
208
- return empty_response("No results found for '#{original_pattern}' in #{path || 'app'}.")
201
+ # The lines past a cut were never read, so they may still match.
202
+ unread = truncated ? " in the first #{count_phrase(line_cap, 'line')} scanned (max_search_results); more lines remain" : ""
203
+ return empty_response("No results found for '#{echo_input(original_pattern)}' in #{path ? echo_input(path) : 'app'}#{unread}.")
209
204
  end
210
205
 
211
206
  # A non-empty row list whose flags all went missing still found
@@ -253,7 +248,7 @@ module RailsAiContext
253
248
  mixed = paginated.any? { |r| !match_row?(r) }
254
249
  with_context = mixed ? " (#{count_phrase(paginated.size, 'line')} with context)" : ""
255
250
  header = "# Search: `#{original_pattern}`\n" \
256
- "**#{capped_match_phrase(match_total, truncated)}#{scanned_note(truncated)}**#{" in #{path}" if path}, " \
251
+ "**#{capped_match_phrase(match_total, truncated)}#{scanned_note(truncated, line_cap)}**#{" in #{path}" if path}, " \
257
252
  "showing #{shown}#{with_context}\n"
258
253
  header += "`>` = match line\n" if mixed
259
254
  header += "_The limit held only context lines, so this page starts at the next match._\n" if moved_to_match
@@ -285,6 +280,13 @@ module RailsAiContext
285
280
  Regexp.escape(text).gsub("\\ ", " ")
286
281
  end
287
282
 
283
+ # A definition or class search reads a plain name literally, so `valid?`
284
+ # is not `vali` and an optional `d`, and any other pattern as the regex
285
+ # the schema promises: `\w+Job`, `generate_.*`.
286
+ private_class_method def self.name_source(text)
287
+ text.match?(PLAIN_NAME) ? literal(text) : "(?:#{text})"
288
+ end
289
+
288
290
  # "> " for match lines, " " for context lines; empty when the result
289
291
  # set has no context lines to distinguish from.
290
292
  private_class_method def self.line_marker(result, mixed)
@@ -325,11 +327,16 @@ module RailsAiContext
325
327
 
326
328
  private_class_method def self.ripgrep_available?
327
329
  return @rg_available unless @rg_available.nil?
328
- @rg_available = system("which rg > /dev/null 2>&1")
330
+ @rg_available = Executable.on_path?("rg")
329
331
  end
330
332
 
331
333
  private_class_method def self.search_with_ripgrep(pattern, search_path, file_type, max_results, root, ctx_lines = 0, exclude_tests: false)
332
- cmd = [ "rg", "--no-heading", "--with-filename", "--line-number", "--sort=path", "--max-count", max_results.to_s ]
334
+ # On Windows ripgrep joins the paths it finds with backslashes, which
335
+ # neither strip the root nor meet the sensitive patterns' slashes.
336
+ # --crlf ends a line before its CRLF, which is every line of a Windows
337
+ # checkout: without it `end$` matched none of them and `\s$` all.
338
+ cmd = [ "rg", "--no-heading", "--with-filename", "--line-number", "--sort=path", "--path-separator", "/",
339
+ "--crlf", "--max-count", max_results.to_s ]
333
340
  if ctx_lines > 0
334
341
  cmd.push("-C", ctx_lines.to_s)
335
342
  cmd.push("--field-context-separator", CONTEXT_FIELD_SEPARATOR)
@@ -360,8 +367,18 @@ module RailsAiContext
360
367
  cmd << "--" # Prevent pattern from being parsed as flags
361
368
  cmd << pattern
362
369
  cmd << search_path
363
-
364
- output, err, status = Open3.capture3(*cmd)
370
+ # ripgrep enters no linked directory by itself but follows one it is handed.
371
+ trees = linked_trees(search_path, root, skip_rule(exclude_tests))
372
+ cmd.concat(trees.map(&:first).reject { |dir| dir == search_path })
373
+
374
+ output, err, status = begin
375
+ Open3.capture3(*cmd)
376
+ rescue SystemCallError
377
+ # Windows refuses a command line past 32K characters, and the error
378
+ # repeated the whole line, pattern and all. The Ruby search takes a
379
+ # pattern of any length.
380
+ return search_with_ruby(pattern, search_path, file_type, max_results, root, ctx_lines, exclude_tests: exclude_tests)
381
+ end
365
382
 
366
383
  # rg exits 1 for "no matches" and 2 for any error, including one it
367
384
  # recovered from - an unreadable file in the tree - and it still
@@ -375,7 +392,7 @@ module RailsAiContext
375
392
 
376
393
  # SafePath, not an rg glob, drops sensitive files: a glob cannot exempt a placeholder.
377
394
  rows = parse_rg_output(output, root)
378
- .reject { |r| sensitive_file?(r[:file]) }
395
+ .reject { |r| sensitive_file?(r[:file]) || sensitive_in_tree?(r[:file], trees, root) }
379
396
  .first(max_results)
380
397
  [ rows, failed ]
381
398
  rescue => e
@@ -405,8 +422,7 @@ module RailsAiContext
405
422
  end
406
423
  # ripgrep searches a file it is named whatever its type or ignore files say.
407
424
  if File.file?(search_path)
408
- relative = File.realpath(search_path).delete_prefix("#{File.realpath(root)}/")
409
- scan_file(File.realpath(search_path), relative, regex, ctx_lines, max_results, results)
425
+ scan_file(File.realpath(search_path), spelled_relative(search_path, root), regex, ctx_lines, max_results, results)
410
426
  return [ results, search_error ]
411
427
  end
412
428
  return [ results, search_error ] unless File.directory?(search_path)
@@ -414,27 +430,148 @@ module RailsAiContext
414
430
  # Every file ripgrep searches, unless the app narrowed the fallback
415
431
  # with search_extensions.
416
432
  extensions = file_type ? [ file_type ] : RailsAiContext.configuration.search_extensions&.map(&:to_s)
417
- # Read as ripgrep reads its `--glob=!` globs: `docs` at any depth, and
418
- # `log` never reaching `logo/`.
419
- skipped = RailsAiContext.configuration.excluded_paths + (exclude_tests ? TEST_DIRS : [])
420
- skip_rules = RailsAiContext::GitIgnore.parse(skipped.join("\n"))
421
- skip = ->(relative, dir) { RailsAiContext::GitIgnore.verdict(skip_rules, relative, dir: dir) == :ignore }
433
+ skip = skip_rule(exclude_tests)
422
434
  ai_context = ai_context_paths
423
435
 
424
436
  # ripgrep's walk and ignore files, so both backends search the same
425
- # files, gitignored secrets excluded.
426
- RailsAiContext::GitIgnore.for_tree(root).each_file(File.realpath(search_path), skip: skip) do |file, relative|
427
- next if extensions && extensions.none? { |ext| file.end_with?(".#{ext}") }
428
- next if sensitive_file?(relative) || ai_context_file?(ai_context, relative)
429
- return [ results, search_error ] if scan_file(file, relative, regex, ctx_lines, max_results, results)
430
- rescue => _e
431
- search_error = true
432
- next # Skip a file this process cannot read or scan
437
+ # files, gitignored secrets excluded: the tree under the root, then each
438
+ # directory linked in from the app's repository, under the name the app
439
+ # gives it and read against the ignore files where it lives.
440
+ linked = linked_trees(search_path, root, skip)
441
+ real_search = File.realpath(search_path)
442
+ own = RailsAiContext::SafePath.contained?(real_search, File.realpath(root)) ? [ [ nil, real_search, nil ] ] : []
443
+ (own + linked).each do |spelled, start, base|
444
+ prefix = spelled && spelled_relative(spelled, root)
445
+ in_tree = prefix ? ->(relative, dir) { skip.call("#{prefix}/#{relative}", dir) } : skip
446
+ RailsAiContext::GitIgnore.for_tree(prefix ? start : root).each_file(start, skip: in_tree) do |file, walked|
447
+ relative = prefix ? "#{prefix}/#{walked}" : walked
448
+ next if extensions && extensions.none? { |ext| file.end_with?(".#{ext}") }
449
+ next if sensitive_file?(relative) || ai_context_file?(ai_context, relative)
450
+ next if base && sensitive_file?(base.empty? ? walked : "#{base}/#{walked}")
451
+ return [ results, search_error ] if scan_file(file, relative, regex, ctx_lines, max_results, results)
452
+ rescue => _e
453
+ search_error = true
454
+ next # Skip a file this process cannot read or scan
455
+ end
433
456
  end
434
457
 
435
458
  [ results, search_error ]
436
459
  end
437
460
 
461
+ # Where a search runs: the root, or `path` under it when that resolves
462
+ # inside the root or through a directory linked in from the app's
463
+ # repository (SafePath.linked_in). [search_path, nil], or [nil, the
464
+ # answer that refuses the path or does not find it].
465
+ private_class_method def self.resolve_search_path(path, root)
466
+ return [ root, nil ] unless path
467
+
468
+ # Before the join, which would turn an absolute path into a subpath of
469
+ # the root and leave it looking merely absent.
470
+ return [ nil, error_response("Path not allowed: #{echo_input(path)}") ] if RailsAiContext::SafePath.traversal?(path)
471
+ return [ nil, error_response("Path not allowed: #{echo_input(path)} (sensitive file)") ] if sensitive_file?(path)
472
+
473
+ search_path = File.join(root, path)
474
+ # A symlink under the root can still resolve outside it; that check is
475
+ # on the realpath below.
476
+ unless File.exist?(search_path)
477
+ top_dirs = Dir.glob(File.join(root, "*")).select { |f| File.directory?(f) }.map { |f| File.basename(f) }.sort
478
+ return [ nil, text_response("Path not found: #{echo_input(path)}. Top-level directories: #{top_dirs.first(15).join(', ')}") ]
479
+ end
480
+
481
+ real_root = File.realpath(root)
482
+ real_search = File.realpath(search_path)
483
+ inside = RailsAiContext::SafePath.contained?(real_search, real_root)
484
+ linked = !inside && RailsAiContext::SafePath.linked_in(search_path, real_search, root, root)
485
+ return [ nil, error_response("Path not allowed: #{echo_input(path)}") ] unless inside || linked
486
+ # A file named as the path is read whatever it is linked to, so the
487
+ # name it has where it lives is asked too.
488
+ real_name = real_search.delete_prefix("#{linked || real_root}/")
489
+ return [ nil, error_response("Path not allowed: #{echo_input(path)} (sensitive file)") ] if File.file?(real_search) && sensitive_file?(real_name)
490
+
491
+ [ search_path, nil ]
492
+ rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP, Errno::ENAMETOOLONG
493
+ [ nil, text_response("Path not found: #{echo_input(path)}") ]
494
+ end
495
+
496
+ # Read as ripgrep reads its `--glob=!` globs: `docs` at any depth, and
497
+ # `log` never reaching `logo/`.
498
+ private_class_method def self.skip_rule(exclude_tests)
499
+ skipped = RailsAiContext.configuration.excluded_paths + (exclude_tests ? TEST_DIRS : [])
500
+ skip_rules = RailsAiContext::GitIgnore.parse(skipped.join("\n"))
501
+ ->(relative, dir) { RailsAiContext::GitIgnore.verdict(skip_rules, relative, dir: dir) == :ignore }
502
+ end
503
+
504
+ # The trees a search reads past the root's own, each as [path as spelled,
505
+ # real path, where that sits in its linked-in directory]: `search_path`
506
+ # when a directory linked in from the app's repository leads to it
507
+ # (SafePath.linked_in), then each such directory below it (linked_dirs).
508
+ private_class_method def self.linked_trees(search_path, root, skip)
509
+ real_search = File.realpath(search_path)
510
+ trees = []
511
+ unless RailsAiContext::SafePath.contained?(real_search, File.realpath(root))
512
+ linked = RailsAiContext::SafePath.linked_in(search_path, real_search, root, root)
513
+ trees << [ search_path, real_search, real_search == linked ? "" : real_search.delete_prefix("#{linked}/") ] if linked
514
+ end
515
+ trees + linked_dirs(search_path, root, skip).map { |name, target| [ name, target, "" ] }
516
+ rescue SystemCallError
517
+ []
518
+ end
519
+
520
+ # The directories linked in from the app's repository below `search_path`
521
+ # (SafePath.link_bound) that ripgrep's walk meets past the same ignore
522
+ # files and enters none of, a link inside one too, as [path as spelled,
523
+ # real path]. Each place is searched once: a link into the root or into
524
+ # another tree searched leads where the search goes already, and one up
525
+ # over the root or the search path would search them again.
526
+ private_class_method def self.linked_dirs(search_path, root, skip)
527
+ bound = RailsAiContext::SafePath.link_bound(root)
528
+ real_root = File.realpath(root)
529
+ # Outside a monorepo, nothing outside the root counts.
530
+ return [] if bound == real_root || !File.directory?(search_path)
531
+
532
+ real_search = File.realpath(search_path)
533
+ visited = Set.new([ real_root, real_search ])
534
+ pending = [ [ search_path, real_search ] ]
535
+ found = []
536
+ until pending.empty?
537
+ spelled, real = pending.shift
538
+ inside = RailsAiContext::SafePath.contained?(real, real_root)
539
+ prefix = spelled_relative(spelled, root)
540
+ rule = inside ? skip : ->(relative, dir) { skip.call("#{prefix}/#{relative}", dir) }
541
+ RailsAiContext::GitIgnore.for_tree(inside ? real_root : real).dir_links(real, skip: rule).each do |link|
542
+ target = RailsAiContext::SafePath.linked_dir(link, bound)
543
+ next unless target && visited.add?(target)
544
+ next if [ real_root, real_search ].any? { |dir| RailsAiContext::SafePath.contained?(target, dir) || RailsAiContext::SafePath.contained?(dir, target) }
545
+
546
+ name = File.join(spelled, link.delete_prefix("#{real}/"))
547
+ found << [ name, target ]
548
+ pending << [ name, target ]
549
+ end
550
+ end
551
+ targets = found.map(&:last)
552
+ found.reject { |_, target| targets.any? { |other| other != target && RailsAiContext::SafePath.contained?(target, other) } }
553
+ rescue SystemCallError
554
+ []
555
+ end
556
+
557
+ # A file in a linked-in tree is asked by the name it has there too, as
558
+ # SafePath.locate asks it: a pack's config/master.key is not the app's
559
+ # name for it, but it is the key.
560
+ private_class_method def self.sensitive_in_tree?(relative, trees, root)
561
+ trees.any? do |spelled, _real, base|
562
+ prefix = spelled_relative(spelled, root)
563
+ next false unless relative == prefix || relative.start_with?("#{prefix}/")
564
+
565
+ name = [ base, relative.delete_prefix(prefix).delete_prefix("/") ].reject(&:empty?).join("/")
566
+ sensitive_file?(name)
567
+ end
568
+ end
569
+
570
+ # A path under the root as the app spells it, whatever links lead there.
571
+ private_class_method def self.spelled_relative(path, root)
572
+ RailsAiContext::SafePath.spelled_relative(path, root)
573
+ end
574
+
438
575
  # One file's rows, as ripgrep's -C and --max-count give them: at most
439
576
  # max_results matches, each with its context, overlapping context once.
440
577
  # A binary file (ripgrep's test, on the first block) gives none. True
@@ -443,7 +580,8 @@ module RailsAiContext
443
580
  return false if File.open(file, "rb") { |io| io.read(BINARY_PROBE_BYTES) }.to_s.include?("\0")
444
581
 
445
582
  lines = (RailsAiContext::SafeFile.read(file) || "").lines
446
- hits = lines.each_index.select { |i| lines[i].delete_suffix("\n").match?(regex) }.first(max_results)
583
+ # A line without its terminator, LF or CRLF, as ripgrep --crlf reads it.
584
+ hits = lines.each_index.select { |i| lines[i].delete_suffix("\n").delete_suffix("\r").match?(regex) }.first(max_results)
447
585
  hit = hits.to_h { |i| [ i, true ] }
448
586
  shown = hits.flat_map { |i| ([ i - ctx_lines, 0 ].max..[ i + ctx_lines, lines.size - 1 ].min).to_a }.uniq.sort
449
587
  shown.each do |i|
@@ -456,15 +594,15 @@ module RailsAiContext
456
594
 
457
595
  # Rows are matches plus context lines; only the flagged ones are matches.
458
596
  # Rows are fetched one past the cap so a cut list is knowable.
459
- private_class_method def self.cap_results(rows)
460
- truncated = rows.size > max_results_cap
461
- [ truncated ? rows.first(max_results_cap) : rows, truncated ]
597
+ private_class_method def self.cap_results(rows, cap = max_results_cap)
598
+ truncated = rows.size > cap
599
+ [ truncated ? rows.first(cap) : rows, truncated ]
462
600
  end
463
601
 
464
602
  # The cap is on emitted lines, so a cut list means the count beside it
465
603
  # covers only the lines the search got to read.
466
- private_class_method def self.scanned_note(truncated)
467
- truncated ? " - first #{count_phrase(max_results_cap, 'line')} scanned" : ""
604
+ private_class_method def self.scanned_note(truncated, cap = max_results_cap)
605
+ truncated ? " - first #{count_phrase(cap, 'line')} scanned" : ""
468
606
  end
469
607
 
470
608
  private_class_method def self.capped_match_phrase(total, truncated)
@@ -586,17 +724,19 @@ module RailsAiContext
586
724
  # ── Trace Mode ─────────────────────────────────────────────────
587
725
  # Shows definition + source + callers + internal calls in one response
588
726
 
589
- private_class_method def self.trace_method(method_name, root, path, exclude_tests)
727
+ private_class_method def self.trace_method(method_name, root, search_path, exclude_tests)
590
728
  # Clean input: strip "def ", "self.", parens
591
729
  cleaned = method_name.sub(/\A\s*def\s+/, "").sub(/\Aself\./, "").sub(/\(.*/, "").strip
592
730
  return text_response("Provide a method name to trace.") if cleaned.empty?
593
731
 
594
- search_path = path ? File.join(root, path) : root
595
732
  lines = [ "# Trace: `#{cleaned}`", "" ]
596
733
 
597
- # 1. Find the definition
734
+ # 1. Find the definition: a `def`, else the `scope :name` that
735
+ # defines the class method a model's scope is.
598
736
  def_pattern = "#{DEF_HEAD}#{literal(cleaned)}#{RailsAiContext::MethodName.definition_end(cleaned)}"
599
737
  def_results, = quick_search(def_pattern, search_path, root, 10, exclude_tests)
738
+ scope = def_results.empty?
739
+ def_results, = quick_search(scope_pattern(cleaned), search_path, root, 10, exclude_tests) if scope
600
740
 
601
741
  if def_results.any?
602
742
  lines << "## Definition"
@@ -605,8 +745,12 @@ module RailsAiContext
605
745
  class_context = extract_class_context(File.join(root, r[:file]), r[:line_number])
606
746
  lines << "**#{r[:file]}:#{r[:line_number]}**#{class_context ? " in `#{class_context}`" : ""}"
607
747
 
608
- # Full method body
609
- body = extract_method_body(File.join(root, r[:file]), r[:line_number])
748
+ # Full method body, or the whole scope declaration
749
+ body = if scope
750
+ extract_scope_source(File.join(root, r[:file]), r[:line_number], cleaned)
751
+ else
752
+ extract_method_body(File.join(root, r[:file]), r[:line_number])
753
+ end
610
754
  if body
611
755
  lines << "```ruby"
612
756
  lines << RailsAiContext::Redaction.redact_source_lines(body.lines.map(&:chomp), path: r[:file]).join("\n")
@@ -621,6 +765,8 @@ module RailsAiContext
621
765
  internal_calls += Introspectors::SourceCalls.calls(def_source)
622
766
  internal_calls.uniq!
623
767
  internal_calls.reject! { |c| c == cleaned }
768
+ # The scope macro and the lambda holding its body are no calls the scope makes.
769
+ internal_calls -= SCOPE_WRAPPERS if scope
624
770
 
625
771
  if internal_calls.any?
626
772
  lines << "" << "## Calls internally"
@@ -638,7 +784,7 @@ module RailsAiContext
638
784
  lines << ""
639
785
  end
640
786
  else
641
- lines << "_No definition found for `def #{cleaned}`_"
787
+ lines << "_No definition found for `def #{cleaned}` or `scope :#{cleaned}`_"
642
788
  lines << ""
643
789
  end
644
790
 
@@ -720,6 +866,36 @@ module RailsAiContext
720
866
  text_response("Trace error: #{e.message}")
721
867
  end
722
868
 
869
+ SCOPE_WRAPPERS = %w[scope lambda proc].freeze
870
+
871
+ # `scope :published, -> { ... }` or `scope("published", ...)`: the
872
+ # line that defines a model's scope method of that name.
873
+ private_class_method def self.scope_pattern(name)
874
+ "^\\s*scope\\s*\\(?\\s*(?::#{literal(name)}\\b|[\"']#{literal(name)}[\"'])"
875
+ end
876
+
877
+ # The whole `scope` call that starts on the line, however many lines
878
+ # its lambda runs to; the line alone when it cannot be parsed.
879
+ private_class_method def self.extract_scope_source(file_path, line, name)
880
+ source = RailsAiContext::SafeFile.read(file_path) || ""
881
+ found = nil
882
+ visit = lambda do |node|
883
+ return if found || !node.is_a?(Prism::Node)
884
+
885
+ first = node.arguments&.arguments&.first if node.is_a?(Prism::CallNode)
886
+ if node.is_a?(Prism::CallNode) && node.name == :scope && node.location.start_line == line &&
887
+ (first.is_a?(Prism::SymbolNode) || first.is_a?(Prism::StringNode)) && first.unescaped == name
888
+ found = node.slice
889
+ else
890
+ node.compact_child_nodes.each(&visit)
891
+ end
892
+ end
893
+ visit.call(RailsAiContext::AstCache.parse_string(source).value)
894
+ found || source.lines[line - 1]&.rstrip
895
+ rescue => e
896
+ RailsAiContext.debug_fail(e, nil, label: "extract_scope_source")
897
+ end
898
+
723
899
  # Fast ripgrep search for trace mode (no formatting, just results)
724
900
  private_class_method def self.quick_search(pattern, search_path, root, limit, exclude_tests)
725
901
  if ripgrep_available?
@@ -58,7 +58,7 @@ module RailsAiContext
58
58
  # Validate source
59
59
  source = source.to_s.downcase
60
60
  unless VALID_SOURCES.include?(source)
61
- return text_response("Invalid source: '#{source}'. Valid values: #{VALID_SOURCES.join(', ')}")
61
+ return text_response("Invalid source: '#{echo_input(source)}'. Valid values: #{VALID_SOURCES.join(', ')}")
62
62
  end
63
63
 
64
64
  # Normalize limit
@@ -94,11 +94,11 @@ module RailsAiContext
94
94
  { topic: topic, score: score }
95
95
  end
96
96
 
97
- scored.sort_by! { |s| -s[:score] }
97
+ scored = scored.sort_by.with_index { |s, i| [ -s[:score], i ] }
98
98
  results = scored.first(limit)
99
99
 
100
100
  if results.empty?
101
- return text_response("No documentation found for '#{query}'. Try broader terms like 'active record', 'routing', or 'testing'.")
101
+ return text_response("No documentation found for '#{echo_input(query)}'. Try broader terms like 'active record', 'routing', or 'testing'.")
102
102
  end
103
103
 
104
104
  format_results(results, query, source, branch, fetch: fetch)
@@ -178,7 +178,7 @@ module RailsAiContext
178
178
 
179
179
  def format_results(results, query, source, branch, fetch: false)
180
180
  lines = []
181
- lines << "# Rails Documentation Search: \"#{query}\"#{" (fetched)" if fetch}"
181
+ lines << "# Rails Documentation Search: \"#{echo_input(query)}\"#{" (fetched)" if fetch}"
182
182
  lines << "Found #{count_phrase(results.size, "result")} (#{source})"
183
183
  lines << ""
184
184