kaizo 0.7.0 → 0.9.2

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.
@@ -9,12 +9,34 @@ module RuboCop
9
9
  # `context`/`it` description, a clearer example name, or another example --
10
10
  # not prose riding alongside the code.
11
11
  #
12
- # By default only `*_spec.rb` files are inspected (see `Include`). Magic
12
+ # By default only `*_spec.rb` files are inspected, and `spec/helpers` and
13
+ # `spec/support` are excluded -- they hold infrastructure, not specs. Magic
13
14
  # comments (`# frozen_string_literal: true`, `# encoding: ...`), RuboCop
14
15
  # directives (any `# rubocop:` comment), and shebangs are
15
16
  # never flagged; add further exemptions with `AllowedPatterns`. There is no
16
17
  # autocorrection: turning an explanation into a spec is a design decision.
17
18
  #
19
+ # == Configuration
20
+ #
21
+ # [+Include+] Files the cop runs on. Default: <tt>**/*_spec.rb</tt>.
22
+ # Broaden it to cover support files or a Minitest suite.
23
+ # [+Exclude+] Files the cop skips even when included. Default:
24
+ # <tt>**/spec/helpers/**/*</tt> and
25
+ # <tt>**/spec/support/**/*</tt>. Set it to <tt>[]</tt> to
26
+ # inspect those too.
27
+ # [+AllowedPatterns+] Regexps matched against the full comment text,
28
+ # leading +#+ included; a match is exempt.
29
+ # Default: none.
30
+ #
31
+ # Kaizo/SpecComment:
32
+ # inherit_mode:
33
+ # merge:
34
+ # - Include
35
+ # Include:
36
+ # - '**/*_test.rb' # Minitest too
37
+ # AllowedPatterns:
38
+ # - '\A#\s*@rbs' # rbs-inline type annotations
39
+ #
18
40
  # @example
19
41
  # # bad
20
42
  # it 'permits the request' do
@@ -5,16 +5,19 @@ module RuboCop
5
5
  # specifications, after the spec-skeleton naming law.
6
6
  #
7
7
  # An `it`/`specify`/`example` description must not contain a comma (a list
8
- # is several behaviors), a coordinating or conditional conjunction
9
- # (`Conjunctions` -- joined clauses are separate examples; a condition
10
- # belongs in a `context`), or code (`_ : # = { } ! [ ]`, a backtick, or a
11
- # nested quoted literal -- a description is prose, not identifiers or wire
12
- # values). Each rule is structural: it signals that one example is really
13
- # more than one, or that the assertion is leaking into the name.
8
+ # is several behaviors), a forbidden word (`ForbiddenWords` -- conjunctions
9
+ # join clauses that are separate examples; a conditional signals a hidden
10
+ # `context`), or code (`_ : # = { } ! [ ]`, a backtick, or a nested quoted
11
+ # literal -- a description is prose, not identifiers or wire values). Each
12
+ # rule is structural: it signals that one example is really more than one,
13
+ # or that the assertion is leaking into the name.
14
14
  #
15
15
  # A `context` description must not contain code, and must open with one of
16
- # `ContextPrefixes` (`when`/`with`/`without`/`after`). `describe` strings
17
- # name the unit under test and are exempt.
16
+ # `RequiredContextPrefixes` (`when`/`with`/`without`/`after`). `describe`
17
+ # strings name the unit under test and are exempt. An error class name
18
+ # (`Foo::Error`, `Timeout::DeadlineException`) reads as prose, not code:
19
+ # the error is part of the specified behavior and is what the user
20
+ # ultimately sees raised.
18
21
  #
19
22
  # Wording preferences that do not change the spec's structure (e.g. `should`
20
23
  # vs a present-tense verb) are out of scope -- see rubocop-rspec's
@@ -23,6 +26,32 @@ module RuboCop
23
26
  # There is no autocorrection: splitting an example, or extracting a
24
27
  # condition into a `context`, is a modelling decision for a human.
25
28
  #
