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.
@@ -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