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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1491 -0
- data/CONTEXT.md +27 -3
- data/README.md +16 -12
- data/Rakefile +6 -0
- data/app/controllers/rails_ai_context/mcp_controller.rb +112 -84
- data/config/routes.rb +2 -3
- data/docs/ARCHITECTURE.md +7 -5
- data/docs/CLI.md +79 -6
- data/docs/COMPATIBILITY.md +75 -1
- data/docs/CONFIGURATION.md +30 -11
- data/docs/CUSTOM_TOOLS.md +2 -1
- data/docs/FAQ.md +16 -6
- data/docs/GUIDE.md +117 -71
- data/docs/INTROSPECTORS.md +10 -8
- data/docs/QUICKSTART.md +3 -3
- data/docs/RECIPES.md +1 -1
- data/docs/SECURITY.md +109 -25
- data/docs/SETUP.md +30 -6
- data/docs/STANDALONE.md +10 -3
- data/docs/TOOLS.md +152 -34
- data/docs/TROUBLESHOOTING.md +90 -12
- data/docs/adr/0003-shared-tool-cache-semantics.md +8 -0
- data/docs/adr/0005-workspace-mcp-entries.md +39 -0
- data/exe/rails-ai-context +733 -104
- data/lib/generators/rails_ai_context/install/install_generator.rb +341 -125
- data/lib/rails_ai_context/app_kind.rb +18 -5
- data/lib/rails_ai_context/ast_cache.rb +7 -0
- data/lib/rails_ai_context/base_mixins.rb +8 -8
- data/lib/rails_ai_context/boot_manager.rb +22 -3
- data/lib/rails_ai_context/change_watch.rb +14 -11
- data/lib/rails_ai_context/cli/app_root.rb +401 -0
- data/lib/rails_ai_context/cli/entry_boot.rb +58 -22
- data/lib/rails_ai_context/cli/tool_runner.rb +47 -21
- data/lib/rails_ai_context/code_reloader.rb +115 -1
- data/lib/rails_ai_context/concern_membership.rb +3 -1
- data/lib/rails_ai_context/concern_paths.rb +8 -3
- data/lib/rails_ai_context/configuration.rb +41 -9
- data/lib/rails_ai_context/context_file_report.rb +19 -2
- data/lib/rails_ai_context/database_file.rb +26 -0
- data/lib/rails_ai_context/doctor.rb +1122 -184
- data/lib/rails_ai_context/engine.rb +18 -1
- data/lib/rails_ai_context/erb_source.rb +19 -0
- data/lib/rails_ai_context/executable.rb +21 -0
- data/lib/rails_ai_context/facts_formatter.rb +5 -2
- data/lib/rails_ai_context/file_walk.rb +61 -10
- data/lib/rails_ai_context/fingerprinter/snapshot.rb +234 -0
- data/lib/rails_ai_context/fingerprinter.rb +40 -12
- data/lib/rails_ai_context/gem_lock.rb +186 -27
- data/lib/rails_ai_context/git_ignore.rb +22 -0
- data/lib/rails_ai_context/install/ai_tool.rb +31 -5
- data/lib/rails_ai_context/install/cleanup.rb +21 -0
- data/lib/rails_ai_context/install/program.rb +209 -35
- data/lib/rails_ai_context/install/selection_record.rb +258 -97
- data/lib/rails_ai_context/install/surface.rb +6 -1
- data/lib/rails_ai_context/install/validation_hook.rb +282 -0
- data/lib/rails_ai_context/install/workspace.rb +166 -0
- data/lib/rails_ai_context/install_mode.rb +71 -13
- data/lib/rails_ai_context/introspectors/action_mailbox_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/action_presence.rb +11 -6
- data/lib/rails_ai_context/introspectors/action_source.rb +182 -0
- data/lib/rails_ai_context/introspectors/action_text_introspector.rb +1 -2
- data/lib/rails_ai_context/introspectors/active_record_settings.rb +1 -1
- data/lib/rails_ai_context/introspectors/active_storage_introspector.rb +1 -3
- data/lib/rails_ai_context/introspectors/active_support_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/admin_resources.rb +1 -1
- data/lib/rails_ai_context/introspectors/anyway_configs.rb +1 -1
- data/lib/rails_ai_context/introspectors/api_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/auth_introspector.rb +15 -10
- data/lib/rails_ai_context/introspectors/call_site_expansion.rb +24 -1
- data/lib/rails_ai_context/introspectors/component_introspector.rb +3 -2
- data/lib/rails_ai_context/introspectors/controller_introspector.rb +387 -60
- data/lib/rails_ai_context/introspectors/convention_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/database_stats_introspector.rb +15 -3
- data/lib/rails_ai_context/introspectors/devops_introspector.rb +3 -1
- data/lib/rails_ai_context/introspectors/eager_load.rb +201 -9
- data/lib/rails_ai_context/introspectors/engine_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_config_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/env_references.rb +10 -1
- data/lib/rails_ai_context/introspectors/frontend_framework_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/gem_introspector.rb +23 -10
- data/lib/rails_ai_context/introspectors/grape_endpoints.rb +1 -1
- data/lib/rails_ai_context/introspectors/habtm_join_tables.rb +1 -1
- data/lib/rails_ai_context/introspectors/helper_names.rb +1 -1
- data/lib/rails_ai_context/introspectors/i18n_introspector.rb +3 -3
- data/lib/rails_ai_context/introspectors/job_introspector.rb +8 -6
- data/lib/rails_ai_context/introspectors/listeners/base_listener.rb +12 -8
- data/lib/rails_ai_context/introspectors/listeners/callbacks_listener.rb +78 -4
- data/lib/rails_ai_context/introspectors/listeners/config_assignment_listener.rb +1 -0
- data/lib/rails_ai_context/introspectors/listeners/env_access_listener.rb +10 -1
- data/lib/rails_ai_context/introspectors/listeners/foreign_evals_listener.rb +61 -0
- data/lib/rails_ai_context/introspectors/listeners/macros_listener.rb +13 -4
- data/lib/rails_ai_context/introspectors/listeners/migration_dsl_listener.rb +2 -1
- data/lib/rails_ai_context/introspectors/listeners/migration_replay_listener.rb +3 -2
- data/lib/rails_ai_context/introspectors/listeners/routes_dsl_listener.rb +29 -8
- data/lib/rails_ai_context/introspectors/listeners/schema_dsl_listener.rb +2 -2
- data/lib/rails_ai_context/introspectors/listeners/scopes_listener.rb +88 -12
- data/lib/rails_ai_context/introspectors/middleware_introspector.rb +2 -2
- data/lib/rails_ai_context/introspectors/migration_introspector.rb +27 -0
- data/lib/rails_ai_context/introspectors/migration_replay/helpers.rb +1 -1
- data/lib/rails_ai_context/introspectors/migration_replay/statements.rb +83 -27
- data/lib/rails_ai_context/introspectors/migration_replay.rb +15 -14
- data/lib/rails_ai_context/introspectors/model_introspector.rb +231 -23
- data/lib/rails_ai_context/introspectors/n_plus_one_scan.rb +620 -0
- data/lib/rails_ai_context/introspectors/performance_introspector.rb +11 -174
- data/lib/rails_ai_context/introspectors/route_introspector.rb +10 -1
- data/lib/rails_ai_context/introspectors/schema_conventions.rb +23 -3
- data/lib/rails_ai_context/introspectors/schema_introspector.rb +66 -16
- data/lib/rails_ai_context/introspectors/schema_reader.rb +4 -3
- data/lib/rails_ai_context/introspectors/seeds_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/source_scan.rb +39 -9
- data/lib/rails_ai_context/introspectors/stimulus_introspector.rb +15 -6
- data/lib/rails_ai_context/introspectors/test_introspector.rb +1 -1
- data/lib/rails_ai_context/introspectors/turbo_introspector.rb +131 -12
- data/lib/rails_ai_context/introspectors/view_introspector.rb +4 -4
- data/lib/rails_ai_context/introspectors/view_template_introspector.rb +15 -1
- data/lib/rails_ai_context/legacy_cleanup.rb +17 -4
- data/lib/rails_ai_context/live_reload.rb +20 -20
- data/lib/rails_ai_context/mcp_config_generator.rb +916 -109
- data/lib/rails_ai_context/mcp_edge.rb +182 -32
- data/lib/rails_ai_context/middleware.rb +5 -1
- data/lib/rails_ai_context/migration_status.rb +88 -25
- data/lib/rails_ai_context/output_guard.rb +48 -2
- data/lib/rails_ai_context/package_json.rb +1 -1
- data/lib/rails_ai_context/path_resolver.rb +46 -13
- data/lib/rails_ai_context/payload.rb +92 -8
- data/lib/rails_ai_context/pending_migrations.rb +5 -1
- data/lib/rails_ai_context/portable_path.rb +3 -2
- data/lib/rails_ai_context/presets.rb +15 -11
- data/lib/rails_ai_context/redaction.rb +262 -51
- data/lib/rails_ai_context/resources.rb +55 -20
- data/lib/rails_ai_context/route_coverage.rb +8 -0
- data/lib/rails_ai_context/safe_file.rb +14 -1
- data/lib/rails_ai_context/safe_path.rb +101 -4
- data/lib/rails_ai_context/schema_adapter.rb +8 -0
- data/lib/rails_ai_context/schema_version.rb +9 -0
- data/lib/rails_ai_context/serializers/claude_rules_serializer.rb +88 -33
- data/lib/rails_ai_context/serializers/compact_serializer_helper.rb +36 -34
- data/lib/rails_ai_context/serializers/context_file_serializer.rb +21 -29
- data/lib/rails_ai_context/serializers/copilot_instructions_serializer.rb +8 -6
- data/lib/rails_ai_context/serializers/copilot_serializer.rb +2 -7
- data/lib/rails_ai_context/serializers/cursor_rules_serializer.rb +19 -16
- data/lib/rails_ai_context/serializers/guide_examples.rb +142 -0
- data/lib/rails_ai_context/serializers/markdown_serializer.rb +146 -30
- data/lib/rails_ai_context/serializers/opencode_rules_serializer.rb +38 -15
- data/lib/rails_ai_context/serializers/section_facts.rb +44 -1
- data/lib/rails_ai_context/serializers/section_marker_writer.rb +94 -12
- data/lib/rails_ai_context/serializers/stack_overview_helper.rb +24 -12
- data/lib/rails_ai_context/serializers/tool_guide_helper.rb +244 -129
- data/lib/rails_ai_context/server.rb +304 -71
- data/lib/rails_ai_context/tasks/rails_ai_context.rake +447 -372
- data/lib/rails_ai_context/tools/analyze_feature.rb +72 -18
- data/lib/rails_ai_context/tools/argument_schema.rb +49 -0
- data/lib/rails_ai_context/tools/base_tool.rb +367 -46
- data/lib/rails_ai_context/tools/brakeman_guard.rb +24 -0
- data/lib/rails_ai_context/tools/dependency_graph.rb +63 -28
- data/lib/rails_ai_context/tools/diagnose.rb +218 -11
- data/lib/rails_ai_context/tools/generate_test.rb +339 -39
- data/lib/rails_ai_context/tools/get_callbacks.rb +24 -13
- data/lib/rails_ai_context/tools/get_component_catalog.rb +1 -1
- data/lib/rails_ai_context/tools/get_concern.rb +91 -27
- data/lib/rails_ai_context/tools/get_config.rb +13 -4
- data/lib/rails_ai_context/tools/get_context.rb +71 -14
- data/lib/rails_ai_context/tools/get_controllers.rb +179 -44
- data/lib/rails_ai_context/tools/get_conventions.rb +28 -6
- data/lib/rails_ai_context/tools/get_edit_context.rb +27 -14
- data/lib/rails_ai_context/tools/get_env.rb +18 -9
- data/lib/rails_ai_context/tools/get_frontend_stack.rb +3 -1
- data/lib/rails_ai_context/tools/get_gems.rb +1 -1
- data/lib/rails_ai_context/tools/get_helper_methods.rb +10 -6
- data/lib/rails_ai_context/tools/get_i18n.rb +1 -1
- data/lib/rails_ai_context/tools/get_job_pattern.rb +3 -2
- data/lib/rails_ai_context/tools/get_mailers.rb +3 -3
- data/lib/rails_ai_context/tools/get_model_details.rb +30 -10
- data/lib/rails_ai_context/tools/get_partial_interface.rb +242 -23
- data/lib/rails_ai_context/tools/get_routes.rb +29 -8
- data/lib/rails_ai_context/tools/get_schema.rb +52 -19
- data/lib/rails_ai_context/tools/get_service_pattern.rb +24 -10
- data/lib/rails_ai_context/tools/get_stimulus.rb +37 -1
- data/lib/rails_ai_context/tools/get_test_info.rb +18 -4
- data/lib/rails_ai_context/tools/get_turbo_map.rb +221 -58
- data/lib/rails_ai_context/tools/get_view.rb +14 -14
- data/lib/rails_ai_context/tools/migration_advisor.rb +355 -48
- data/lib/rails_ai_context/tools/onboard.rb +52 -8
- data/lib/rails_ai_context/tools/performance_check.rb +5 -3
- data/lib/rails_ai_context/tools/query.rb +914 -86
- data/lib/rails_ai_context/tools/read_logs.rb +69 -15
- data/lib/rails_ai_context/tools/review_changes.rb +215 -53
- data/lib/rails_ai_context/tools/runtime_info.rb +51 -1
- data/lib/rails_ai_context/tools/safe_call.rb +35 -14
- data/lib/rails_ai_context/tools/search_code.rb +243 -67
- data/lib/rails_ai_context/tools/search_docs.rb +4 -4
- data/lib/rails_ai_context/tools/security_scan.rb +143 -33
- data/lib/rails_ai_context/tools/session_context.rb +39 -5
- data/lib/rails_ai_context/tools/validate.rb +259 -40
- data/lib/rails_ai_context/tools/validate_semantics.rb +130 -56
- data/lib/rails_ai_context/version.rb +1 -1
- data/lib/rails_ai_context/vfs.rb +67 -40
- data/lib/rails_ai_context/view_file.rb +41 -3
- data/lib/rails_ai_context/watcher.rb +51 -13
- data/lib/rails_ai_context.rb +21 -0
- data/server.json +1 -1
- 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
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
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 >
|
|
461
|
-
[ truncated ? rows.first(
|
|
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(
|
|
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,
|
|
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 =
|
|
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
|
|
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
|
|