29
+ # == Configuration
30
+ #
31
+ # [+ForbiddenWords+] Words that force a split when they appear in an
32
+ # example description, matched as whole words, case
33
+ # insensitively. Default: +and+, +but+, +or+, +nor+,
34
+ # +so+, +yet+, +when+, +whenever+, +if+, +unless+,
35
+ # +while+, +until+, +because+, +although+, +though+.
36
+ # [+RequiredContextPrefixes+] Words a `context` description may open
37
+ # with. Default: +when+, +with+, +without+,
38
+ # +after+.
39
+ # [+AllowedPatterns+] Regexps matched against the whole description; a
40
+ # match exempts it from every rule. The escape hatch
41
+ # for a description that must quote something
42
+ # code-shaped. Default: none.
43
+ # [+Include+] Files the cop runs on. Default: <tt>**/*_spec.rb</tt>.
44
+ #
45
+ # Kaizo/SpecDescriptionProse:
46
+ # inherit_mode:
47
+ # merge:
48
+ # - ForbiddenWords
49
+ # - AllowedPatterns
50
+ # ForbiddenWords:
51
+ # - given # flag `given ...` too
52
+ # AllowedPatterns:
53
+ # - 'Foo::Widget' # allow this one identifier
54
+ #
26
55
  # @example
27
56
  # # bad
28
57
  # it "renders the name, image, and flag"
@@ -39,6 +68,8 @@ module RuboCop
39
68
  # end
40
69
  #
41
70
  class SpecDescriptionProse < Base
71
+ include AllowedPattern
72
+
42
73
  EXAMPLE_METHODS = %i[
43
74
  it specify example fit xit fspecify xspecify fexample xexample
44
75
  ].freeze
@@ -46,8 +77,8 @@ module RuboCop
46
77
  RESTRICT_ON_SEND = (EXAMPLE_METHODS + CONTEXT_METHODS).freeze
47
78
 
48
79
  COMMA_MSG = "Split this example: its description contains a comma.".freeze
49
- CONJUNCTION_MSG = "Split this example: its description contains `%<word>s`; " \
50
- "use separate examples or a `context`.".freeze
80
+ FORBIDDEN_WORD_MSG = "Split this example: its description contains `%<word>s`; " \
81
+ "use separate examples or a `context`.".freeze
51
82
  CODE_MSG = "Write the description as prose; it contains code, not English.".freeze
52
83
  CONTEXT_CODE_MSG = "Write the context description as prose; it contains code, not English.".freeze
53
84
  CONTEXT_PREFIX_MSG = "Begin the context description with %<prefixes>s.".freeze
@@ -57,15 +88,19 @@ module RuboCop
57
88
  # Homographs like `even`/`given`/`regardless` are deliberately absent --
58
89
  # they collide with adjectives/nouns (`even numbers`) -- add them via
59
90
  # config if you want them.
