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,108 @@
1
+ module Scryer
2
+ module PerformanceRules
3
+ # Flags `.save`/`.save!`/`.update`/`.update!`/`.update_attribute(s)` called
4
+ # on the block variable inside a `.each`/`.each_with_index` loop — one
5
+ # database write per iteration where a single bulk statement
6
+ # (`update_all`/`insert_all`/`upsert_all`) could often do the same work in
7
+ # one round-trip. Best-effort: this rule can't tell whether the
8
+ # per-record operation is actually uniform enough to batch (e.g. distinct
9
+ # values per row still need `update_all` with a `CASE`, or don't fit the
10
+ # bulk-method shape at all) — it's a nudge to double check, not a
11
+ # guarantee the loop is replaceable as-is.
12
+ class InefficientSaveLoopRule < Rule
13
+ self.rule_id = "inefficient_save_loop"
14
+ self.category = "performance"
15
+ self.default_severity = "warning"
16
+ self.title = "Per-record save/update inside a loop"
17
+
18
+ LOOP_METHODS = %w[each each_with_index].freeze
19
+ # Genuinely argless in normal use — a bare `:call` node, never wrapped
20
+ # by a method_add_arg, so matching this tag alone can't double-count.
21
+ BARE_METHODS = %w[save save!].freeze
22
+ # Always take an argument, so they only ever show up wrapped in
23
+ # method_add_arg/command/command_call — matching only the wrapper
24
+ # avoids double-counting the inner call node these wrap.
25
+ ARG_METHODS = %w[update update! update_attribute update_attributes].freeze
26
+
27
+ def scan
28
+ findings = []
29
+
30
+ Ast.each_node(sexp) do |node|
31
+ next unless Ast.tagged?(node, :method_add_block)
32
+
33
+ call_node = node[1]
34
+ _receiver, loop_method = Ast.call_name(call_node) || [nil, nil]
35
+ next unless LOOP_METHODS.include?(loop_method)
36
+
37
+ block_node = node[2]
38
+ param_name = block_param_name(block_node)
39
+ next unless param_name
40
+
41
+ find_save_calls(block_node[2], param_name).each do |line, method_name|
42
+ findings << finding(
43
+ line: line,
44
+ message: "Inside a `.each` loop, `.#{method_name}` runs once per record — each call " \
45
+ "is a separate database round-trip, which scales linearly with the number " \
46
+ "of records instead of running as one bulk statement.",
47
+ suggested_fix: "If every record gets the same update, replace the loop with " \
48
+ "`Model.where(...).update_all(column: value)` (one UPDATE for the whole " \
49
+ "set). If each record's new values differ but come from data already " \
50
+ "in hand, `upsert_all`/`insert_all` with an array of attribute hashes " \
51
+ "can also replace the per-row round-trips."
52
+ )
53
+ end
54
+ end
55
+
56
+ findings
57
+ end
58
+
59
+ private
60
+
61
+ def block_param_name(block_node)
62
+ return nil unless Ast.tagged?(block_node, :do_block, :brace_block)
63
+
64
+ block_var = block_node[1]
65
+ return nil unless Ast.tagged?(block_var, :block_var)
66
+
67
+ params = block_var[1]
68
+ first_param = params.is_a?(Array) ? params[1]&.first : nil
69
+ return nil unless first_param.is_a?(Array) && first_param[0] == :@ident
70
+
71
+ first_param[1]
72
+ end
73
+
74
+ def block_var_receiver?(node, param_name)
75
+ return false unless Ast.tagged?(node, :var_ref, :vcall)
76
+
77
+ inner = node[1]
78
+ inner.is_a?(Array) && %i[@ident @ivar].include?(inner[0]) && inner[1] == param_name
79
+ end
80
+
81
+ def find_save_calls(body, param_name)
82
+ results = []
83
+
84
+ Ast.each_node(body) do |n|
85
+ if Ast.tagged?(n, :call)
86
+ name = Ast.ident_text(n[3])
87
+ next unless BARE_METHODS.include?(name)
88
+ next unless block_var_receiver?(n[1], param_name)
89
+
90
+ results << [Ast.line_of(n), name]
91
+ elsif Ast.tagged?(n, :method_add_arg, :command, :command_call)
92
+ inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
93
+ receiver_and_name = Ast.call_name(inner)
94
+ next unless receiver_and_name
95
+
96
+ receiver, name = receiver_and_name
97
+ next unless ARG_METHODS.include?(name)
98
+ next unless block_var_receiver?(receiver, param_name)
99
+
100
+ results << [Ast.line_of(n), name]
101
+ end
102
+ end
103
+
104
+ results
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,132 @@
1
+ module Scryer
2
+ module PerformanceRules
3
+ # Flags a controller `index` action that loads `Model.all` or a bare
4
+ # `Model.where(...)` (no `.limit`/`.page`/`.per`/`.paginate`/`.find_each`
5
+ # bound anywhere in the chain) and hands the result straight to an
6
+ # instance variable or `render` — i.e. nothing bounds how many rows get
7
+ # loaded and rendered. Heuristic: only looks at the literal call chain
8
+ # text, not what happens to the variable afterwards (e.g. slicing it in
9
+ # the view would not be detected as "safe" by this rule).
10
+ class MissingPaginationRule < Rule
11
+ self.rule_id = "missing_pagination"
12
+ self.category = "performance"
13
+ self.default_severity = "warning"
14
+ self.title = "Possible unbounded result set on an index action"
15
+
16
+ QUERY_METHODS = %w[all where].freeze
17
+ BOUND_METHODS = %w[limit page per paginate find_each find_in_batches first take].freeze
18
+
19
+ def scan
20
+ findings = []
21
+
22
+ Ast.each_node(sexp) do |node|
23
+ next unless Ast.tagged?(node, :def)
24
+ next unless Ast.ident_text(node[1]) == "index"
25
+
26
+ body = node.last
27
+ findings.concat(check_body(body))
28
+ end
29
+
30
+ findings
31
+ end
32
+
33
+ private
34
+
35
+ def check_body(body)
36
+ results = []
37
+ seen_lines = []
38
+
39
+ sink_values(body).each do |value|
40
+ next unless unbounded_query?(value)
41
+
42
+ line = Ast.line_of(value)
43
+ next if seen_lines.include?(line)
44
+
45
+ seen_lines << line
46
+ results << finding(
47
+ line: line,
48
+ message: "The `index` action loads records with no `.limit`/`.page`/`.per` bound — " \
49
+ "as the table grows this action will load (and likely render) every row, " \
50
+ "getting slower and more memory-hungry over time.",
51
+ suggested_fix: "Bound the result set, e.g. with Kaminari/will_paginate " \
52
+ "(`Model.page(params[:page]).per(25)`) or a plain `.limit(...)`, " \
53
+ "so the action's cost stays roughly constant as the table grows."
54
+ )
55
+ end
56
+
57
+ results
58
+ end
59
+
60
+ # Collects the value expressions that end up as an ivar/local assignment
61
+ # or a `render(...)` argument — the "sinks" an unbounded query could flow
62
+ # into directly.
63
+ def sink_values(body)
64
+ values = []
65
+
66
+ Ast.each_node(body) do |node|
67
+ if Ast.tagged?(node, :assign) && assignable_target?(node[1])
68
+ values << node[2]
69
+ elsif render_call?(node)
70
+ values.concat(flatten_arg_values(Ast.call_arguments(node)))
71
+ end
72
+ end
73
+
74
+ values
75
+ end
76
+
77
+ def assignable_target?(target)
78
+ return false unless Ast.tagged?(target, :var_field)
79
+
80
+ inner = target[1]
81
+ inner.is_a?(Array) && %i[@ident @ivar].include?(inner[0])
82
+ end
83
+
84
+ def render_call?(node)
85
+ inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
86
+ return false unless Ast.tagged?(inner, :command, :fcall, :call, :vcall, :command_call)
87
+
88
+ Ast.call_name(inner)&.last == "render"
89
+ end
90
+
91
+ def flatten_arg_values(nodes)
92
+ nodes.flat_map do |n|
93
+ if Ast.tagged?(n, :bare_assoc_hash)
94
+ n[1].filter_map { |assoc| Ast.tagged?(assoc, :assoc_new) ? assoc[2] : nil }
95
+ else
96
+ [n]
97
+ end
98
+ end
99
+ end
100
+
101
+ def unbounded_query?(value)
102
+ return false unless value.is_a?(Array)
103
+
104
+ root = root_of(value)
105
+ return false unless Ast.tagged?(root, :var_ref, :vcall)
106
+
107
+ const = root[1]
108
+ return false unless const.is_a?(Array) && const[0] == :@const
109
+
110
+ method_names = Ast.each_node(value).filter_map do |n|
111
+ next unless Ast.tagged?(n, :call, :method_add_arg, :command, :vcall, :fcall)
112
+
113
+ inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
114
+ Ast.call_name(inner)&.last
115
+ end
116
+
117
+ (method_names & QUERY_METHODS).any? && (method_names & BOUND_METHODS).none?
118
+ end
119
+
120
+ def root_of(node)
121
+ return node unless node.is_a?(Array)
122
+
123
+ case node[0]
124
+ when :method_add_arg, :call, :command_call
125
+ root_of(node[1])
126
+ else
127
+ node
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,221 @@
1
+ module Scryer
2
+ module PerformanceRules
3
+ # Flags likely N+1 queries: inside a `.each`/`.map` block whose receiver
4
+ # looks like an Active Record collection (a local variable previously
5
+ # assigned from `Model.where(...)`/`Model.all`/`Model.find(...)`, or a bare
6
+ # instance variable — the common `@orders.each do |order| ... end` shape),
7
+ # a bare no-arg `.method_name` call directly on the block variable (likely
8
+ # an association access, e.g. `order.line_items`) is flagged unless an
9
+ # `.includes(:that_name)` appeared earlier in the same base query chain.
10
+ #
11
+ # This is a heuristic, not type inference: it can't tell an association
12
+ # read (`order.line_items`, which issues a query per row) from a plain
13
+ # attribute/column read (`order.status`) or a harmless Ruby method call —
14
+ # both parse identically as a bare no-arg call on the block variable. A
15
+ # small block-list of universally-common non-association methods
16
+ # (to_s, present?, class, ...) cuts down the noisiest false positives, but
17
+ # real false positives on attribute reads are still expected and normal
18
+ # for this class of tool (see README).
19
+ class NPlusOneQueryRule < Rule
20
+ self.rule_id = "n_plus_one_query"
21
+ self.category = "performance"
22
+ self.default_severity = "warning"
23
+ self.title = "Possible N+1 query inside a loop"
24
+
25
+ QUERY_METHODS = %w[where all find find_by find_by! order limit].freeze
26
+ LOOP_METHODS = %w[each map collect each_with_index].freeze
27
+
28
+ # Bare methods that are overwhelmingly plain Ruby/attribute reads rather
29
+ # than association traversal — flagging these would be mostly noise.
30
+ NON_ASSOCIATION_METHODS = %w[
31
+ to_s to_i to_a to_h inspect class dup clone freeze frozen? hash
32
+ present? blank? nil? empty? any? id id_value == equal? try tap then
33
+ is_a? kind_of? instance_of? respond_to? send public_send object_id
34
+ itself
35
+ ].freeze
36
+
37
+ def scan
38
+ findings = []
39
+
40
+ Ast.each_node(sexp) do |node|
41
+ next unless Ast.tagged?(node, :def, :defs)
42
+
43
+ body = node.last
44
+ bindings = query_bindings(body)
45
+
46
+ Ast.each_node(body) do |block_call|
47
+ next unless Ast.tagged?(block_call, :method_add_block)
48
+
49
+ call_node = block_call[1]
50
+ receiver, loop_method = Ast.call_name(call_node) || [nil, nil]
51
+ next unless LOOP_METHODS.include?(loop_method)
52
+ next unless receiver
53
+
54
+ base_name = var_or_ivar_name(receiver)
55
+ next unless base_name
56
+
57
+ includes = if bindings.key?(base_name)
58
+ bindings[base_name]
59
+ elsif base_name.start_with?("@")
60
+ [] # ivar set elsewhere — treat as an AR collection with no known eager-loading
61
+ end
62
+ next unless includes # local var we never saw assigned from a query — don't guess
63
+
64
+ block_node = block_call[2]
65
+ param_name = block_param_name(block_node)
66
+ next unless param_name
67
+
68
+ findings.concat(association_calls(block_node[2], param_name, includes))
69
+ end
70
+ end
71
+
72
+ findings
73
+ end
74
+
75
+ private
76
+
77
+ # Local-variable/ivar assignments in this method whose value is a query
78
+ # chain rooted at a bare Model constant, mapped to the set of association
79
+ # names already eager-loaded via `.includes(...)` in that same chain.
80
+ def query_bindings(body)
81
+ bindings = {}
82
+
83
+ Ast.each_node(body) do |node|
84
+ next unless Ast.tagged?(node, :assign)
85
+
86
+ name = assign_target_name(node[1])
87
+ next unless name
88
+
89
+ value = node[2]
90
+ next unless query_chain?(value)
91
+
92
+ bindings[name] = includes_symbols(value)
93
+ end
94
+
95
+ bindings
96
+ end
97
+
98
+ def assign_target_name(target)
99
+ return nil unless Ast.tagged?(target, :var_field)
100
+
101
+ inner = target[1]
102
+ return nil unless inner.is_a?(Array)
103
+
104
+ inner[1] if %i[@ident @ivar].include?(inner[0])
105
+ end
106
+
107
+ def query_chain?(value)
108
+ root = root_of(value)
109
+ return false unless Ast.tagged?(root, :var_ref, :vcall)
110
+
111
+ const = root[1]
112
+ return false unless const.is_a?(Array) && const[0] == :@const
113
+
114
+ Ast.each_node(value).any? do |n|
115
+ next false unless Ast.tagged?(n, :call, :method_add_arg, :command, :vcall, :fcall)
116
+
117
+ inner = Ast.tagged?(n, :method_add_arg) ? n[1] : n
118
+ name = Ast.call_name(inner)&.last
119
+ QUERY_METHODS.include?(name)
120
+ end
121
+ end
122
+
123
+ def includes_symbols(value)
124
+ names = []
125
+
126
+ Ast.each_node(value) do |n|
127
+ next unless Ast.tagged?(n, :method_add_arg)
128
+
129
+ receiver_and_name = Ast.call_name(n[1])
130
+ next unless receiver_and_name && receiver_and_name.last == "includes"
131
+
132
+ Ast.call_arguments(n).each do |arg|
133
+ sym = symbol_name(arg)
134
+ names << sym if sym
135
+ end
136
+ end
137
+
138
+ names
139
+ end
140
+
141
+ def symbol_name(node)
142
+ return nil unless Ast.tagged?(node, :symbol_literal)
143
+
144
+ # symbol_literal wraps [:symbol, [:@ident, name, pos]] — one more
145
+ # level of unwrapping than a bare @ident node.
146
+ wrapper = node[1]
147
+ return nil unless Ast.tagged?(wrapper, :symbol)
148
+
149
+ ident = wrapper[1]
150
+ return nil unless ident.is_a?(Array)
151
+
152
+ ident[1] if %i[@ident @const @kw].include?(ident[0])
153
+ end
154
+
155
+ # Walks down the leftmost receiver chain of a call expression to find
156
+ # the root object the whole chain is called on.
157
+ def root_of(node)
158
+ return node unless node.is_a?(Array)
159
+
160
+ case node[0]
161
+ when :method_add_arg, :call, :command_call
162
+ root_of(node[1])
163
+ else
164
+ node
165
+ end
166
+ end
167
+
168
+ def var_or_ivar_name(node)
169
+ return nil unless Ast.tagged?(node, :var_ref, :vcall)
170
+
171
+ inner = node[1]
172
+ return nil unless inner.is_a?(Array)
173
+
174
+ inner[1] if %i[@ident @ivar].include?(inner[0])
175
+ end
176
+
177
+ def block_param_name(block_node)
178
+ return nil unless Ast.tagged?(block_node, :do_block, :brace_block)
179
+
180
+ block_var = block_node[1]
181
+ return nil unless Ast.tagged?(block_var, :block_var)
182
+
183
+ params = block_var[1]
184
+ first_param = params.is_a?(Array) ? params[1]&.first : nil
185
+ return nil unless first_param.is_a?(Array) && first_param[0] == :@ident
186
+
187
+ first_param[1]
188
+ end
189
+
190
+ def association_calls(body, param_name, includes)
191
+ results = []
192
+
193
+ Ast.each_node(body) do |n|
194
+ next unless Ast.tagged?(n, :call)
195
+
196
+ receiver = n[1]
197
+ next unless var_or_ivar_name(receiver) == param_name
198
+
199
+ method_name = Ast.ident_text(n[3])
200
+ next unless method_name
201
+ next if NON_ASSOCIATION_METHODS.include?(method_name)
202
+ next if includes.include?(method_name)
203
+
204
+ line = Ast.line_of(n)
205
+ results << finding(
206
+ line: line,
207
+ message: "`#{param_name}.#{method_name}` is called inside a loop — if `#{method_name}` " \
208
+ "is an association, this issues a separate query per iteration instead of one " \
209
+ "batched query (a classic N+1).",
210
+ suggested_fix: "Eager-load the association on the base query before the loop, e.g. " \
211
+ "`#{param_name.chomp('s')}s = Model.includes(:#{method_name}).where(...)` " \
212
+ "(or add `:#{method_name}` to an existing `.includes(...)` call), so Rails " \
213
+ "fetches it in one extra query instead of one per record."
214
+ )
215
+ end
216
+
217
+ results
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,78 @@
1
+ module Scryer
2
+ module PerformanceRules
3
+ # Flags `Model.all.each`/`Model.where(...).each` (or `.order(...).each`) —
4
+ # chaining `.each` directly onto a query loads every matching row into
5
+ # memory at once before iterating. `find_each`/`find_in_batches` load and
6
+ # yield records in bounded batches instead, keeping memory flat regardless
7
+ # of table size. This only looks at the literal chain (`Const.query.each`)
8
+ # so it won't catch the same problem one step removed — e.g. a variable
9
+ # assigned from the query and iterated later (that pattern is out of scope
10
+ # here; see `NPlusOneQueryRule`, which does track simple local
11
+ # assignments, for a related check on what happens *inside* such a loop).
12
+ class UnboundedTableScanRule < Rule
13
+ self.rule_id = "unbounded_table_scan"
14
+ self.category = "performance"
15
+ self.default_severity = "warning"
16
+ self.title = "Full query result loaded into memory before iterating"
17
+
18
+ QUERY_METHODS = %w[all where order].freeze
19
+
20
+ def scan
21
+ findings = []
22
+
23
+ Ast.each_node(sexp) do |node|
24
+ next unless Ast.tagged?(node, :method_add_block)
25
+
26
+ call_node = node[1]
27
+ receiver, method_name = Ast.call_name(call_node) || [nil, nil]
28
+ next unless method_name == "each"
29
+ next unless receiver
30
+ next unless direct_query_chain?(receiver)
31
+
32
+ line = Ast.line_of(call_node)
33
+ findings << finding(
34
+ line: line,
35
+ message: "`.each` is chained directly onto a query — Active Record loads every " \
36
+ "matching row into memory before the block runs even once, which can exhaust " \
37
+ "memory (or just be very slow) once the table is large.",
38
+ suggested_fix: "Use `find_each` (row-by-row, fixed batch size) or `find_in_batches` " \
39
+ "(access a batch `Array` at a time) instead of `.each`, e.g. " \
40
+ "`Model.where(...).find_each { |record| ... }` — Active Record loads and " \
41
+ "discards records in bounded batches instead of all at once."
42
+ )
43
+ end
44
+
45
+ findings
46
+ end
47
+
48
+ private
49
+
50
+ def direct_query_chain?(receiver)
51
+ return false unless receiver.is_a?(Array)
52
+
53
+ inner = Ast.tagged?(receiver, :method_add_arg) ? receiver[1] : receiver
54
+ return false unless Ast.tagged?(inner, :call)
55
+
56
+ _, name = Ast.call_name(inner)
57
+ return false unless QUERY_METHODS.include?(name)
58
+
59
+ root = root_of(receiver)
60
+ return false unless Ast.tagged?(root, :var_ref, :vcall)
61
+
62
+ const = root[1]
63
+ const.is_a?(Array) && const[0] == :@const
64
+ end
65
+
66
+ def root_of(node)
67
+ return node unless node.is_a?(Array)
68
+
69
+ case node[0]
70
+ when :method_add_arg, :call, :command_call
71
+ root_of(node[1])
72
+ else
73
+ node
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,123 @@
1
+ require "ripper"
2
+
3
+ module Scryer
4
+ # Extracts each Active Record query chain rooted at a bare Model constant
5
+ # (`User.where(...).order(...)`, `Order.joins(:items).count`, ...) as a
6
+ # MethodInfo — same shape MethodExtractor produces for `def`s, so it can
7
+ # feed the same DuplicateDetector — to catch copy-pasted query logic that
8
+ # never got extracted into a scope/service, even when the surrounding
9
+ # methods themselves don't look alike.
10
+ #
11
+ # A "chain" is captured at its outermost call (the last method in the
12
+ # chain), and the walk does not recurse into a chain it just captured —
13
+ # so `User.where(...).order(...)` is one unit, not also a nested `where`
14
+ # unit. This means a subquery buried inside a captured chain's arguments
15
+ # (e.g. `Foo.where(id: Bar.select(:id))`) isn't separately extracted — an
16
+ # accepted simplification, not a correctness bug: it would still be found
17
+ # if the same subquery shape appears somewhere else outside a captured
18
+ # chain.
19
+ module QueryExtractor
20
+ module_function
21
+
22
+ # Mirrors the query-method lists in NPlusOneQueryRule/UnboundedTableScanRule
23
+ # but broader, since here we're looking for "this is recognizably a query"
24
+ # rather than one specific anti-pattern.
25
+ QUERY_METHODS = %w[
26
+ where find_by find_by! find_or_create_by find_or_initialize_by
27
+ order reorder limit offset joins left_joins includes preload eager_load
28
+ references group having select distinct pluck exists? count sum average
29
+ minimum maximum find first last not or none unscope ids find_each
30
+ find_in_batches in_batches lock readonly
31
+ ].freeze
32
+
33
+ MIN_TOKENS = 8 # shorter than MethodExtractor's threshold — queries are terser than whole methods
34
+
35
+ def extract(file:, source:, sexp:)
36
+ queries = []
37
+ walk(sexp) do |node|
38
+ info = build_info(node, file: file, source: source)
39
+ queries << info if info
40
+ end
41
+ queries
42
+ end
43
+
44
+ def walk(node, &block)
45
+ return unless node.is_a?(Array)
46
+
47
+ if chain_root?(node)
48
+ block.call(node)
49
+ return # don't descend into a chain we just captured — see module doc
50
+ end
51
+
52
+ node.each { |child| walk(child, &block) }
53
+ end
54
+
55
+ def chain_root?(node)
56
+ inner = Ast.tagged?(node, :method_add_arg) ? node[1] : node
57
+ name = Ast.call_name(inner)&.last
58
+ return false unless name && QUERY_METHODS.include?(name)
59
+
60
+ root = root_of(node)
61
+ Ast.tagged?(root, :var_ref, :vcall) &&
62
+ root[1].is_a?(Array) && root[1][0] == :@const
63
+ end
64
+
65
+ # Walks down the leftmost receiver chain to the constant the whole chain
66
+ # is called on (same logic as NPlusOneQueryRule#root_of).
67
+ def root_of(node)
68
+ return node unless node.is_a?(Array)
69
+
70
+ case node[0]
71
+ when :method_add_arg, :call, :command_call
72
+ root_of(node[1])
73
+ else
74
+ node
75
+ end
76
+ end
77
+
78
+ def build_info(node, file:, source:)
79
+ start_line, end_line = Ast.line_range_of(node)
80
+ return nil unless start_line
81
+
82
+ tokens = Ast.normalized_tokens(node)
83
+ return nil if tokens.size < MIN_TOKENS
84
+
85
+ const_name = Ast.ident_text(root_of(node)[1]) || "?"
86
+
87
+ MethodInfo.new(
88
+ name: "#{const_name}.#{chain_label(node)}",
89
+ file: file,
90
+ start_line: start_line,
91
+ end_line: end_line,
92
+ token_stream: tokens,
93
+ source_snippet: Ast.source_text(source, node)
94
+ )
95
+ end
96
+
97
+ # Best-effort ".method1.method2" label (outermost-first) for display —
98
+ # not used for comparison, just so a finding reads as
99
+ # "User.where.order" instead of a bare line number. Each iteration
100
+ # unwraps one call link (method_add_arg -> its :call, or a bare :call)
101
+ # and steps the cursor to *that* call's receiver, so a chain link is
102
+ # never counted twice.
103
+ def chain_label(node)
104
+ names = []
105
+ cursor = node
106
+
107
+ loop do
108
+ break unless cursor.is_a?(Array)
109
+
110
+ inner = Ast.tagged?(cursor, :method_add_arg) ? cursor[1] : cursor
111
+ break unless Ast.tagged?(inner, :call, :vcall, :fcall, :command)
112
+
113
+ name = Ast.call_name(inner)&.last
114
+ names.unshift(name) if name
115
+ break unless Ast.tagged?(inner, :call) # vcall/fcall/command have no receiver to descend into
116
+
117
+ cursor = inner[1]
118
+ end
119
+
120
+ names.join(".")
121
+ end
122
+ end
123
+ end