rubyzen-lint 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/LICENSE +201 -0
- data/README.md +110 -0
- data/lib/rubyzen/cache/parse_cache.rb +36 -0
- data/lib/rubyzen/collections/attributes_collection.rb +32 -0
- data/lib/rubyzen/collections/base_collection.rb +30 -0
- data/lib/rubyzen/collections/blocks_collection.rb +27 -0
- data/lib/rubyzen/collections/call_site_collection.rb +52 -0
- data/lib/rubyzen/collections/classes_collection.rb +77 -0
- data/lib/rubyzen/collections/constants_collection.rb +11 -0
- data/lib/rubyzen/collections/declaration_collection.rb +11 -0
- data/lib/rubyzen/collections/file_collection.rb +81 -0
- data/lib/rubyzen/collections/macros_collection.rb +11 -0
- data/lib/rubyzen/collections/methods_collection.rb +67 -0
- data/lib/rubyzen/collections/modules_collection.rb +36 -0
- data/lib/rubyzen/collections/parameters_collection.rb +11 -0
- data/lib/rubyzen/collections/raises_collection.rb +26 -0
- data/lib/rubyzen/collections/requires_collection.rb +32 -0
- data/lib/rubyzen/collections/rescues_collection.rb +19 -0
- data/lib/rubyzen/declarations/attribute_declaration.rb +62 -0
- data/lib/rubyzen/declarations/block_declaration.rb +49 -0
- data/lib/rubyzen/declarations/call_site_declaration.rb +98 -0
- data/lib/rubyzen/declarations/class_declaration.rb +168 -0
- data/lib/rubyzen/declarations/constant_declaration.rb +155 -0
- data/lib/rubyzen/declarations/file_declaration.rb +69 -0
- data/lib/rubyzen/declarations/if_statement_declaration.rb +44 -0
- data/lib/rubyzen/declarations/macro_declaration.rb +81 -0
- data/lib/rubyzen/declarations/method_declaration.rb +63 -0
- data/lib/rubyzen/declarations/module_declaration.rb +115 -0
- data/lib/rubyzen/declarations/parameter_declaration.rb +43 -0
- data/lib/rubyzen/declarations/raise_declaration.rb +87 -0
- data/lib/rubyzen/declarations/require_declaration.rb +61 -0
- data/lib/rubyzen/declarations/rescue_declaration.rb +54 -0
- data/lib/rubyzen/lint.rb +1 -0
- data/lib/rubyzen/matchers/matcher_helpers.rb +176 -0
- data/lib/rubyzen/matchers/zen_empty_matcher.rb +54 -0
- data/lib/rubyzen/matchers/zen_false_matcher.rb +63 -0
- data/lib/rubyzen/matchers/zen_true_matcher.rb +57 -0
- data/lib/rubyzen/parsers/a_s_t_parser.rb +33 -0
- data/lib/rubyzen/project.rb +69 -0
- data/lib/rubyzen/providers/attributes_provider.rb +19 -0
- data/lib/rubyzen/providers/blocks_provider.rb +15 -0
- data/lib/rubyzen/providers/call_site_provider.rb +15 -0
- data/lib/rubyzen/providers/class_name_provider.rb +36 -0
- data/lib/rubyzen/providers/collection_filter_provider.rb +64 -0
- data/lib/rubyzen/providers/constants_provider.rb +17 -0
- data/lib/rubyzen/providers/file_path_provider.rb +26 -0
- data/lib/rubyzen/providers/if_statements_provider.rb +11 -0
- data/lib/rubyzen/providers/line_number_provider.rb +11 -0
- data/lib/rubyzen/providers/lines_of_code_provider.rb +11 -0
- data/lib/rubyzen/providers/macros_provider.rb +17 -0
- data/lib/rubyzen/providers/raises_provider.rb +19 -0
- data/lib/rubyzen/providers/requires_provider.rb +19 -0
- data/lib/rubyzen/providers/rescues_provider.rb +17 -0
- data/lib/rubyzen/providers/source_code_provider.rb +11 -0
- data/lib/rubyzen/providers/visibility_provider.rb +49 -0
- data/lib/rubyzen/version.rb +3 -0
- data/lib/rubyzen-lint.rb +1 -0
- data/lib/rubyzen.rb +98 -0
- data/rubyzen-lint.gemspec +28 -0
- metadata +148 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
module Declarations
|
|
3
|
+
# Represents a +require+, +require_relative+, or +load+ statement.
|
|
4
|
+
#
|
|
5
|
+
# @example
|
|
6
|
+
# req = file.requires.first
|
|
7
|
+
# req.required_path #=> "json"
|
|
8
|
+
# req.require? #=> true
|
|
9
|
+
# req.require_relative? #=> false
|
|
10
|
+
#
|
|
11
|
+
class RequireDeclaration
|
|
12
|
+
include Rubyzen::Providers::FilePathProvider
|
|
13
|
+
include Rubyzen::Providers::LineNumberProvider
|
|
14
|
+
|
|
15
|
+
# @return [RuboCop::AST::Node]
|
|
16
|
+
attr_reader :node
|
|
17
|
+
|
|
18
|
+
# @return [FileDeclaration]
|
|
19
|
+
attr_reader :parent_file
|
|
20
|
+
alias :parent :parent_file
|
|
21
|
+
|
|
22
|
+
# @param node [RuboCop::AST::Node] the AST node
|
|
23
|
+
# @param parent_file [FileDeclaration] the parent file declaration
|
|
24
|
+
def initialize(node, parent_file)
|
|
25
|
+
@node = node
|
|
26
|
+
@parent_file = parent_file
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns the statement type.
|
|
30
|
+
#
|
|
31
|
+
# @return [String] one of +"require"+, +"require_relative"+, +"load"+
|
|
32
|
+
def name
|
|
33
|
+
node.method_name.to_s
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Returns the required path string.
|
|
37
|
+
#
|
|
38
|
+
# @return [String, nil]
|
|
39
|
+
def required_path
|
|
40
|
+
first_arg = node.arguments.first
|
|
41
|
+
return nil unless first_arg&.type == :str
|
|
42
|
+
first_arg.value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @return [Boolean]
|
|
46
|
+
def require?
|
|
47
|
+
name == 'require'
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Boolean]
|
|
51
|
+
def require_relative?
|
|
52
|
+
name == 'require_relative'
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def load?
|
|
57
|
+
name == 'load'
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
module Declarations
|
|
3
|
+
# Represents a +rescue+ clause within a method or block.
|
|
4
|
+
#
|
|
5
|
+
# @example
|
|
6
|
+
# rescue_decl = method.rescues.first
|
|
7
|
+
# rescue_decl.exception_types #=> ["ArgumentError", "TypeError"]
|
|
8
|
+
#
|
|
9
|
+
class RescueDeclaration
|
|
10
|
+
include Rubyzen::Providers::FilePathProvider
|
|
11
|
+
include Rubyzen::Providers::LineNumberProvider
|
|
12
|
+
include Rubyzen::Providers::ClassNameProvider
|
|
13
|
+
|
|
14
|
+
# @return [RuboCop::AST::Node]
|
|
15
|
+
attr_reader :node
|
|
16
|
+
|
|
17
|
+
# @return [MethodDeclaration, BlockDeclaration]
|
|
18
|
+
attr_reader :parent
|
|
19
|
+
|
|
20
|
+
# @param node [RuboCop::AST::Node] the AST node
|
|
21
|
+
# @param parent [MethodDeclaration, BlockDeclaration] the parent declaration
|
|
22
|
+
def initialize(node, parent)
|
|
23
|
+
@node = node
|
|
24
|
+
@parent = parent
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns the rescued exception class names.
|
|
28
|
+
# Defaults to +["StandardError"]+ for bare +rescue+.
|
|
29
|
+
#
|
|
30
|
+
# @return [Array<String>]
|
|
31
|
+
def exception_types
|
|
32
|
+
extract_exception_types
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def extract_exception_types
|
|
38
|
+
exception_array_node = node.children[0]
|
|
39
|
+
|
|
40
|
+
return ['StandardError'] if exception_array_node.nil?
|
|
41
|
+
|
|
42
|
+
exception_array_node.children.map do |const_node|
|
|
43
|
+
extract_const_name(const_node)
|
|
44
|
+
end.compact
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def extract_const_name(node)
|
|
48
|
+
return nil unless node&.type == :const
|
|
49
|
+
|
|
50
|
+
node.const_name
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/rubyzen/lint.rb
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
require_relative '../rubyzen'
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
module Matchers
|
|
3
|
+
# Shared helper methods used by Rubyzen's custom RSpec matchers.
|
|
4
|
+
#
|
|
5
|
+
# Provides utilities for normalizing exception lists, extracting item
|
|
6
|
+
# details, matching items against allowlist/baseline entries, and
|
|
7
|
+
# formatting failure messages.
|
|
8
|
+
module MatcherHelpers
|
|
9
|
+
# Normalizes a list of exception entries into unique, non-blank strings.
|
|
10
|
+
#
|
|
11
|
+
# @param entries [Array<String>, String, nil] raw exception entries
|
|
12
|
+
# @return [Array<String>] deduplicated, stripped, non-empty strings
|
|
13
|
+
def normalize_exception_entries(entries)
|
|
14
|
+
Array(entries).flatten.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Extracts identifying details from a declaration item.
|
|
18
|
+
#
|
|
19
|
+
# @param item [Object] a declaration object (e.g., FileDeclaration, ClassDeclaration)
|
|
20
|
+
# @return [Hash{Symbol => String, nil}] hash with :name, :class_name, :file_path, :line
|
|
21
|
+
def item_details(item)
|
|
22
|
+
{
|
|
23
|
+
name: item.respond_to?(:name) ? item.name : nil,
|
|
24
|
+
class_name: item.respond_to?(:class_name) ? item.class_name : nil,
|
|
25
|
+
file_path: item.respond_to?(:file_path) ? item.file_path : 'Unknown file',
|
|
26
|
+
line: item.respond_to?(:line) ? item.line : nil
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Returns a list of unique identifier strings for an item, used for matching.
|
|
31
|
+
#
|
|
32
|
+
# @param item [Object] a declaration object
|
|
33
|
+
# @return [Array<String>] identifiers such as name, class name, file path, and file:line
|
|
34
|
+
def item_identifiers(item)
|
|
35
|
+
details = item_details(item)
|
|
36
|
+
identifiers = [details[:name], details[:class_name], details[:file_path]]
|
|
37
|
+
|
|
38
|
+
if details[:line]
|
|
39
|
+
identifiers << "#{details[:file_path]}:#{details[:line]}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
identifiers.compact.uniq
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Checks whether a given exception entry string matches an item.
|
|
46
|
+
#
|
|
47
|
+
# @param entry [String] an allowlist or baseline entry
|
|
48
|
+
# @param item [Object] a declaration object
|
|
49
|
+
# @return [Boolean] true if the entry matches the item by name, class, or path
|
|
50
|
+
def exception_entry_matches_item?(entry, item)
|
|
51
|
+
normalized_entry = entry.to_s.strip
|
|
52
|
+
return false if normalized_entry.empty?
|
|
53
|
+
|
|
54
|
+
details = item_details(item)
|
|
55
|
+
return true if item_identifiers(item).include?(normalized_entry)
|
|
56
|
+
|
|
57
|
+
file_path = details[:file_path]
|
|
58
|
+
file_path && (file_path.end_with?(normalized_entry) || file_path.end_with?("/#{normalized_entry}"))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Classifies items into violations, baseline matches, allowlist matches,
|
|
62
|
+
# and detects stale entries in either list.
|
|
63
|
+
#
|
|
64
|
+
# @param subject_collection [Array, Object] items to classify
|
|
65
|
+
# @param allowlist [Array<String>, nil] allowed exception entries
|
|
66
|
+
# @param baseline [Array<String>, nil] baseline exception entries
|
|
67
|
+
# @return [Hash{Symbol => Array<String>}] keys: :violations, :baseline, :allowlist,
|
|
68
|
+
# :stale_baseline, :stale_allowlist
|
|
69
|
+
def classify_items(subject_collection, allowlist: nil, baseline: nil)
|
|
70
|
+
items = Array(subject_collection).compact
|
|
71
|
+
normalized_allowlist = normalize_exception_entries(allowlist)
|
|
72
|
+
normalized_baseline = normalize_exception_entries(baseline)
|
|
73
|
+
matched_baseline_entries = []
|
|
74
|
+
matched_allowlist_entries = []
|
|
75
|
+
|
|
76
|
+
grouped_items = items.group_by do |item|
|
|
77
|
+
matching_baseline_entry = normalized_baseline.find do |entry|
|
|
78
|
+
exception_entry_matches_item?(entry, item)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
if matching_baseline_entry
|
|
82
|
+
matched_baseline_entries << matching_baseline_entry
|
|
83
|
+
:baseline
|
|
84
|
+
else
|
|
85
|
+
matching_allowlist_entry = normalized_allowlist.find do |entry|
|
|
86
|
+
exception_entry_matches_item?(entry, item)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if matching_allowlist_entry
|
|
90
|
+
matched_allowlist_entries << matching_allowlist_entry
|
|
91
|
+
:allowlist
|
|
92
|
+
else
|
|
93
|
+
:violations
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
classifications = {
|
|
99
|
+
baseline: Array(grouped_items[:baseline]).map { |item| element_name(item) },
|
|
100
|
+
allowlist: Array(grouped_items[:allowlist]).map { |item| element_name(item) },
|
|
101
|
+
violations: Array(grouped_items[:violations]).map { |item| element_name(item) }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
classifications.merge(
|
|
105
|
+
stale_baseline: normalized_baseline - matched_baseline_entries.uniq,
|
|
106
|
+
stale_allowlist: normalized_allowlist - matched_allowlist_entries.uniq
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Formats a human-readable description of an item for failure messages.
|
|
111
|
+
#
|
|
112
|
+
# @param item [Object] a declaration object
|
|
113
|
+
# @return [String] formatted multi-line description
|
|
114
|
+
def element_name(item)
|
|
115
|
+
details = item_details(item)
|
|
116
|
+
location = [details[:file_path], details[:line]].compact.join(':')
|
|
117
|
+
|
|
118
|
+
case
|
|
119
|
+
when details[:name] && details[:class_name]
|
|
120
|
+
" - element: #{details[:name]}\n - class: #{details[:class_name]}\n - file: #{location}"
|
|
121
|
+
when details[:name]
|
|
122
|
+
" - element: #{details[:name]}\n - file: #{location}"
|
|
123
|
+
when details[:class_name]
|
|
124
|
+
" - class: #{details[:class_name]}\n - file: #{location}"
|
|
125
|
+
else
|
|
126
|
+
" - unknown element in #{location}"
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Builds a formatted string of violations and stale entries for failure output.
|
|
131
|
+
#
|
|
132
|
+
# @return [String, nil] formatted sections or nil if no classified items
|
|
133
|
+
def formatted_matcher_groups
|
|
134
|
+
return unless defined?(@classified_items) && @classified_items
|
|
135
|
+
|
|
136
|
+
sections = []
|
|
137
|
+
|
|
138
|
+
if @classified_items[:violations].any?
|
|
139
|
+
sections << "Violations:\n#{@classified_items[:violations].join("\n")}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
if @classified_items[:stale_baseline].any?
|
|
143
|
+
stale_entries = @classified_items[:stale_baseline].map { |entry| " - #{entry}" }
|
|
144
|
+
sections << "Stale baseline entries:\n#{stale_entries.join("\n")}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
if @classified_items[:stale_allowlist].any?
|
|
148
|
+
stale_entries = @classified_items[:stale_allowlist].map { |entry| " - #{entry}" }
|
|
149
|
+
sections << "Stale allowlist entries:\n#{stale_entries.join("\n")}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
sections.join("\n")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def self.included(base)
|
|
156
|
+
base.define_method(:message_for_failure) do |base_message|
|
|
157
|
+
return @failure_message if @failure_message
|
|
158
|
+
|
|
159
|
+
details = formatted_matcher_groups
|
|
160
|
+
|
|
161
|
+
if @custom_message
|
|
162
|
+
if details && !details.empty?
|
|
163
|
+
"#{@custom_message}\n#{details}"
|
|
164
|
+
else
|
|
165
|
+
@custom_message
|
|
166
|
+
end
|
|
167
|
+
elsif details && !details.empty?
|
|
168
|
+
"#{base_message}\n#{details}"
|
|
169
|
+
else
|
|
170
|
+
base_message
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Custom RSpec matcher that asserts a Rubyzen collection is empty.
|
|
2
|
+
#
|
|
3
|
+
# Used in architectural lint rules to verify that no items match
|
|
4
|
+
# a forbidden pattern (e.g., no controllers call +.where+ directly).
|
|
5
|
+
#
|
|
6
|
+
# @example Ensure no controllers use .where
|
|
7
|
+
# expect(controllers.all_methods.call_sites.with_name('where')).to zen_empty
|
|
8
|
+
#
|
|
9
|
+
# @example With a custom failure message
|
|
10
|
+
# expect(violations).to zen_empty("Controllers should not call .where directly")
|
|
11
|
+
RSpec::Matchers.define :zen_empty do |custom_message=nil, allowlist: nil, baseline: nil|
|
|
12
|
+
include Rubyzen::Matchers::MatcherHelpers
|
|
13
|
+
|
|
14
|
+
match do |subject_collection|
|
|
15
|
+
options = custom_message.is_a?(Hash) ? custom_message : {}
|
|
16
|
+
resolved_allowlist = allowlist || options[:allowlist] || options['allowlist']
|
|
17
|
+
resolved_baseline = baseline || options[:baseline] || options['baseline']
|
|
18
|
+
@custom_message = options[:message] || options['message'] || (custom_message unless custom_message.is_a?(Hash))
|
|
19
|
+
|
|
20
|
+
@classified_items = classify_items(
|
|
21
|
+
subject_collection,
|
|
22
|
+
allowlist: resolved_allowlist,
|
|
23
|
+
baseline: resolved_baseline
|
|
24
|
+
)
|
|
25
|
+
@offenders = @classified_items[:violations]
|
|
26
|
+
stale_exception_groups = []
|
|
27
|
+
stale_exception_groups << 'baseline entries' if @classified_items[:stale_baseline].any?
|
|
28
|
+
stale_exception_groups << 'allowlist entries' if @classified_items[:stale_allowlist].any?
|
|
29
|
+
|
|
30
|
+
@failure_reason = if @classified_items[:violations].any? && stale_exception_groups.any?
|
|
31
|
+
"Expected to be empty, but found live violations and stale #{stale_exception_groups.join(' and ')}."
|
|
32
|
+
elsif @classified_items[:violations].any?
|
|
33
|
+
if resolved_baseline || resolved_allowlist
|
|
34
|
+
'Expected to be empty, but found live violations.'
|
|
35
|
+
else
|
|
36
|
+
'Expected to be empty, but had elements.'
|
|
37
|
+
end
|
|
38
|
+
elsif stale_exception_groups.any?
|
|
39
|
+
"Expected to be empty, but found stale #{stale_exception_groups.join(' and ')}."
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
@classified_items[:violations].empty? &&
|
|
43
|
+
@classified_items[:stale_baseline].empty? &&
|
|
44
|
+
@classified_items[:stale_allowlist].empty?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
failure_message do |_|
|
|
48
|
+
message_for_failure(@failure_reason || 'Expected to be empty, but had elements.')
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
failure_message_when_negated do |_|
|
|
52
|
+
message_for_failure('Expected not to be empty, but had no elements.')
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Custom RSpec matcher that asserts a block returns false for every item in a collection.
|
|
2
|
+
#
|
|
3
|
+
# Supports +allowlist:+ and +baseline:+ for gradual adoption, matching items
|
|
4
|
+
# where the block returns true against exception lists.
|
|
5
|
+
#
|
|
6
|
+
# @example Ensure no methods have more than 5 parameters
|
|
7
|
+
# expect(methods).to zen_false { |m| m.parameters.size > 5 }
|
|
8
|
+
#
|
|
9
|
+
# @example With a custom failure message
|
|
10
|
+
# expect(controllers.all_methods.call_sites).to zen_false("Controllers must not call .where directly") { |cs| cs.name == 'where' }
|
|
11
|
+
#
|
|
12
|
+
# @example With a baseline for gradual adoption
|
|
13
|
+
# expect(classes).to zen_false(baseline: ['LegacyModel']) { |k| k.lines_of_code > 200 }
|
|
14
|
+
RSpec::Matchers.define :zen_false do |custom_message=nil, allowlist: nil, baseline: nil|
|
|
15
|
+
include Rubyzen::Matchers::MatcherHelpers
|
|
16
|
+
|
|
17
|
+
match do |subject_collection|
|
|
18
|
+
options = custom_message.is_a?(Hash) ? custom_message : {}
|
|
19
|
+
resolved_allowlist = allowlist || options[:allowlist] || options['allowlist']
|
|
20
|
+
resolved_baseline = baseline || options[:baseline] || options['baseline']
|
|
21
|
+
@custom_message = options[:message] || options['message'] || (custom_message unless custom_message.is_a?(Hash))
|
|
22
|
+
@offenders = []
|
|
23
|
+
|
|
24
|
+
if block_arg != nil
|
|
25
|
+
items = Array(subject_collection)
|
|
26
|
+
|
|
27
|
+
failing_items = items.filter { |item| block_arg.call(item) }
|
|
28
|
+
@classified_items = classify_items(
|
|
29
|
+
failing_items,
|
|
30
|
+
allowlist: resolved_allowlist,
|
|
31
|
+
baseline: resolved_baseline
|
|
32
|
+
)
|
|
33
|
+
@offenders = @classified_items[:violations]
|
|
34
|
+
|
|
35
|
+
stale_exception_groups = []
|
|
36
|
+
stale_baseline = @classified_items[:stale_baseline]
|
|
37
|
+
stale_allowlist = @classified_items[:stale_allowlist]
|
|
38
|
+
stale_exception_groups << 'baseline entries' if stale_baseline.any?
|
|
39
|
+
stale_exception_groups << 'allowlist entries' if stale_allowlist.any?
|
|
40
|
+
|
|
41
|
+
@failure_reason = if @offenders.any? && stale_exception_groups.any?
|
|
42
|
+
"Expected to return false for all elements, but found live violations and stale #{stale_exception_groups.join(' and ')}."
|
|
43
|
+
elsif @offenders.any?
|
|
44
|
+
"Expected to return false for all elements."
|
|
45
|
+
elsif stale_exception_groups.any?
|
|
46
|
+
"Expected to return false for all elements, but found stale #{stale_exception_groups.join(' and ')}."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@offenders.empty? && stale_baseline.empty? && stale_allowlist.empty?
|
|
50
|
+
else
|
|
51
|
+
@failure_reason = "Expected a block, but got nil."
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
failure_message do |_|
|
|
57
|
+
message_for_failure(@failure_reason || "Expected to return false for all elements.")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
failure_message_when_negated do |_|
|
|
61
|
+
message_for_failure("Expected to return true for at least one element, but all elements returned false.")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# Custom RSpec matcher that asserts a block returns true for every item in a collection.
|
|
2
|
+
#
|
|
3
|
+
# @example Ensure all methods have parameters
|
|
4
|
+
# expect(methods).to zen_true { |m| m.parameters? }
|
|
5
|
+
#
|
|
6
|
+
# @example With a custom failure message
|
|
7
|
+
# expect(services).to zen_true("All services must inherit from BaseService") { |s| s.superclass_name == 'BaseService' }
|
|
8
|
+
RSpec::Matchers.define :zen_true do |custom_message=nil, allowlist: nil, baseline: nil|
|
|
9
|
+
include Rubyzen::Matchers::MatcherHelpers
|
|
10
|
+
|
|
11
|
+
match do |subject_collection|
|
|
12
|
+
options = custom_message.is_a?(Hash) ? custom_message : {}
|
|
13
|
+
resolved_allowlist = allowlist || options[:allowlist] || options['allowlist']
|
|
14
|
+
resolved_baseline = baseline || options[:baseline] || options['baseline']
|
|
15
|
+
@custom_message = options[:message] || options['message'] || (custom_message unless custom_message.is_a?(Hash))
|
|
16
|
+
@offenders = []
|
|
17
|
+
|
|
18
|
+
if block_arg != nil
|
|
19
|
+
items = Array(subject_collection) # to handle one or multiple subjects
|
|
20
|
+
|
|
21
|
+
failing_items = items.filter { |item| !block_arg.call(item) }
|
|
22
|
+
@classified_items = classify_items(
|
|
23
|
+
failing_items,
|
|
24
|
+
allowlist: resolved_allowlist,
|
|
25
|
+
baseline: resolved_baseline
|
|
26
|
+
)
|
|
27
|
+
@offenders = @classified_items[:violations]
|
|
28
|
+
|
|
29
|
+
stale_exception_groups = []
|
|
30
|
+
stale_baseline = @classified_items[:stale_baseline]
|
|
31
|
+
stale_allowlist = @classified_items[:stale_allowlist]
|
|
32
|
+
stale_exception_groups << 'baseline entries' if stale_baseline.any?
|
|
33
|
+
stale_exception_groups << 'allowlist entries' if stale_allowlist.any?
|
|
34
|
+
|
|
35
|
+
@failure_reason = if @offenders.any? && stale_exception_groups.any?
|
|
36
|
+
"Expected to return true for all elements, but found live violations and stale #{stale_exception_groups.join(' and ')}."
|
|
37
|
+
elsif @offenders.any?
|
|
38
|
+
"Expected to return true for all elements."
|
|
39
|
+
elsif stale_exception_groups.any?
|
|
40
|
+
"Expected to return true for all elements, but found stale #{stale_exception_groups.join(' and ')}."
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
@offenders.empty? && stale_baseline.empty? && stale_allowlist.empty?
|
|
44
|
+
else
|
|
45
|
+
@failure_reason = "Expected a block, but got nil."
|
|
46
|
+
false
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
failure_message do |_|
|
|
51
|
+
message_for_failure(@failure_reason || "Expected to return true for all elements.")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
failure_message_when_negated do |_|
|
|
55
|
+
message_for_failure("Expected to return false for at least one element, but all elements returned true.")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
require 'rubocop-ast'
|
|
2
|
+
|
|
3
|
+
module Rubyzen
|
|
4
|
+
module Parsers
|
|
5
|
+
# Singleton parser that converts Ruby source files into Rubyzen declarations
|
|
6
|
+
# using RuboCop's AST processing. Results are cached via {Cache::ParseCache}.
|
|
7
|
+
class ASTParser
|
|
8
|
+
# Returns the singleton instance of the parser.
|
|
9
|
+
#
|
|
10
|
+
# @return [ASTParser]
|
|
11
|
+
def self.instance
|
|
12
|
+
@instance ||= new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@cache = Rubyzen::Cache::ParseCache.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Parses a Ruby source file and returns its declaration, using the cache.
|
|
20
|
+
#
|
|
21
|
+
# @param file_path [String] absolute path to the Ruby file
|
|
22
|
+
# @return [Declarations::FileDeclaration, nil] the parsed file declaration, or nil if unparseable
|
|
23
|
+
def parse_file(file_path)
|
|
24
|
+
@cache.fetch_or_parse(file_path) do
|
|
25
|
+
source = File.read(file_path)
|
|
26
|
+
processed_source = RuboCop::AST::ProcessedSource.new(source, RUBY_VERSION.to_f, file_path)
|
|
27
|
+
next nil unless processed_source.ast
|
|
28
|
+
Rubyzen::Declarations::FileDeclaration.new(file_path, processed_source.ast)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
# Main entry point for analyzing a Ruby project. Parses all +.rb+ files
|
|
3
|
+
# in the given paths and provides access to files, classes, and modules.
|
|
4
|
+
#
|
|
5
|
+
# @example Analyzing specific directories
|
|
6
|
+
# project = Rubyzen::Project.new(["app/models", "app/controllers"])
|
|
7
|
+
# project.files.with_paths("controllers/").classes
|
|
8
|
+
#
|
|
9
|
+
# @example Using auto-discovery
|
|
10
|
+
# project = Rubyzen::Project.new # scans app/, lib/, src/, spec/
|
|
11
|
+
# project.classes.with_name("UsersController")
|
|
12
|
+
#
|
|
13
|
+
class Project
|
|
14
|
+
# @param paths [String, Array<String>, nil] directories or file paths to analyze.
|
|
15
|
+
# Falls back to {Configuration#project_paths} (auto-discovery) if nil.
|
|
16
|
+
def initialize(paths = nil)
|
|
17
|
+
paths ||= Rubyzen.configuration.project_paths
|
|
18
|
+
@root_paths = Array(paths).map { |p| File.expand_path(p) }
|
|
19
|
+
|
|
20
|
+
@root_paths.each do |path|
|
|
21
|
+
unless File.exist?(path)
|
|
22
|
+
raise Rubyzen::Error, "Path does not exist: #{path}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
@file_paths = @root_paths.flat_map do |path|
|
|
27
|
+
if File.directory?(path)
|
|
28
|
+
Dir[File.join(path, '**', '*.rb')]
|
|
29
|
+
else
|
|
30
|
+
[path]
|
|
31
|
+
end
|
|
32
|
+
end.uniq
|
|
33
|
+
|
|
34
|
+
@parser = Rubyzen::Parsers::ASTParser.instance
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Returns all parsed files as a filterable collection.
|
|
38
|
+
#
|
|
39
|
+
# @return [Collections::FileCollection]
|
|
40
|
+
def files
|
|
41
|
+
all_files = file_declarations
|
|
42
|
+
Collections::FileCollection.new(all_files)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns all classes found across all parsed files.
|
|
46
|
+
#
|
|
47
|
+
# @return [Collections::ClassesCollection]
|
|
48
|
+
def classes
|
|
49
|
+
all_classes = file_declarations.flat_map(&:classes)
|
|
50
|
+
Collections::ClassesCollection.new(all_classes)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns all modules found across all parsed files.
|
|
54
|
+
#
|
|
55
|
+
# @return [Collections::ModulesCollection]
|
|
56
|
+
def modules
|
|
57
|
+
all_modules = file_declarations.flat_map(&:modules)
|
|
58
|
+
Collections::ModulesCollection.new(all_modules)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def file_declarations
|
|
64
|
+
@file_declarations ||= @file_paths.map do |file_path|
|
|
65
|
+
@parser.parse_file(file_path)
|
|
66
|
+
end.compact
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
module Providers
|
|
3
|
+
# Provides access to attr_reader, attr_writer, and attr_accessor declarations.
|
|
4
|
+
module AttributesProvider
|
|
5
|
+
# @return [Rubyzen::Collections::AttributesCollection] collection of attribute declarations
|
|
6
|
+
def attributes
|
|
7
|
+
attribute_nodes = node.each_descendant(:send).select do |send_node|
|
|
8
|
+
%w[attr_reader attr_writer attr_accessor].include?(send_node.method_name.to_s)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
attribute_declarations = attribute_nodes.map do |attr_node|
|
|
12
|
+
Rubyzen::Declarations::AttributeDeclaration.new(attr_node, self)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
Rubyzen::Collections::AttributesCollection.new(attribute_declarations)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
module Providers
|
|
3
|
+
# Provides access to block expressions (do..end / {..}) within a declaration.
|
|
4
|
+
module BlocksProvider
|
|
5
|
+
# @return [Rubyzen::Collections::BlocksCollection] collection of block declarations
|
|
6
|
+
def blocks
|
|
7
|
+
Collections::BlocksCollection.new(
|
|
8
|
+
node.each_descendant(:block).map do |block_node|
|
|
9
|
+
Declarations::BlockDeclaration.new(block_node, self)
|
|
10
|
+
end
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
module Providers
|
|
3
|
+
# Provides access to method call sites within a declaration.
|
|
4
|
+
module CallSiteProvider
|
|
5
|
+
# @return [Rubyzen::Collections::CallSiteCollection] collection of call site declarations
|
|
6
|
+
def call_sites
|
|
7
|
+
Collections::CallSiteCollection.new(
|
|
8
|
+
node.each_descendant(:send).map do |send_node|
|
|
9
|
+
Declarations::CallSiteDeclaration.new(send_node, self)
|
|
10
|
+
end
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Rubyzen
|
|
2
|
+
module Providers
|
|
3
|
+
# Provides access to the enclosing class name by traversing parent declarations.
|
|
4
|
+
module ClassNameProvider
|
|
5
|
+
# @return [String, nil] the name of the enclosing class, or nil if not within a class
|
|
6
|
+
def class_name
|
|
7
|
+
class_name_recursive(self)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def class_name_recursive(declaration)
|
|
13
|
+
return if declaration.nil?
|
|
14
|
+
return nil if declaration.is_a?(Rubyzen::Declarations::FileDeclaration)
|
|
15
|
+
return declaration.name if declaration.is_a?(Rubyzen::Declarations::ClassDeclaration)
|
|
16
|
+
|
|
17
|
+
if declaration.is_a?(Rubyzen::Declarations::ModuleDeclaration)
|
|
18
|
+
return module_class_name(declaration)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class_name_recursive(parent_declaration(declaration))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def module_class_name(declaration)
|
|
25
|
+
parent_class_name = class_name_recursive(parent_declaration(declaration))
|
|
26
|
+
parent_class_name || declaration.name
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def parent_declaration(declaration)
|
|
30
|
+
return unless declaration.respond_to?(:parent)
|
|
31
|
+
|
|
32
|
+
declaration.parent
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|