eager_eye 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/.rspec +3 -0
- data/.rubocop.yml +35 -0
- data/CHANGELOG.md +45 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +310 -0
- data/Rakefile +12 -0
- data/examples/github_action.yml +75 -0
- data/exe/eager_eye +6 -0
- data/lib/eager_eye/analyzer.rb +78 -0
- data/lib/eager_eye/cli.rb +120 -0
- data/lib/eager_eye/configuration.rb +16 -0
- data/lib/eager_eye/detectors/base.rb +48 -0
- data/lib/eager_eye/detectors/loop_association.rb +124 -0
- data/lib/eager_eye/detectors/missing_counter_cache.rb +73 -0
- data/lib/eager_eye/detectors/serializer_nesting.rb +192 -0
- data/lib/eager_eye/generators/install_generator.rb +41 -0
- data/lib/eager_eye/issue.rb +58 -0
- data/lib/eager_eye/railtie.rb +63 -0
- data/lib/eager_eye/reporters/base.rb +31 -0
- data/lib/eager_eye/reporters/console.rb +96 -0
- data/lib/eager_eye/reporters/json.rb +34 -0
- data/lib/eager_eye/version.rb +5 -0
- data/lib/eager_eye.rb +35 -0
- data/sig/eager_eye.rbs +4 -0
- metadata +103 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "parser/current"
|
|
4
|
+
|
|
5
|
+
module EagerEye
|
|
6
|
+
class Analyzer
|
|
7
|
+
DETECTOR_CLASSES = {
|
|
8
|
+
loop_association: Detectors::LoopAssociation,
|
|
9
|
+
serializer_nesting: Detectors::SerializerNesting,
|
|
10
|
+
missing_counter_cache: Detectors::MissingCounterCache
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :paths, :issues
|
|
14
|
+
|
|
15
|
+
def initialize(paths: nil)
|
|
16
|
+
@paths = Array(paths || EagerEye.configuration.app_path)
|
|
17
|
+
@issues = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
@issues = []
|
|
22
|
+
|
|
23
|
+
ruby_files.each do |file_path|
|
|
24
|
+
analyze_file(file_path)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@issues
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def ruby_files
|
|
33
|
+
all_files = paths.flat_map do |path|
|
|
34
|
+
if File.file?(path)
|
|
35
|
+
[path]
|
|
36
|
+
elsif File.directory?(path)
|
|
37
|
+
Dir.glob(File.join(path, "**", "*.rb"))
|
|
38
|
+
else
|
|
39
|
+
Dir.glob(path)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
all_files.reject { |file| excluded?(file) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def excluded?(file_path)
|
|
47
|
+
EagerEye.configuration.excluded_paths.any? do |pattern|
|
|
48
|
+
File.fnmatch?(pattern, file_path, File::FNM_PATHNAME)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def analyze_file(file_path)
|
|
53
|
+
source = File.read(file_path)
|
|
54
|
+
ast = parse_source(source)
|
|
55
|
+
return unless ast
|
|
56
|
+
|
|
57
|
+
enabled_detectors.each do |detector|
|
|
58
|
+
file_issues = detector.detect(ast, file_path)
|
|
59
|
+
@issues.concat(file_issues)
|
|
60
|
+
end
|
|
61
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
62
|
+
warn "EagerEye: Could not read file #{file_path}: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse_source(source)
|
|
66
|
+
Parser::CurrentRuby.parse(source)
|
|
67
|
+
rescue Parser::SyntaxError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def enabled_detectors
|
|
72
|
+
@enabled_detectors ||= EagerEye.configuration.enabled_detectors.filter_map do |name|
|
|
73
|
+
detector_class = DETECTOR_CLASSES[name]
|
|
74
|
+
detector_class&.new
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
module EagerEye
|
|
6
|
+
class CLI
|
|
7
|
+
attr_reader :argv, :options
|
|
8
|
+
|
|
9
|
+
def initialize(argv = ARGV)
|
|
10
|
+
@argv = argv
|
|
11
|
+
@options = default_options
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run
|
|
15
|
+
parse_options!
|
|
16
|
+
return 0 if options[:help] || options[:version]
|
|
17
|
+
|
|
18
|
+
issues = analyze
|
|
19
|
+
output_report(issues)
|
|
20
|
+
exit_code(issues)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def default_options
|
|
26
|
+
{
|
|
27
|
+
paths: [],
|
|
28
|
+
format: :console,
|
|
29
|
+
exclude: [],
|
|
30
|
+
only: [],
|
|
31
|
+
fail_on_issues: true,
|
|
32
|
+
colorize: $stdout.tty?,
|
|
33
|
+
help: false,
|
|
34
|
+
version: false
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def parse_options!
|
|
39
|
+
parser.parse!(argv)
|
|
40
|
+
options[:paths] = argv.empty? ? [EagerEye.configuration.app_path] : argv
|
|
41
|
+
rescue OptionParser::InvalidOption => e
|
|
42
|
+
warn "Error: #{e.message}"
|
|
43
|
+
warn parser
|
|
44
|
+
exit 1
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def parser
|
|
48
|
+
@parser ||= OptionParser.new do |opts|
|
|
49
|
+
opts.banner = "Usage: eager_eye [options] [paths...]"
|
|
50
|
+
opts.separator ""
|
|
51
|
+
opts.separator "Options:"
|
|
52
|
+
|
|
53
|
+
opts.on("-f", "--format FORMAT", %i[console json], "Output format (console, json)") do |format|
|
|
54
|
+
options[:format] = format
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
opts.on("-e", "--exclude PATTERN", "Exclude files matching pattern (can be used multiple times)") do |pattern|
|
|
58
|
+
options[:exclude] << pattern
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
opts.on("-o", "--only DETECTORS", "Run only specified detectors (comma-separated)") do |detectors|
|
|
62
|
+
options[:only] = detectors.split(",").map(&:strip).map(&:to_sym)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
opts.on("--no-fail", "Exit with 0 even if issues found") do
|
|
66
|
+
options[:fail_on_issues] = false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
opts.on("--no-color", "Disable colored output") do
|
|
70
|
+
options[:colorize] = false
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
opts.on("-v", "--version", "Show version") do
|
|
74
|
+
puts "EagerEye #{EagerEye::VERSION}"
|
|
75
|
+
options[:version] = true
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
opts.on("-h", "--help", "Show this help message") do
|
|
79
|
+
puts opts
|
|
80
|
+
options[:help] = true
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def analyze
|
|
86
|
+
configure_from_options!
|
|
87
|
+
|
|
88
|
+
analyzer = Analyzer.new(paths: options[:paths])
|
|
89
|
+
analyzer.run
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def configure_from_options!
|
|
93
|
+
EagerEye.configure do |config|
|
|
94
|
+
config.excluded_paths += options[:exclude]
|
|
95
|
+
config.enabled_detectors = options[:only] unless options[:only].empty?
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def output_report(issues)
|
|
100
|
+
reporter = create_reporter(issues)
|
|
101
|
+
puts reporter.report
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def create_reporter(issues)
|
|
105
|
+
case options[:format]
|
|
106
|
+
when :json
|
|
107
|
+
Reporters::Json.new(issues, pretty: true)
|
|
108
|
+
else
|
|
109
|
+
Reporters::Console.new(issues, colorize: options[:colorize])
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def exit_code(issues)
|
|
114
|
+
return 0 unless options[:fail_on_issues]
|
|
115
|
+
return 0 if issues.empty?
|
|
116
|
+
|
|
117
|
+
1
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :excluded_paths, :enabled_detectors, :app_path, :fail_on_issues
|
|
6
|
+
|
|
7
|
+
DEFAULT_DETECTORS = %i[loop_association serializer_nesting missing_counter_cache].freeze
|
|
8
|
+
|
|
9
|
+
def initialize
|
|
10
|
+
@excluded_paths = []
|
|
11
|
+
@enabled_detectors = DEFAULT_DETECTORS.dup
|
|
12
|
+
@app_path = "app"
|
|
13
|
+
@fail_on_issues = true
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "parser/current"
|
|
4
|
+
|
|
5
|
+
module EagerEye
|
|
6
|
+
module Detectors
|
|
7
|
+
class Base
|
|
8
|
+
class << self
|
|
9
|
+
def detector_name
|
|
10
|
+
raise NotImplementedError, "Subclasses must implement .detector_name"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def detect(_ast, _file_path)
|
|
15
|
+
raise NotImplementedError, "Subclasses must implement #detect"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
protected
|
|
19
|
+
|
|
20
|
+
def create_issue(file_path:, line_number:, message:, severity: :warning, suggestion: nil)
|
|
21
|
+
Issue.new(
|
|
22
|
+
detector: self.class.detector_name,
|
|
23
|
+
file_path: file_path,
|
|
24
|
+
line_number: line_number,
|
|
25
|
+
message: message,
|
|
26
|
+
severity: severity,
|
|
27
|
+
suggestion: suggestion
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def traverse_ast(node, &block)
|
|
32
|
+
return unless node.is_a?(Parser::AST::Node)
|
|
33
|
+
|
|
34
|
+
yield node
|
|
35
|
+
|
|
36
|
+
node.children.each do |child|
|
|
37
|
+
traverse_ast(child, &block)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def parse_source(source)
|
|
42
|
+
Parser::CurrentRuby.parse(source)
|
|
43
|
+
rescue Parser::SyntaxError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
module Detectors
|
|
5
|
+
class LoopAssociation < Base
|
|
6
|
+
ITERATION_METHODS = %i[each map collect select find find_all reject filter filter_map flat_map].freeze
|
|
7
|
+
|
|
8
|
+
# Common singular association names (belongs_to pattern)
|
|
9
|
+
SINGULAR_ASSOCIATIONS = %w[
|
|
10
|
+
author user owner creator admin member customer client
|
|
11
|
+
post article comment category tag parent company organization
|
|
12
|
+
project task item order product account profile setting
|
|
13
|
+
image avatar photo attachment document
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
# Common plural association names (has_many pattern)
|
|
17
|
+
PLURAL_ASSOCIATIONS = %w[
|
|
18
|
+
authors users owners creators admins members customers clients
|
|
19
|
+
posts articles comments categories tags children companies organizations
|
|
20
|
+
projects tasks items orders products accounts profiles settings
|
|
21
|
+
images avatars photos attachments documents
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
# Methods that should NOT be treated as associations
|
|
25
|
+
EXCLUDED_METHODS = %i[
|
|
26
|
+
id to_s to_h to_a to_json to_xml inspect class object_id
|
|
27
|
+
nil? blank? present? empty? any? none? size count length
|
|
28
|
+
save save! update update! destroy destroy! delete delete!
|
|
29
|
+
valid? invalid? errors new? persisted? changed? frozen?
|
|
30
|
+
name title body content text description value key type status state
|
|
31
|
+
created_at updated_at deleted_at
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
def self.detector_name
|
|
35
|
+
:loop_association
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def detect(ast, file_path)
|
|
39
|
+
return [] unless ast
|
|
40
|
+
|
|
41
|
+
issues = []
|
|
42
|
+
|
|
43
|
+
traverse_ast(ast) do |node|
|
|
44
|
+
next unless iteration_block?(node)
|
|
45
|
+
|
|
46
|
+
block_var = extract_block_variable(node)
|
|
47
|
+
next unless block_var
|
|
48
|
+
|
|
49
|
+
block_body = node.children[2]
|
|
50
|
+
next unless block_body
|
|
51
|
+
|
|
52
|
+
find_association_calls(block_body, block_var, file_path, issues)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
issues
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def iteration_block?(node)
|
|
61
|
+
return false unless node.type == :block
|
|
62
|
+
|
|
63
|
+
send_node = node.children[0]
|
|
64
|
+
return false unless send_node&.type == :send
|
|
65
|
+
|
|
66
|
+
method_name = send_node.children[1]
|
|
67
|
+
ITERATION_METHODS.include?(method_name)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def extract_block_variable(block_node)
|
|
71
|
+
args_node = block_node.children[1]
|
|
72
|
+
return nil unless args_node&.type == :args
|
|
73
|
+
return nil if args_node.children.empty?
|
|
74
|
+
|
|
75
|
+
first_arg = args_node.children[0]
|
|
76
|
+
return nil unless first_arg&.type == :arg
|
|
77
|
+
|
|
78
|
+
first_arg.children[0]
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def find_association_calls(node, block_var, file_path, issues)
|
|
82
|
+
reported_associations = Set.new
|
|
83
|
+
|
|
84
|
+
traverse_ast(node) do |child|
|
|
85
|
+
next unless child.type == :send
|
|
86
|
+
|
|
87
|
+
receiver = child.children[0]
|
|
88
|
+
method_name = child.children[1]
|
|
89
|
+
|
|
90
|
+
# Only detect direct calls on block variable (post.author, not post.author.name)
|
|
91
|
+
next unless direct_call_on_block_var?(receiver, block_var)
|
|
92
|
+
next unless likely_association?(method_name)
|
|
93
|
+
|
|
94
|
+
# Avoid duplicate reports for same association on same line
|
|
95
|
+
report_key = "#{child.loc.line}:#{method_name}"
|
|
96
|
+
next if reported_associations.include?(report_key)
|
|
97
|
+
|
|
98
|
+
reported_associations << report_key
|
|
99
|
+
|
|
100
|
+
issues << create_issue(
|
|
101
|
+
file_path: file_path,
|
|
102
|
+
line_number: child.loc.line,
|
|
103
|
+
message: "Potential N+1 query: `#{block_var}.#{method_name}` called inside iteration",
|
|
104
|
+
suggestion: "Consider using `includes(:#{method_name})` on the collection before iterating"
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def direct_call_on_block_var?(receiver, block_var)
|
|
110
|
+
return false unless receiver
|
|
111
|
+
|
|
112
|
+
receiver.type == :lvar && receiver.children[0] == block_var
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def likely_association?(method_name)
|
|
116
|
+
return false if EXCLUDED_METHODS.include?(method_name)
|
|
117
|
+
|
|
118
|
+
name = method_name.to_s
|
|
119
|
+
|
|
120
|
+
SINGULAR_ASSOCIATIONS.include?(name) || PLURAL_ASSOCIATIONS.include?(name)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
module Detectors
|
|
5
|
+
class MissingCounterCache < Base
|
|
6
|
+
# Methods that trigger COUNT queries
|
|
7
|
+
COUNT_METHODS = %i[count size length].freeze
|
|
8
|
+
|
|
9
|
+
# Common has_many association names (plural)
|
|
10
|
+
PLURAL_ASSOCIATIONS = %w[
|
|
11
|
+
posts comments tags categories articles users members
|
|
12
|
+
items orders products tasks projects images attachments
|
|
13
|
+
documents files messages notifications reviews ratings
|
|
14
|
+
followers followings likes favorites bookmarks votes
|
|
15
|
+
children replies responses answers questions
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
def self.detector_name
|
|
19
|
+
:missing_counter_cache
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def detect(ast, file_path)
|
|
23
|
+
return [] unless ast
|
|
24
|
+
|
|
25
|
+
issues = []
|
|
26
|
+
|
|
27
|
+
traverse_ast(ast) do |node|
|
|
28
|
+
next unless count_on_association?(node)
|
|
29
|
+
|
|
30
|
+
association_name = extract_association_name(node)
|
|
31
|
+
next unless association_name
|
|
32
|
+
|
|
33
|
+
issues << create_issue(
|
|
34
|
+
file_path: file_path,
|
|
35
|
+
line_number: node.loc.line,
|
|
36
|
+
message: "`.#{node.children[1]}` called on `#{association_name}` may cause N+1 queries",
|
|
37
|
+
suggestion: "Consider adding `counter_cache: true` to the belongs_to association"
|
|
38
|
+
)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
issues
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def count_on_association?(node)
|
|
47
|
+
return false unless node.type == :send
|
|
48
|
+
|
|
49
|
+
method_name = node.children[1]
|
|
50
|
+
return false unless COUNT_METHODS.include?(method_name)
|
|
51
|
+
|
|
52
|
+
receiver = node.children[0]
|
|
53
|
+
return false unless receiver
|
|
54
|
+
|
|
55
|
+
likely_association_receiver?(receiver)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def likely_association_receiver?(node)
|
|
59
|
+
return false unless node.type == :send
|
|
60
|
+
|
|
61
|
+
method_name = node.children[1]
|
|
62
|
+
PLURAL_ASSOCIATIONS.include?(method_name.to_s)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def extract_association_name(node)
|
|
66
|
+
receiver = node.children[0]
|
|
67
|
+
return nil unless receiver&.type == :send
|
|
68
|
+
|
|
69
|
+
receiver.children[1].to_s
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EagerEye
|
|
4
|
+
module Detectors
|
|
5
|
+
class SerializerNesting < Base
|
|
6
|
+
# Serializer base classes to detect
|
|
7
|
+
SERIALIZER_PATTERNS = [
|
|
8
|
+
"ActiveModel::Serializer",
|
|
9
|
+
"ActiveModelSerializers::Model",
|
|
10
|
+
"Blueprinter::Base",
|
|
11
|
+
"Alba::Resource"
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
# Method names that define attributes in serializers
|
|
15
|
+
ATTRIBUTE_METHODS = %i[attribute field attributes].freeze
|
|
16
|
+
|
|
17
|
+
# Object reference names in serializers
|
|
18
|
+
OBJECT_REFS = %i[object record resource].freeze
|
|
19
|
+
|
|
20
|
+
# Common association names (same as LoopAssociation)
|
|
21
|
+
ASSOCIATION_NAMES = %w[
|
|
22
|
+
author user owner creator admin member customer client
|
|
23
|
+
post article comment category tag parent company organization
|
|
24
|
+
project task item order product account profile setting
|
|
25
|
+
image avatar photo attachment document
|
|
26
|
+
authors users owners creators admins members customers clients
|
|
27
|
+
posts articles comments categories tags children companies organizations
|
|
28
|
+
projects tasks items orders products accounts profiles settings
|
|
29
|
+
images avatars photos attachments documents
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
def self.detector_name
|
|
33
|
+
:serializer_nesting
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def detect(ast, file_path)
|
|
37
|
+
return [] unless ast
|
|
38
|
+
|
|
39
|
+
issues = []
|
|
40
|
+
|
|
41
|
+
traverse_ast(ast) do |node|
|
|
42
|
+
next unless serializer_class?(node)
|
|
43
|
+
|
|
44
|
+
find_nested_associations(node, file_path, issues)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
issues
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def serializer_class?(node)
|
|
53
|
+
return false unless node.type == :class
|
|
54
|
+
|
|
55
|
+
# Check class name ends with Serializer, Blueprint, or Resource
|
|
56
|
+
class_name = extract_class_name(node)
|
|
57
|
+
return false unless class_name
|
|
58
|
+
|
|
59
|
+
class_name.end_with?("Serializer", "Blueprint", "Resource") ||
|
|
60
|
+
inherits_from_serializer?(node) ||
|
|
61
|
+
includes_serializer_module?(node)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def extract_class_name(class_node)
|
|
65
|
+
name_node = class_node.children[0]
|
|
66
|
+
return nil unless name_node
|
|
67
|
+
return nil unless name_node.type == :const
|
|
68
|
+
|
|
69
|
+
name_node.children[1].to_s
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def inherits_from_serializer?(class_node)
|
|
73
|
+
parent_node = class_node.children[1]
|
|
74
|
+
return false unless parent_node
|
|
75
|
+
|
|
76
|
+
parent_name = const_to_string(parent_node)
|
|
77
|
+
SERIALIZER_PATTERNS.any? { |pattern| parent_name&.include?(pattern.split("::").last) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def includes_serializer_module?(class_node)
|
|
81
|
+
body = class_node.children[2]
|
|
82
|
+
return false unless body
|
|
83
|
+
|
|
84
|
+
traverse_ast(body) do |node|
|
|
85
|
+
next unless node.type == :send
|
|
86
|
+
|
|
87
|
+
method = node.children[1]
|
|
88
|
+
return true if method == :include && alba_resource?(node)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def alba_resource?(include_node)
|
|
95
|
+
arg = include_node.children[2]
|
|
96
|
+
return false unless arg
|
|
97
|
+
|
|
98
|
+
const_to_string(arg)&.include?("Alba")
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def const_to_string(node)
|
|
102
|
+
return nil unless node&.type == :const
|
|
103
|
+
|
|
104
|
+
parts = []
|
|
105
|
+
current = node
|
|
106
|
+
|
|
107
|
+
while current&.type == :const
|
|
108
|
+
parts.unshift(current.children[1].to_s)
|
|
109
|
+
current = current.children[0]
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
parts.join("::")
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def find_nested_associations(class_node, file_path, issues)
|
|
116
|
+
body = class_node.children[2]
|
|
117
|
+
return unless body
|
|
118
|
+
|
|
119
|
+
traverse_ast(body) do |node|
|
|
120
|
+
next unless attribute_block?(node)
|
|
121
|
+
|
|
122
|
+
block_body = node.children[2]
|
|
123
|
+
next unless block_body
|
|
124
|
+
|
|
125
|
+
find_association_in_block(block_body, node, file_path, issues)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def attribute_block?(node)
|
|
130
|
+
return false unless node.type == :block
|
|
131
|
+
|
|
132
|
+
send_node = node.children[0]
|
|
133
|
+
return false unless send_node&.type == :send
|
|
134
|
+
|
|
135
|
+
method_name = send_node.children[1]
|
|
136
|
+
ATTRIBUTE_METHODS.include?(method_name)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def find_association_in_block(block_body, _block_node, file_path, issues)
|
|
140
|
+
traverse_ast(block_body) do |node|
|
|
141
|
+
next unless node.type == :send
|
|
142
|
+
|
|
143
|
+
receiver = node.children[0]
|
|
144
|
+
method_name = node.children[1]
|
|
145
|
+
|
|
146
|
+
next unless object_reference?(receiver)
|
|
147
|
+
next unless likely_association?(method_name)
|
|
148
|
+
|
|
149
|
+
issues << create_issue(
|
|
150
|
+
file_path: file_path,
|
|
151
|
+
line_number: node.loc.line,
|
|
152
|
+
message: "Nested association `#{receiver_name(receiver)}.#{method_name}` in serializer attribute",
|
|
153
|
+
suggestion: "Eager load :#{method_name} in controller or use association serializer"
|
|
154
|
+
)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def object_reference?(node)
|
|
159
|
+
return false unless node
|
|
160
|
+
|
|
161
|
+
case node.type
|
|
162
|
+
when :send
|
|
163
|
+
# object.something or record.something
|
|
164
|
+
receiver = node.children[0]
|
|
165
|
+
method = node.children[1]
|
|
166
|
+
|
|
167
|
+
receiver.nil? && OBJECT_REFS.include?(method)
|
|
168
|
+
when :lvar
|
|
169
|
+
# Block variable like |post|
|
|
170
|
+
true
|
|
171
|
+
else
|
|
172
|
+
false
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def receiver_name(node)
|
|
177
|
+
case node.type
|
|
178
|
+
when :send
|
|
179
|
+
node.children[1].to_s
|
|
180
|
+
when :lvar
|
|
181
|
+
node.children[0].to_s
|
|
182
|
+
else
|
|
183
|
+
"object"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def likely_association?(method_name)
|
|
188
|
+
ASSOCIATION_NAMES.include?(method_name.to_s)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
|
|
5
|
+
module EagerEye
|
|
6
|
+
module Generators
|
|
7
|
+
class InstallGenerator < Rails::Generators::Base
|
|
8
|
+
desc "Creates an EagerEye configuration file"
|
|
9
|
+
|
|
10
|
+
def create_config_file
|
|
11
|
+
create_file ".eager_eye.yml", config_content
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def config_content
|
|
17
|
+
<<~YAML
|
|
18
|
+
# EagerEye Configuration
|
|
19
|
+
# See https://github.com/hamzagedikkaya/eager_eye for documentation
|
|
20
|
+
|
|
21
|
+
# Paths to exclude from analysis (glob patterns)
|
|
22
|
+
excluded_paths:
|
|
23
|
+
# - app/serializers/legacy/**
|
|
24
|
+
# - lib/tasks/**
|
|
25
|
+
|
|
26
|
+
# Detectors to enable (default: all)
|
|
27
|
+
enabled_detectors:
|
|
28
|
+
- loop_association
|
|
29
|
+
- serializer_nesting
|
|
30
|
+
- missing_counter_cache
|
|
31
|
+
|
|
32
|
+
# Base path to analyze (default: app)
|
|
33
|
+
app_path: app
|
|
34
|
+
|
|
35
|
+
# Exit with error code when issues found (default: true)
|
|
36
|
+
fail_on_issues: true
|
|
37
|
+
YAML
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|