scryer 0.1.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 (39) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +367 -0
  3. data/exe/scryer +7 -0
  4. data/lib/generators/scryer/USAGE +66 -0
  5. data/lib/generators/scryer/install_generator.rb +29 -0
  6. data/lib/generators/scryer/templates/scryer_initializer.rb +28 -0
  7. data/lib/scryer/ai_client.rb +53 -0
  8. data/lib/scryer/ai_fix_suggester.rb +138 -0
  9. data/lib/scryer/ast.rb +277 -0
  10. data/lib/scryer/cache_extractor.rb +124 -0
  11. data/lib/scryer/cli.rb +193 -0
  12. data/lib/scryer/dependency_audit.rb +225 -0
  13. data/lib/scryer/duplicate_detector.rb +103 -0
  14. data/lib/scryer/finding.rb +21 -0
  15. data/lib/scryer/method_extractor.rb +55 -0
  16. data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +108 -0
  17. data/lib/scryer/performance_rules/missing_pagination_rule.rb +132 -0
  18. data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +221 -0
  19. data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +78 -0
  20. data/lib/scryer/query_extractor.rb +123 -0
  21. data/lib/scryer/query_watcher.rb +250 -0
  22. data/lib/scryer/railtie.rb +12 -0
  23. data/lib/scryer/report_renderer.rb +546 -0
  24. data/lib/scryer/rule.rb +43 -0
  25. data/lib/scryer/rule_set.rb +19 -0
  26. data/lib/scryer/rules/command_injection_rule.rb +61 -0
  27. data/lib/scryer/rules/csrf_protection_rule.rb +89 -0
  28. data/lib/scryer/rules/hardcoded_secret_rule.rb +96 -0
  29. data/lib/scryer/rules/mass_assignment_rule.rb +103 -0
  30. data/lib/scryer/rules/open_redirect_rule.rb +57 -0
  31. data/lib/scryer/rules/sql_injection_rule.rb +63 -0
  32. data/lib/scryer/rules/unsafe_deserialization_rule.rb +71 -0
  33. data/lib/scryer/rules/weak_crypto_rule.rb +66 -0
  34. data/lib/scryer/rules/xss_unsafe_html_rule.rb +70 -0
  35. data/lib/scryer/scanner.rb +129 -0
  36. data/lib/scryer/version.rb +3 -0
  37. data/lib/scryer.rb +65 -0
  38. data/lib/tasks/scryer.rake +172 -0
  39. metadata +106 -0
