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.
- checksums.yaml +7 -0
- data/README.md +367 -0
- data/exe/scryer +7 -0
- data/lib/generators/scryer/USAGE +66 -0
- data/lib/generators/scryer/install_generator.rb +29 -0
- data/lib/generators/scryer/templates/scryer_initializer.rb +28 -0
- data/lib/scryer/ai_client.rb +53 -0
- data/lib/scryer/ai_fix_suggester.rb +138 -0
- data/lib/scryer/ast.rb +277 -0
- data/lib/scryer/cache_extractor.rb +124 -0
- data/lib/scryer/cli.rb +193 -0
- data/lib/scryer/dependency_audit.rb +225 -0
- data/lib/scryer/duplicate_detector.rb +103 -0
- data/lib/scryer/finding.rb +21 -0
- data/lib/scryer/method_extractor.rb +55 -0
- data/lib/scryer/performance_rules/inefficient_save_loop_rule.rb +108 -0
- data/lib/scryer/performance_rules/missing_pagination_rule.rb +132 -0
- data/lib/scryer/performance_rules/n_plus_one_query_rule.rb +221 -0
- data/lib/scryer/performance_rules/unbounded_table_scan_rule.rb +78 -0
- data/lib/scryer/query_extractor.rb +123 -0
- data/lib/scryer/query_watcher.rb +250 -0
- data/lib/scryer/railtie.rb +12 -0
- data/lib/scryer/report_renderer.rb +546 -0
- data/lib/scryer/rule.rb +43 -0
- data/lib/scryer/rule_set.rb +19 -0
- data/lib/scryer/rules/command_injection_rule.rb +61 -0
- data/lib/scryer/rules/csrf_protection_rule.rb +89 -0
- data/lib/scryer/rules/hardcoded_secret_rule.rb +96 -0
- data/lib/scryer/rules/mass_assignment_rule.rb +103 -0
- data/lib/scryer/rules/open_redirect_rule.rb +57 -0
- data/lib/scryer/rules/sql_injection_rule.rb +63 -0
- data/lib/scryer/rules/unsafe_deserialization_rule.rb +71 -0
- data/lib/scryer/rules/weak_crypto_rule.rb +66 -0
- data/lib/scryer/rules/xss_unsafe_html_rule.rb +70 -0
- data/lib/scryer/scanner.rb +129 -0
- data/lib/scryer/version.rb +3 -0
- data/lib/scryer.rb +65 -0
- data/lib/tasks/scryer.rake +172 -0
- 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
|