60
- DEFAULT_CONJUNCTIONS = %w[
91
+ DEFAULT_FORBIDDEN_WORDS = %w[
61
92
  and but or nor so yet
62
93
  when whenever if unless while until because although though
63
94
  ].freeze
64
- DEFAULT_CONTEXT_PREFIXES = %w[when with without after].freeze
95
+ DEFAULT_REQUIRED_CONTEXT_PREFIXES = %w[when with without after].freeze
65
96
 
66
97
  CODE_CHARS = /[_:#={}!`\[\]]/
67
98
  NESTED_QUOTE = /(['"]).+\1/
68
99
 
100
+ # An error class name is part of the specified behavior -- it is what
101
+ # the user sees raised -- so it is masked to prose before the checks.
102
+ ERROR_CONSTANT = /(?:::)?\b(?:[A-Z]\w*::)*(?:[A-Z]\w*)?(?:Error|Exception)\b/
103
+
69
104
  # @!method description(node)
70
105
  def_node_matcher :description, <<~PATTERN
71
106
  (send nil? _ (str $_) ...)
@@ -74,15 +109,20 @@ module RuboCop
74
109
  def on_send(node)
75
110
  text = description(node)
76
111
  return unless text
112
+ return if matches_allowed_pattern?(text)
77
113
 
78
- message = violation(node.method_name, text)
114
+ message = violation(node.method_name, without_error_constants(text))
79
115
  return unless message
80
116
 
81
- add_offense(node.first_argument, message: message)
117
+ add_offense(node.first_argument, message:)
82
118
  end
83
119
 
84
120
  private
85
121
 
122
+ def without_error_constants(text)
123
+ text.gsub(ERROR_CONSTANT, "error")
124
+ end
125
+
86
126
  def violation(method, text)
87
127
  if EXAMPLE_METHODS.include?(method)
88
128
  example_violation(text)
@@ -94,8 +134,8 @@ module RuboCop
94
134
  def example_violation(text)
95
135
  return COMMA_MSG if text.include?(",")
96
136
 
97
- word = forbidden(text, conjunctions)
98
- return format(CONJUNCTION_MSG, word: word) if word
137
+ word = forbidden(text, forbidden_words)
138
+ return format(FORBIDDEN_WORD_MSG, word:) if word
99
139
 
100
140
  CODE_MSG if code?(text)
101
141
  end
@@ -116,19 +156,19 @@ module RuboCop
116
156
  end
117
157
 
118
158
  def prefix_regexp
119
- /\A\s*(?:#{context_prefixes.map { |prefix| Regexp.escape(prefix) }.join("|")})\b/i
159
+ /\A\s*(?:#{required_context_prefixes.map { |prefix| Regexp.escape(prefix) }.join("|")})\b/i
120
160
  end
121
161
 
122
162
  def quoted_prefixes
123
- context_prefixes.map { |prefix| "`#{prefix}`" }.join("/")
163
+ required_context_prefixes.map { |prefix| "`#{prefix}`" }.join("/")
124
164
  end
125
165
 
126
- def conjunctions
127
- cop_config.fetch("Conjunctions", DEFAULT_CONJUNCTIONS)
166
+ def forbidden_words
167
+ cop_config.fetch("ForbiddenWords", DEFAULT_FORBIDDEN_WORDS)
128
168
  end
129
169
 
130
- def context_prefixes
131
- cop_config.fetch("ContextPrefixes", DEFAULT_CONTEXT_PREFIXES)
170
+ def required_context_prefixes
171
+ cop_config.fetch("RequiredContextPrefixes", DEFAULT_REQUIRED_CONTEXT_PREFIXES)
132
172
  end
133
173
  end
134
174
  end
@@ -0,0 +1,117 @@
1
+ module RuboCop
2
+ module Cop
3
+ module Kaizo
4
+ # Requires the unit under test to be declared with `subject`, not `let`.
5
+ # `subject` is RSpec's name for the object being specified; hiding it in a
6
+ # `let` obscures which object the examples are about and forfeits
7
+ # `is_expected`/one-liner syntax.
8
+ #
9
+ # A `let` (or `let!`) is flagged when the value it returns is confidently
10
+ # an instance of the class under test: the block's final expression is a
11
+ # `.new` call on `described_class`, on the constant named by an enclosing
12
+ # `describe`/`context` (full or short name), or on a constant matching the
13
+ # spec's file name (`pool_spec.rb` names `Pool`, `api_client_spec.rb`
14
+ # names `APIClient`).
15
+ #
16
+ # A `let` that builds a second instance on purpose -- an `other` for an
17
+ # equality spec, say -- is exempted through `AllowedMethods` or
18
+ # `AllowedPatterns`, both matched against the `let` name.
19
+ #
20
+ # There is no autocorrection: renaming the helper every example refers to
21
+ # is a change the spec's author should make deliberately.
22
+ #
23
+ # == Configuration
24
+ #
25
+ # [+AllowedMethods+] `let` names never flagged. Default: none.
26
+ # [+AllowedPatterns+] Regexps matched against the `let` name; a match is
27
+ # exempt. Default: none.
28
+ # [+Include+] Files the cop runs on. Default: <tt>**/*_spec.rb</tt>.
29
+ #
30
+ # Kaizo/SpecSubject:
31
+ # AllowedMethods:
32
+ # - other # a second instance for equality specs
33
+ # AllowedPatterns:
34
+ # - '\Aother_'
35
+ #
36
+ # @example
37
+ # # bad
38
+ # RSpec.describe Session::Pool do
39
+ # let(:pool) { described_class.new }
40
+ # end
41
+ #
42
+ # # good
43
+ # RSpec.describe Session::Pool do
44
+ # subject(:pool) { described_class.new }
45
+ # end
46
+ #
47
+ # @example AllowedMethods: ['other'] (default: [])
48
+ # # good - a deliberate second instance
49
+ # RSpec.describe Session::Pool do
50
+ # subject(:pool) { described_class.new }
51
+ #
52
+ # let(:other) { described_class.new }
53
+ # end
54
+ #
55
+ class SpecSubject < Base
56
+ include AllowedMethods
57
+ include AllowedPattern
58
+
59
+ MSG = "Declare the unit under test with `subject(:%<name>s)`, not `let`.".freeze
60
+
61
+ # @!method let_declaration(node)
62
+ def_node_matcher :let_declaration, <<~PATTERN
63
+ (block (send nil? {:let :let!} (sym $_)) _ $_)
64
+ PATTERN
65
+
66
+ # @!method constructed_class(node)
67
+ def_node_matcher :constructed_class, <<~PATTERN
68
+ (send ${(send nil? :described_class) (const _ _)} :new ...)
69
+ PATTERN
70
+
71
+ # @!method described_constant(node)
72
+ def_node_matcher :described_constant, <<~PATTERN
73
+ (block (send {(const {nil? cbase} :RSpec) nil?} {:describe :context} $(const ...) ...) ...)
74
+ PATTERN
75
+
76
+ def on_block(node)
77
+ name, body = let_declaration(node)
78
+ return unless name
79
+ return if allowed_method?(name) || matches_allowed_pattern?(name.to_s)
80
+ return unless unit_under_test?(node, final_expression(body))
81
+
82
+ add_offense(node.send_node.loc.selector, message: format(MSG, name:))
83
+ end
84
+
85
+ private
86
+
87
+ def final_expression(body)
88
+ body&.begin_type? ? body.children.last : body
89
+ end
90
+
91
+ def unit_under_test?(node, expression)
92
+ receiver = expression && constructed_class(expression)
93
+ return false unless receiver
94
+ return true if receiver.send_type?
95
+
96
+ described?(node, receiver)
97
+ end
98
+
99
+ def described?(node, const_node)
100
+ described_constants(node).any? do |described|
101
+ described.const_name == const_node.const_name ||
102
+ described.short_name == const_node.short_name
103
+ end || file_named_after?(const_node.short_name)
104
+ end
105
+
106
+ def described_constants(node)
107
+ node.each_ancestor(:block).filter_map { |ancestor| described_constant(ancestor) }
108
+ end
109
+
110
+ def file_named_after?(short_name)
111
+ base = processed_source.file_path&.[](%r{([^/]+)_spec\.rb\z}, 1)
112
+ base && short_name.to_s.downcase == base.delete("_")
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,71 @@
1
+ module RuboCop
2
+ module Cop
3
+ module Kaizo
4
+ # Requires temporary files to be created with block-form `Tempfile.create`,
5
+ # and flags `Tempfile.new`, `Tempfile.open`, and blockless `Tempfile.create`.
6
+ #
7
+ # Only the block form cleans up deterministically: the file is closed and
8
+ # removed when the block returns, however it returns. A `Tempfile` built
9
+ # with `.new` or `.open` is removed by a GC finalizer that runs at some
10
+ # unpredictable point -- possibly never -- and blockless `Tempfile.create`
11
+ # hands back a plain `File` that is never removed automatically at all.
12
+ #
13
+ # There is no autocorrection: moving the file's users into the block is a
14
+ # restructuring, and the block's return value replaces the handle the old
15
+ # code held onto.
16
+ #
17
+ # == Configuration
18
+ #
19
+ # No cop-specific options; the standard per-cop settings (+Enabled+,
20
+ # +Severity+, +Include+/+Exclude+) apply.
21
+ #
22
+ # @example
23
+ # # bad
24
+ # file = Tempfile.new("report")
25
+ # file = Tempfile.open("report")
26
+ # file = Tempfile.create("report")
27
+ #
28
+ # # good
29
+ # Tempfile.create("report") do |file|
30
+ # file.write(data)
31
+ # end
32
+ #
33
+ class TempfileCreate < Base
34
+ MSG = "Use `Tempfile.create` with a block instead of `Tempfile.%<method>s`; " \
35
+ "finalizer-based cleanup is unpredictable.".freeze
36
+ BLOCKLESS_CREATE_MSG = "Pass a block to `Tempfile.create`; " \
37
+ "without one the file is never removed.".freeze
38
+
39
+ RESTRICT_ON_SEND = %i[new open create].freeze
40
+
41
+ # @!method tempfile_call?(node)
42
+ def_node_matcher :tempfile_call?, <<~PATTERN
43
+ (send (const {nil? cbase} :Tempfile) _ ...)
44
+ PATTERN
45
+
46
+ def on_send(node)
47
+ return unless tempfile_call?(node)
48
+ return if node.method?(:create) && block_given_to?(node)
49
+
50
+ range = node.receiver.source_range.join(node.loc.selector)
51
+ add_offense(range, message: message_for(node))
52
+ end
53
+
54
+ private
55
+
56
+ def message_for(node)
57
+ return BLOCKLESS_CREATE_MSG if node.method?(:create)
58
+
59
+ format(MSG, method: node.method_name)
60
+ end
61
+
62
+ def block_given_to?(node)
63
+ return true if node.last_argument&.block_pass_type?
64
+
65
+ parent = node.parent
66
+ parent&.any_block_type? && parent.send_node.equal?(node)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -12,6 +12,24 @@ module RuboCop
12
12
  # `initialize` of a `Struct.new`/`Data.define` block is exempt, since those
13
13
  # parameters mirror the value object's attributes.
14
14
  #
15
+ # == Configuration
16
+ #
17
+ # [+Max+] Most arguments -- positional plus keyword -- a method may
18
+ # declare. Default: +2+.
19
+ # [+AllowedMethods+] Method names exempt from the limit. Default: none.
20
+ # [+AllowedPatterns+] Regexps matched against the method name; a match is
21
+ # exempt. Default: none.
22
+ # [+Exclude+] Paths the cop skips. Default: <tt>**/spec/**/*</tt> and
23
+ # <tt>**/test/**/*</tt>, matching `KeywordArguments` -- a
24
+ # total bound would re-police the keyword freedom tests get.
25
+ # `PositionalArguments` alone still bounds positional
26
+ # arguments there. Set it to <tt>[]</tt> to police tests too.
27
+ #
28
+ # Kaizo/TotalArguments:
29
+ # Max: 3
30
+ # AllowedMethods:
31
+ # - initialize
32
+ #
15
33
  # @example Max: 3
16
34
  # # bad
17
35
  # def calculate_volume(width, length, height, shape_type)
@@ -1,12 +1,15 @@
1
1
  require_relative "kaizo/argument_counting"
2
2
  require_relative "kaizo/keyword_arguments"
3
+ require_relative "kaizo/plural_collection_name"
3
4
  require_relative "kaizo/positional_arguments"
4
5
  require_relative "kaizo/total_arguments"
5
6
  require_relative "kaizo/agent_noun_class_name"
6
7
  require_relative "kaizo/nested_method_calls"
7
8
  require_relative "kaizo/spec_comment"
8
9
  require_relative "kaizo/spec_description_prose"
10
+ require_relative "kaizo/spec_subject"
9
11
  require_relative "kaizo/file_utils_inclusion"
10
12
  require_relative "kaizo/prefer_pathname"
11
13
  require_relative "kaizo/explicit_begin"
12
14
  require_relative "kaizo/next_in_non_void_enumerable"
15
+ require_relative "kaizo/tempfile_create"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kaizo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.0
4
+ version: 0.9.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Gillis
@@ -62,10 +62,13 @@ files:
62
62
  - lib/rubocop/cop/kaizo/keyword_arguments.rb
63
63
  - lib/rubocop/cop/kaizo/nested_method_calls.rb
64
64
  - lib/rubocop/cop/kaizo/next_in_non_void_enumerable.rb
65
+ - lib/rubocop/cop/kaizo/plural_collection_name.rb
65
66
  - lib/rubocop/cop/kaizo/positional_arguments.rb
66
67
  - lib/rubocop/cop/kaizo/prefer_pathname.rb
67
68
  - lib/rubocop/cop/kaizo/spec_comment.rb
68
69
  - lib/rubocop/cop/kaizo/spec_description_prose.rb
70
+ - lib/rubocop/cop/kaizo/spec_subject.rb
71
+ - lib/rubocop/cop/kaizo/tempfile_create.rb
69
72
  - lib/rubocop/cop/kaizo/total_arguments.rb
70
73
  - lib/rubocop/cop/kaizo_cops.rb
71
74
  homepage: https://github.com/flipmine/kaizo
@@ -90,7 +93,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
93
  - !ruby/object:Gem::Version
91
94
  version: '0'
92
95
  requirements: []
93
- rubygems_version: 4.0.16
96
+ rubygems_version: 4.0.18
94
97
  specification_version: 4
95
98
  summary: A strict, punishing set of RuboCop design cops for AI-agent-authored Ruby.
96
99
  test_files: []