@@ -0,0 +1,138 @@
1
+ module Scryer
2
+ # Optional, opt-in enrichment of a finding's `suggested_fix` using an
3
+ # external LLM. Every rule already ships a generic, human-reviewable
4
+ # suggested fix (see Rule#finding) — this replaces that generic text with
5
+ # one written against the finding's actual code snippet, when the host
6
+ # app has configured an LLM client.
7
+ #
8
+ # Scryer stays entirely provider-agnostic here: `client` is any object
9
+ # (or bare Proc/lambda) responding to #call(prompt) — or #complete(prompt)
10
+ # — that returns the model's reply as a String. Claude, OpenAI, a local
11
+ # Ollama server, a Bedrock/Vertex-backed client, a fake in a test: all of
12
+ # them work identically, and nothing in this class knows or cares which
13
+ # one is in use. Scryer::AiClient (ai_client.rb) is a small ready-made
14
+ # adapter for wiring up any JSON/HTTP chat endpoint; using it is optional.
15
+ #
16
+ # Off by default: with no client configured (the default), .enhance!/
17
+ # .enhance_result! are no-ops, and nothing here ever runs — this feature
18
+ # makes no network calls unless Scryer.configuration.ai_client is set.
19
+ # Nothing is ever auto-applied to source files; this only changes the
20
+ # *text* of a finding's suggested_fix, same as every other rule's fix
21
+ # text — still just something for a human to read and act on.
22
+ class AiFixSuggester
23
+ class << self
24
+ # Enhances a single Finding in place and returns it. Any failure
25
+ # (client raises, times out, returns nothing usable) is swallowed and
26
+ # the finding's original suggested_fix is left as-is — an LLM call
27
+ # failing should never break a scan.
28
+ def enhance!(finding, client: Scryer.configuration.ai_client)
29
+ return finding unless client
30
+
31
+ reply = call_client(client, prompt_for(finding))
32
+ finding.suggested_fix = reply.strip unless blank?(reply)
33
+ finding
34
+ rescue StandardError
35
+ finding
36
+ end
37
+
38
+ # Enhances every security/performance finding on a Scanner::Result in
39
+ # place. Runs across a small thread pool (network-bound work, same
40
+ # pattern as DependencyAudit.vulnerable_gems) so a large finding count
41
+ # doesn't mean one-request-at-a-time. No-op if no client is
42
+ # configured — callers don't need to check first.
43
+ def enhance_result!(result, client: Scryer.configuration.ai_client, concurrency: 4)
44
+ enhance_many!(result.security_findings + result.performance_findings, client: client, concurrency: concurrency)
45
+ result
46
+ end
47
+
48
+ # Same as enhance_result! but for a plain array of findings — used for
49
+ # Scryer::DependencyAudit::Finding objects, which aren't attached to a
50
+ # Scanner::Result. Works on any mix of Finding/DependencyAudit::Finding
51
+ # (prompt_for below dispatches on which one it got). No-op if no client
52
+ # is configured.
53
+ def enhance_many!(findings, client: Scryer.configuration.ai_client, concurrency: 4)
54
+ return findings unless client
55
+
56
+ queue = Queue.new
57
+ findings.each { |f| queue << f }
58
+
59
+ workers = Array.new([concurrency, findings.size].min) do
60
+ Thread.new do
61
+ loop do
62
+ finding = begin
63
+ queue.pop(true)
64
+ rescue ThreadError
65
+ nil
66
+ end
67
+ break unless finding
68
+
69
+ enhance!(finding, client: client)
70
+ end
71
+ end
72
+ end
73
+ workers.each(&:join)
74
+
75
+ findings
76
+ end
77
+
78
+ private
79
+
80
+ def call_client(client, prompt)
81
+ client.respond_to?(:call) ? client.call(prompt) : client.complete(prompt)
82
+ end
83
+
84
+ # Scryer::Finding (rule-based static-analysis findings) and
85
+ # Scryer::DependencyAudit::Finding (bundler-audit-like findings) carry
86
+ # different fields — this dispatches to whichever prompt shape fits the
87
+ # object it was actually given rather than assuming one Finding class.
88
+ def prompt_for(finding)
89
+ finding.is_a?(DependencyAudit::Finding) ? dependency_prompt_for(finding) : static_prompt_for(finding)
90
+ end
91
+
92
+ def static_prompt_for(finding)
93
+ <<~PROMPT
94
+ You are a senior Rails engineer reviewing a static-analysis finding. Suggest a
95
+ concrete, minimal fix for this exact code — not general advice about the issue
96
+ category.
97
+
98
+ Rule: #{finding.rule_id}
99
+ Category: #{finding.category}
100
+ Severity: #{finding.severity}
101
+ Location: #{finding.file}#{finding.line ? ":#{finding.line}" : ""}
102
+ Issue: #{finding.message}
103
+ Offending code:
104
+ #{finding.code_snippet}
105
+
106
+ Generic guidance for this rule: #{finding.suggested_fix}
107
+
108
+ Reply with a short explanation (1-3 sentences) followed by a before/after code
109
+ example using the actual snippet above. Do not restate the issue description.
110
+ PROMPT
111
+ end
112
+
113
+ def dependency_prompt_for(finding)
114
+ <<~PROMPT
115
+ You are a senior Rails engineer reviewing a dependency-audit finding (bundler-audit
116
+ style) for a Gemfile.lock. Suggest a concrete, minimal remediation.
117
+
118
+ Kind: #{finding.kind}
119
+ Gem: #{finding.gem_name}#{finding.installed_version ? " #{finding.installed_version}" : ""}
120
+ Severity: #{finding.severity}
121
+ Advisory: #{finding.advisory_id}#{finding.title ? " - #{finding.title}" : ""}
122
+ Issue: #{finding.message}
123
+ Patched version(s): #{Array(finding.patched_versions).join(", ")}
124
+
125
+ Generic guidance: #{finding.suggested_fix}
126
+
127
+ Reply with a short explanation (1-2 sentences) followed by the exact
128
+ `bundle update <gem> --conservative` (or Gemfile version pin) command to run.
129
+ Do not restate the issue description.
130
+ PROMPT
131
+ end
132
+
133
+ def blank?(text)
134
+ text.nil? || text.to_s.strip.empty?
135
+ end
136
+ end
137
+ end
138
+ end
data/lib/scryer/ast.rb ADDED
@@ -0,0 +1,277 @@
1
+ require "ripper"
2
+
3
+ module Scryer
4
+ # Small set of helpers for walking the S-expression tree that Ripper.sexp
5
+ # produces. We deliberately don't depend on the `parser`/`RuboCop::AST` gems
6
+ # so this gem has zero runtime dependencies beyond Ruby's own stdlib —
7
+ # Ripper has shipped with Ruby since 1.9.
8
+ #
9
+ # A Ripper sexp node is either a plain Ruby object (String/Integer/nil/false)
10
+ # or an Array whose first element is a Symbol tag (:def, :call, :string_literal,
11
+ # etc.) followed by child nodes. Terminal "token" nodes look like
12
+ # [:@ident, "foo", [line, col]] — the trailing [line, col] pair is what lets us
13
+ # report accurate line numbers for findings.
14
+ module Ast
15
+ module_function
16
+
17
+ # Depth-first walk of every node in the tree. Yields each node (both
18
+ # tagged Array nodes and plain values) to the block. This is intentionally
19
+ # simple/generic rather than type-specific, so new rules can filter for
20
+ # whatever node shape they care about.
21
+ def each_node(node, &block)
22
+ return enum_for(:each_node, node) unless block
23
+
24
+ block.call(node)
25
+ return unless node.is_a?(Array)
26
+
27
+ node.each do |child|
28
+ each_node(child, &block) if child.is_a?(Array)
29
+ end
30
+ end
31
+
32
+ # True if `node` is a tagged sexp node (e.g. [:def, ...]) whose tag is one
33
+ # of `tags` (symbols).
34
+ def tagged?(node, *tags)
35
+ node.is_a?(Array) && node[0].is_a?(Symbol) && tags.include?(node[0])
36
+ end
37
+
38
+ # Extract the [line, col] position from a node, searching its descendants
39
+ # for the first terminal token if the node itself isn't one. Returns nil
40
+ # if no position info can be found (shouldn't normally happen).
41
+ def position_of(node)
42
+ return nil unless node.is_a?(Array)
43
+
44
+ # Terminal tokens look like [:@ident, "text", [line, col]]
45
+ if node[0].is_a?(Symbol) && node[0].to_s.start_with?("@") && node[2].is_a?(Array) && node[2].size == 2
46
+ return node[2]
47
+ end
48
+
49
+ node.each do |child|
50
+ next unless child.is_a?(Array)
51
+
52
+ pos = position_of(child)
53
+ return pos if pos
54
+ end
55
+
56
+ nil
57
+ end
58
+
59
+ def line_of(node)
60
+ position_of(node)&.first
61
+ end
62
+
63
+ # Matches a `.method_name(...)` or bare `method_name(...)` call node.
64
+ # Returns the receiver node (nil for a bare/vcall) and the method name
65
+ # string if `node` is a call to one of `method_names`, else nil.
66
+ #
67
+ # Handles the two shapes Ripper produces for a called method:
68
+ # [:call, receiver, [:@period,...]|:"::", [:@ident, "name", pos]] (has a receiver)
69
+ # [:vcall, [:@ident, "name", pos]] (bare, no args)
70
+ # [:command, [:@ident, "name", pos], args] (bare, with args, no parens)
71
+ # [:method_add_arg, call_or_fcall_node, args_node] (receiver/bare + parens)
72
+ def call_name(node)
73
+ case node
74
+ when ->(n) { tagged?(n, :call) }
75
+ [node[1], ident_text(node[3])]
76
+ when ->(n) { tagged?(n, :vcall) }
77
+ [nil, ident_text(node[1])]
78
+ when ->(n) { tagged?(n, :fcall) }
79
+ [nil, ident_text(node[1])]
80
+ when ->(n) { tagged?(n, :command) }
81
+ [nil, ident_text(node[1])]
82
+ end
83
+ end
84
+
85
+ def ident_text(node)
86
+ return nil unless node.is_a?(Array)
87
+
88
+ node[1] if node[0].is_a?(Symbol) && %i[@ident @const @kw @op].include?(node[0])
89
+ end
90
+
91
+ # Given a [:method_add_arg, call_node, args_node] or [:command, ident, args_node]
92
+ # node, return the flattened list of top-level argument sexp nodes (best effort —
93
+ # walks through the [:arg_paren, [:args_add_block, [args...], block]] wrapping).
94
+ def call_arguments(node)
95
+ return [] unless node.is_a?(Array)
96
+
97
+ args_node =
98
+ case node[0]
99
+ when :method_add_arg then node[2]
100
+ when :command then node[2]
101
+ when :command_call then node[4]
102
+ end
103
+
104
+ unwrap_args(args_node)
105
+ end
106
+
107
+ def unwrap_args(node)
108
+ return [] unless node.is_a?(Array)
109
+
110
+ inner = tagged?(node, :arg_paren) ? node[1] : node
111
+ return [] unless tagged?(inner, :args_add_block)
112
+
113
+ list = inner[1]
114
+ list.is_a?(Array) ? list : []
115
+ end
116
+
117
+ # True if a [:string_literal, [:string_content, ...]] node contains any
118
+ # interpolation (:string_embexpr / :string_dvar children).
119
+ def string_literal_has_interpolation?(node)
120
+ return false unless tagged?(node, :string_literal, :xstring_literal, :dyna_symbol)
121
+
122
+ each_node(node).any? { |n| tagged?(n, :string_embexpr, :string_dvar) }
123
+ end
124
+
125
+ # Extracts the literal text of a plain (non-interpolated) string_literal,
126
+ # or nil if it has interpolation or isn't a string literal at all.
127
+ def plain_string_value(node)
128
+ return nil if string_literal_has_interpolation?(node)
129
+ return nil unless tagged?(node, :string_literal)
130
+
131
+ content = node[1]
132
+ return nil unless tagged?(content, :string_content)
133
+
134
+ content[1..].map { |part| part.is_a?(Array) && part[0] == :@tstring_content ? part[1] : nil }.compact.join
135
+ end
136
+
137
+ # Best-effort source-line snippet (1-indexed line number) for display in a finding.
138
+ def source_line(source, line_number)
139
+ return nil unless line_number
140
+
141
+ source.lines[line_number - 1]&.strip
142
+ end
143
+
144
+ # The [start_line, end_line] a node spans, found by scanning its
145
+ # descendants for position-bearing terminal tokens (nodes themselves
146
+ # don't carry an explicit end line — see MethodExtractor's comment).
147
+ # Returns nil if the node has no position-bearing descendants at all.
148
+ def line_range_of(node)
149
+ positions = each_node(node).filter_map { |n| position_of(n) }
150
+ return nil if positions.empty?
151
+
152
+ lines = positions.map(&:first)
153
+ [lines.min, lines.max]
154
+ end
155
+
156
+ # Best-effort source text spanning every line `node` touches — used for
157
+ # duplicate-detection snippets (query chains, cache keys/values) where we
158
+ # want the literal source rather than a reconstructed one. Whole-line
159
+ # granularity: fine for a display snippet, but too coarse when the exact
160
+ # boundary matters (see exact_source_text below).
161
+ def source_text(source, node)
162
+ start_line, end_line = line_range_of(node)
163
+ return nil unless start_line
164
+
165
+ source.lines[(start_line - 1)...end_line]&.join
166
+ end
167
+
168
+ # Column-precise source text for exactly what `node` spans — unlike
169
+ # source_text, doesn't pull in the rest of the line. Needed for e.g. a
170
+ # cache key expression that shares a line with the surrounding
171
+ # assignment/call (`x = Rails.cache.fetch("key_#{id}") { ... } unless x`)
172
+ # — source_text would return that whole statement, not just the key.
173
+ # Built from the node's first/last terminal tokens (assumed to appear in
174
+ # source order, which holds for the expressions this is used on); nil if
175
+ # the node has no terminal tokens or its positions don't fit the source.
176
+ #
177
+ # Ripper.sexp doesn't emit a terminal token for a string interpolation's
178
+ # closing `}` — if the interpolation is the last thing in the string
179
+ # (`"foo_#{id}"`), the raw span above ends right after `id`, one `}`
180
+ # short of the true end. closing_interpolation_braces counts how many
181
+ # string_embexpr/string_dvar wrappers actually contain that last token
182
+ # (usually 0 or 1, more if nested) and appends exactly that many `}` —
183
+ # only when the source really has one there, never guessed blindly.
184
+ def exact_source_text(source, node)
185
+ tokens = each_node(node).select do |n|
186
+ n.is_a?(Array) && n[0].is_a?(Symbol) && n[0].to_s.start_with?("@") &&
187
+ n[2].is_a?(Array) && n[2].size == 2
188
+ end
189
+ return nil if tokens.empty?
190
+
191
+ lines = source.lines
192
+ start_line, start_col = tokens.first[2]
193
+ end_line, end_col = tokens.last[2]
194
+ end_col += tokens.last[1].to_s.length
195
+ return nil if start_line.nil? || end_line.nil? || end_line > lines.size
196
+
197
+ text =
198
+ if start_line == end_line
199
+ lines[start_line - 1][start_col...end_col]
200
+ else
201
+ ([lines[start_line - 1][start_col..]] +
202
+ lines[start_line...(end_line - 1)] +
203
+ [lines[end_line - 1][0...end_col]]).join
204
+ end
205
+ return nil unless text
206
+
207
+ text + closing_interpolation_braces(lines[end_line - 1], end_col, node)
208
+ end
209
+
210
+ # The ancestor chain (node itself first) from `node` down to whichever
211
+ # descendant holds the last terminal token found by depth-first order —
212
+ # i.e. mirrors each_node's traversal, but keeps the path instead of just
213
+ # the leaf, so callers can inspect what wraps that last token.
214
+ def path_to_last_token(node)
215
+ return nil unless node.is_a?(Array)
216
+
217
+ if node[0].is_a?(Symbol) && node[0].to_s.start_with?("@") && node[2].is_a?(Array) && node[2].size == 2
218
+ return [node]
219
+ end
220
+
221
+ last_path = nil
222
+ node.each do |child|
223
+ next unless child.is_a?(Array)
224
+
225
+ sub = path_to_last_token(child)
226
+ last_path = sub if sub
227
+ end
228
+
229
+ last_path && [node] + last_path
230
+ end
231
+
232
+ def closing_interpolation_braces(line, from_col, node)
233
+ return "" unless line
234
+
235
+ path = path_to_last_token(node)
236
+ count = path ? path.count { |n| tagged?(n, :string_embexpr, :string_dvar) } : 0
237
+ return "" if count.zero?
238
+
239
+ cursor = from_col
240
+ result = +""
241
+ count.times do
242
+ break unless line[cursor] == "}"
243
+
244
+ result << "}"
245
+ cursor += 1
246
+ end
247
+ result
248
+ end
249
+
250
+ # Walks every terminal token-bearing node inside `node` and maps it to a
251
+ # normalized symbol: identifiers/literals become placeholders (so renamed
252
+ # variables/changed literal values still count as "the same" shape),
253
+ # keywords/operators/punctuation stay literal (so the actual control-flow
254
+ # shape of the code still has to match for two nodes to look similar).
255
+ # Shared by MethodExtractor, QueryExtractor, and CacheExtractor — anything
256
+ # that needs to compare two code fragments for near-duplication.
257
+ def normalized_tokens(node)
258
+ each_node(node).filter_map do |n|
259
+ next unless n.is_a?(Array) && n[0].is_a?(Symbol) && n[0].to_s.start_with?("@")
260
+
261
+ case n[0]
262
+ when :@ident, :@const, :@ivar, :@gvar, :@cvar, :@label
263
+ :ID
264
+ when :@int, :@float, :@CHAR
265
+ :LIT_NUM
266
+ when :@tstring_content
267
+ :LIT_STR
268
+ when :@kw
269
+ n[1].to_sym # if/else/end/def/do/while/... — structurally meaningful
270
+ when :@op, :@period, :@comma, :@lbracket, :@rbracket, :@lparen, :@rparen,
271
+ :@lbrace, :@rbrace, :@semicolon
272
+ n[1].to_sym
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,124 @@
1
+ require "ripper"
2
+
3
+ module Scryer
4
+ # A `Rails.cache.fetch(key) { value }` or `Rails.cache.write(key, value)`
5
+ # call site: what gets cached (`token_stream`, for comparing against other
6
+ # call sites) and under what key (`cache_key`, kept as display text rather
7
+ # than normalized — two sites caching the same value are only worth
8
+ # flagging when their *keys* actually differ; see Scanner).
9
+ CacheCallInfo = Struct.new(:name, :file, :start_line, :end_line, :token_stream, :source_snippet, :cache_key, keyword_init: true)
10
+
11
+ # Finds `Rails.cache.fetch(key) { value }` and `Rails.cache.write(key, value)`
12
+ # call sites and extracts the value being cached as a CacheCallInfo, so
13
+ # DuplicateDetector can flag the same computed value being cached under
14
+ # different keys — redundant cache entries that should share one key, or a
15
+ # sign the keys are inconsistent copy-paste rather than intentionally
16
+ # distinct. `Rails.cache.fetch(key)` with no block is a plain read (nothing
17
+ # is computed/stored there) and isn't a candidate.
18
+ module CacheExtractor
19
+ module_function
20
+
21
+ # Skip trivially small cached values (`true`, `nil`, a bare literal).
22
+ # Lower than MethodExtractor/QueryExtractor's thresholds: Ripper doesn't
23
+ # emit separate terminal tokens for parens/commas on every call shape
24
+ # (e.g. `foo(a, b)` via :arg_paren can normalize to as few as 3 :ID
25
+ # tokens), so a cached value that's just "call this one method" is
26
+ # already near its natural token-count floor, not unusually small.
27
+ MIN_TOKENS = 3
28
+
29
+ def extract(file:, source:, sexp:)
30
+ infos = []
31
+
32
+ Ast.each_node(sexp) do |node|
33
+ info =
34
+ if Ast.tagged?(node, :method_add_block)
35
+ from_fetch_block(node, file: file, source: source)
36
+ elsif Ast.tagged?(node, :method_add_arg)
37
+ from_write(node, file: file, source: source)
38
+ end
39
+
40
+ infos << info if info
41
+ end
42
+
43
+ infos
44
+ end
45
+
46
+ def from_fetch_block(node, file:, source:)
47
+ call_node = node[1]
48
+ return nil unless cache_method_name(call_node) == "fetch"
49
+
50
+ key_node = Ast.call_arguments(call_node).first
51
+ return nil unless key_node
52
+
53
+ body = block_body(node[2])
54
+ return nil unless body
55
+
56
+ build_info(name: "Rails.cache.fetch", file: file, source: source, key_node: key_node, value_node: body)
57
+ end
58
+
59
+ def from_write(node, file:, source:)
60
+ return nil unless cache_method_name(node) == "write"
61
+
62
+ args = Ast.call_arguments(node)
63
+ return nil if args.size < 2
64
+
65
+ build_info(name: "Rails.cache.write", file: file, source: source, key_node: args[0], value_node: args[1])
66
+ end
67
+
68
+ # Returns "fetch"/"write" if `node` is a `Rails.cache.<method>(...)` call
69
+ # ([:method_add_arg, [:call, [:call, Rails_const, ".", "cache"], ".", method], args]), else nil.
70
+ def cache_method_name(node)
71
+ return nil unless Ast.tagged?(node, :method_add_arg)
72
+
73
+ call_node = node[1]
74
+ return nil unless Ast.tagged?(call_node, :call)
75
+
76
+ method_name = Ast.ident_text(call_node[3])
77
+ return nil unless %w[fetch write].include?(method_name)
78
+
79
+ receiver = call_node[1]
80
+ return nil unless Ast.tagged?(receiver, :call)
81
+ return nil unless Ast.ident_text(receiver[3]) == "cache"
82
+
83
+ const_node = receiver[1]
84
+ return nil unless Ast.tagged?(const_node, :var_ref, :vcall)
85
+
86
+ ident = const_node[1]
87
+ return nil unless ident.is_a?(Array) && ident[0] == :@const && ident[1] == "Rails"
88
+
89
+ method_name
90
+ end
91
+
92
+ def block_body(block_node)
93
+ case block_node&.first
94
+ when :brace_block
95
+ block_node[2]
96
+ when :do_block
97
+ bodystmt = block_node[2]
98
+ Ast.tagged?(bodystmt, :bodystmt) ? bodystmt[1] : bodystmt
99
+ end
100
+ end
101
+
102
+ def build_info(name:, file:, source:, key_node:, value_node:)
103
+ tokens = Ast.normalized_tokens(value_node)
104
+ return nil if tokens.size < MIN_TOKENS
105
+
106
+ start_line, end_line = Ast.line_range_of(value_node)
107
+ return nil unless start_line
108
+
109
+ CacheCallInfo.new(
110
+ name: name,
111
+ file: file,
112
+ start_line: start_line,
113
+ end_line: end_line,
114
+ token_stream: tokens,
115
+ source_snippet: Ast.source_text(source, value_node),
116
+ cache_key: key_display(source, key_node)
117
+ )
118
+ end
119
+
120
+ def key_display(source, key_node)
121
+ Ast.plain_string_value(key_node) || Ast.exact_source_text(source, key_node)
122
+ end
123
+ end
124
+ end