Class: RailsAiContext::Tools::GetEditContext

Inherits:
BaseTool
  • Object
show all
Defined in:
lib/rails_ai_context/tools/get_edit_context.rb

Constant Summary

Constants inherited from BaseTool

BaseTool::SESSION_CONTEXT, BaseTool::SHARED_CACHE

Class Method Summary collapse

Methods inherited from BaseTool

abstract!, abstract?, cache_key, cached_context, config, extract_method_source_from_file, extract_method_source_from_string, find_closest_match, fuzzy_find_key, inherited, not_found_response, paginate, rails_app, registered_tools, reset_all_caches!, reset_cache!, session_queries, session_record, session_reset!, set_call_params, text_response

Class Method Details

.call(file:, near:, context_lines: 5, server_context: nil) ⇒ Object



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/rails_ai_context/tools/get_edit_context.rb', line 31

def self.call(file:, near:, context_lines: 5, server_context: nil)
  # Reject empty parameters
  if file.nil? || file.strip.empty?
    return text_response("The `file` parameter is required. Provide a path relative to Rails root (e.g. 'app/models/cook.rb').")
  end

  if near.nil? || near.strip.empty?
    return text_response("The `near` parameter is required. Provide a method name, keyword, or string to find.")
  end

  full_path = Rails.root.join(file)

  # Block access to sensitive files (secrets, keys, credentials)
  if sensitive_file?(file)
    return text_response("Access denied: #{file} is a sensitive file (secrets/keys/credentials).")
  end

  # Path traversal protection (resolves symlinks)
  unless File.exist?(full_path)
    # Try to find a matching file to suggest
    basename = File.basename(file)
    candidates = Dir.glob(File.join(Rails.root, "app", "**", basename)).first(5)
    hint = if candidates.any?
      suggestions = candidates.map { |c| c.sub("#{Rails.root}/", "") }
      " Did you mean: #{suggestions.join(', ')}? Use the full path relative to Rails root."
    else
      " Use the full path relative to Rails root (e.g., 'app/models/#{basename}')."
    end
    return text_response("File not found: #{file}.#{hint}")
  end
  begin
    real = File.realpath(full_path).to_s
    rails_root_real = File.realpath(Rails.root).to_s
    # Separator-aware containment — matches the v5.8.1-r2 hardening in
    # get_view.rb / vfs.rb. Without `+ File::SEPARATOR`, a sibling-dir
    # like `/app/rails_evil/...` would prefix-match a Rails root at
    # `/app/rails`. Same bug class as the original C1.
    unless real == rails_root_real || real.start_with?(rails_root_real + File::SEPARATOR)
      return text_response("Path not allowed: #{file}")
    end
    # Re-run the sensitive_file? check on the realpath. Defense against
    # symlinks that point at sensitive files from a non-sensitive path
    # (e.g. app/models/notes.rb -> ../../config/master.key). The initial
    # check above runs on the caller-supplied string, not the resolved
    # target. See v5.8.1 security review.
    relative_real = real.sub("#{rails_root_real}/", "")
    if sensitive_file?(relative_real)
      return text_response("Access denied: #{file} resolves to a sensitive file (secrets/keys/credentials).")
    end
  rescue Errno::ENOENT
    return text_response("File not found: #{file}")
  end
  if File.size(real) > max_file_size
    return text_response("File too large: #{file}")
  end

  source_lines = (RailsAiContext::SafeFile.read(real) || "").lines
  context_lines = [ context_lines.to_i, 0 ].max

  # Find all matching lines
  matches = []
  source_lines.each_with_index do |line, idx|
    matches << idx if line.include?(near) || line.match?(/\b#{Regexp.escape(near)}\b/)
  end

  if matches.empty?
    return text_response("'#{near}' not found in #{file} (#{source_lines.size} lines).\n\nAvailable methods:\n#{extract_methods(source_lines)}")
  end

  # Build context window around first match
  match_idx = matches.first
  start_idx = [ match_idx - context_lines, 0 ].max
  end_idx = [ match_idx + context_lines, source_lines.size - 1 ].min

  # If match is inside a method, expand to include the full method
  method_start = find_method_start(source_lines, match_idx)
  method_end = find_method_end(source_lines, method_start) if method_start
  if method_start && method_end && source_lines[match_idx].match?(/\A\s*def\s/)
    # Match IS a method definition — show the complete method, no extra context
    start_idx = method_start
    end_idx = method_end
  elsif method_start && method_end
    # Match is inside a method — expand to include full method + context
    start_idx = [ start_idx, method_start ].min
    end_idx = [ end_idx, method_end ].max
  end

  context_code = source_lines[start_idx..end_idx].map.with_index do |line, i|
    "#{(start_idx + i + 1).to_s.rjust(4)}  #{line.rstrip}"
  end.join("\n")

  lang = case file
  when /\.rb$/ then "ruby"
  when /\.js$/ then "javascript"
  when /\.erb$/ then "erb"
  when /\.yml$/, /\.yaml$/ then "yaml"
  else ""
  end

  # Detect enclosing class and method for context
  class_name = source_lines[0..match_idx].reverse.find { |l| l.match?(/\A\s*(class|module)\s/) }&.strip&.sub(/\s*<.*/, "")
  method_name = method_start ? source_lines[method_start].strip.sub(/\s*\(.*/, "").sub(/\Adef\s+/, "def ") : nil
  context_label = [ class_name, method_name ].compact.join(" > ")

  output = [ "# #{file} (lines #{start_idx + 1}-#{end_idx + 1} of #{source_lines.size})", "" ]
  output << "**Context:** `#{context_label}`" unless context_label.empty?
  output << "```#{lang}"
  output << context_code
  output << "```"
  output << ""
  output << "_Use the code between lines #{start_idx + 1}-#{end_idx + 1} as old_string for Edit._"

  if matches.size > 1
    outside = matches[1..].select { |i| i < start_idx || i > end_idx }
    if outside.any?
      other = outside.first(4).map { |i| "line #{i + 1}" }.join(", ")
      output << "_Also found '#{near}' at: #{other}_"
    end
  end

  text_response(output.join("\n"))
end