rubocop-claude 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +25 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +267 -0
  5. data/config/default.yml +202 -0
  6. data/exe/rubocop-claude +7 -0
  7. data/lib/rubocop/cop/claude/explicit_visibility.rb +139 -0
  8. data/lib/rubocop/cop/claude/mystery_regex.rb +46 -0
  9. data/lib/rubocop/cop/claude/no_backwards_compat_hacks.rb +140 -0
  10. data/lib/rubocop/cop/claude/no_commented_code.rb +182 -0
  11. data/lib/rubocop/cop/claude/no_fancy_unicode.rb +173 -0
  12. data/lib/rubocop/cop/claude/no_hardcoded_line_numbers.rb +142 -0
  13. data/lib/rubocop/cop/claude/no_overly_defensive_code.rb +160 -0
  14. data/lib/rubocop/cop/claude/tagged_comments.rb +78 -0
  15. data/lib/rubocop-claude.rb +19 -0
  16. data/lib/rubocop_claude/cli.rb +246 -0
  17. data/lib/rubocop_claude/generator.rb +90 -0
  18. data/lib/rubocop_claude/init_wizard/hooks_installer.rb +127 -0
  19. data/lib/rubocop_claude/init_wizard/linter_configurer.rb +88 -0
  20. data/lib/rubocop_claude/init_wizard/preferences_gatherer.rb +94 -0
  21. data/lib/rubocop_claude/plugin.rb +34 -0
  22. data/lib/rubocop_claude/version.rb +5 -0
  23. data/rubocop-claude.gemspec +41 -0
  24. data/templates/cops/class-structure.md +58 -0
  25. data/templates/cops/disable-cops-directive.md +33 -0
  26. data/templates/cops/explicit-visibility.md +52 -0
  27. data/templates/cops/metrics.md +73 -0
  28. data/templates/cops/mystery-regex.md +54 -0
  29. data/templates/cops/no-backwards-compat-hacks.md +101 -0
  30. data/templates/cops/no-commented-code.md +74 -0
  31. data/templates/cops/no-fancy-unicode.md +72 -0
  32. data/templates/cops/no-hardcoded-line-numbers.md +70 -0
  33. data/templates/cops/no-overly-defensive-code.md +117 -0
  34. data/templates/cops/tagged-comments.md +74 -0
  35. data/templates/hooks/settings.local.json +15 -0
  36. data/templates/linting.md +81 -0
  37. metadata +183 -0
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Claude
6
+ # Enforces that long regexes are extracted to named constants.
7
+ #
8
+ # Complex regexes are hard to understand at a glance. Extracting them
9
+ # to a named constant with a descriptive name (and optionally a comment)
10
+ # makes the intent clear.
11
+ #
12
+ # @example MaxLength: 25 (default)
13
+ # # bad - what does this match?
14
+ # text.match?(/\A[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z/)
15
+ #
16
+ # # good - intent is clear from the name
17
+ # EMAIL_PATTERN = /\A[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z/
18
+ # text.match?(EMAIL_PATTERN)
19
+ #
20
+ # # good - short regexes are fine inline
21
+ # text.match?(/\A\d+\z/)
22
+ #
23
+ class MysteryRegex < Base
24
+ MSG = 'Extract long regex to a named constant. ' \
25
+ 'Complex patterns deserve descriptive names.'
26
+
27
+ def on_regexp(node)
28
+ return if node.content.length <= max_length
29
+ return if inside_constant_assignment?(node)
30
+
31
+ add_offense(node)
32
+ end
33
+
34
+ private
35
+
36
+ def max_length
37
+ @max_length ||= cop_config.fetch('MaxLength', 25)
38
+ end
39
+
40
+ def inside_constant_assignment?(node)
41
+ node.each_ancestor(:casgn).any?
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'set'
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Claude
8
+ # Detects patterns that preserve dead code for backwards compatibility.
9
+ #
10
+ # AI assistants often add "helpful" compatibility shims instead of
11
+ # cleanly removing code. This creates confusion and maintenance burden.
12
+ # If code is unused, delete it completely.
13
+ #
14
+ # == Detection Philosophy
15
+ #
16
+ # This cop catches *self-documented* compatibility hacks - patterns where
17
+ # the code includes comments like "for backwards compatibility" or markers
18
+ # like "# removed:". It won't catch silent compat hacks without comments.
19
+ #
20
+ # This is intentional. The goal is teaching, not comprehensive detection.
21
+ # When the cop fires, it's a teaching moment - the AI reads the guidance
22
+ # and learns the principle "don't preserve dead code." Over time, this
23
+ # shapes better habits even for cases we can't detect.
24
+ #
25
+ # @example Dead code marker comments (always flagged)
26
+ # # bad
27
+ # # removed: def old_method; end
28
+ # # deprecated: use new_method instead
29
+ # # legacy: keeping for backwards compat
30
+ # # backwards compatibility: aliased from OldClass
31
+ #
32
+ # # good - just delete the comment entirely
33
+ #
34
+ # @example Constant re-exports with compat comments (always flagged)
35
+ # # bad
36
+ # OldName = NewName # for backwards compatibility
37
+ #
38
+ # # bad
39
+ # # deprecated alias
40
+ # LegacyClass = ModernClass
41
+ #
42
+ # # good - delete the alias, update callers
43
+ #
44
+ # @example CheckUnderscoreAssignments: true (optional, off by default)
45
+ # # bad - underscore prefix to silence unused variable warning
46
+ # _old_method = :deprecated
47
+ # _unused = previous_value
48
+ #
49
+ # # good - delete the line entirely if not needed
50
+ #
51
+ # # ok - single underscore in blocks is idiomatic Ruby
52
+ # hash.each { |_, v| puts v }
53
+ #
54
+ class NoBackwardsCompatHacks < Base
55
+ MSG_UNDERSCORE = "Delete dead code. Don't use underscore prefix to preserve unused values."
56
+ MSG_REEXPORT = "Delete dead code. Don't re-export removed constants for backwards compatibility."
57
+ MSG_COMMENT = "Delete dead code. Don't leave removal markers in comments."
58
+
59
+ # Comments that indicate removed/deprecated code being preserved
60
+ DEAD_CODE_COMMENT_PATTERN = /\A#\s*(?:removed|deprecated|legacy|backwards?\s*compat(?:ibility)?|for\s+compat(?:ibility)?|compat(?:ibility)?\s+shim):/i
61
+
62
+ # Patterns for detecting backwards-compatibility comments near constant assignments
63
+ COMPAT_KEYWORD_PATTERN = /\b(?:backwards?\s*)?compat(?:ibility)?\b/i
64
+ LEGACY_REFERENCE_PATTERN = /\bfor\s+(?:legacy|old|previous)\b/i
65
+ DEPRECATED_PATTERN = /\bdeprecated\b/i
66
+ ALIAS_PATTERN = /\balias\s+for\b/i
67
+
68
+ # Assignment to underscore-prefixed variables (not just _ which is idiomatic for unused block args)
69
+ UNDERSCORE_ASSIGNMENT_MSG = 'Assignment to underscore-prefixed variable'
70
+
71
+ def on_new_investigation
72
+ build_compat_comment_index
73
+ check_dead_code_comments
74
+ end
75
+
76
+ # Detect _unused = value patterns (optional, off by default)
77
+ def on_lvasgn(node)
78
+ return unless check_underscore_assignments?
79
+
80
+ var_name = node.children[0].to_s
81
+ return unless var_name.start_with?('_') && var_name.length > 1
82
+
83
+ # Allow in block parameters context (common Ruby idiom)
84
+ return if in_block_arguments?(node)
85
+
86
+ add_offense(node, message: MSG_UNDERSCORE)
87
+ end
88
+
89
+ # Detect Constant = OtherConstant patterns with backwards compat comments
90
+ def on_casgn(node)
91
+ # Check for re-export patterns: OldName = NewName
92
+ _, _, value = *node
93
+ return unless value.const_type?
94
+
95
+ # Look for backwards compatibility indicators in nearby comments
96
+ return unless has_compat_comment_nearby?(node)
97
+
98
+ add_offense(node, message: MSG_REEXPORT)
99
+ end
100
+
101
+ private
102
+
103
+ def build_compat_comment_index
104
+ @compat_comment_lines = Set.new
105
+ processed_source.comments.each do |comment|
106
+ @compat_comment_lines << comment.location.line if compat_comment?(comment.text)
107
+ end
108
+ end
109
+
110
+ def check_dead_code_comments
111
+ processed_source.comments.each do |comment|
112
+ next unless comment.text.match?(DEAD_CODE_COMMENT_PATTERN)
113
+
114
+ add_offense(comment, message: MSG_COMMENT)
115
+ end
116
+ end
117
+
118
+ def in_block_arguments?(node)
119
+ node.each_ancestor(:block, :numblock).any?
120
+ end
121
+
122
+ def has_compat_comment_nearby?(node)
123
+ line = node.location.line
124
+ @compat_comment_lines.include?(line) || @compat_comment_lines.include?(line - 1)
125
+ end
126
+
127
+ def compat_comment?(text)
128
+ COMPAT_KEYWORD_PATTERN.match?(text) ||
129
+ LEGACY_REFERENCE_PATTERN.match?(text) ||
130
+ DEPRECATED_PATTERN.match?(text) ||
131
+ ALIAS_PATTERN.match?(text)
132
+ end
133
+
134
+ def check_underscore_assignments?
135
+ @check_underscore_assignments ||= cop_config.fetch('CheckUnderscoreAssignments', false)
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Claude
6
+ # Detects commented-out code blocks.
7
+ #
8
+ # Commented-out code is technical debt. It clutters the codebase,
9
+ # confuses readers, and version control already preserves history.
10
+ # Delete it instead of commenting it out.
11
+ #
12
+ # @example Default (flags consecutive commented code)
13
+ # # bad
14
+ # # def old_method
15
+ # # do_something
16
+ # # end
17
+ #
18
+ # @example AllowKeep: true (default) - explicit exceptions with attribution
19
+ # # good - KEEP comment with attribution
20
+ # # KEEP [@username]: Rollback path, remove after 2025-06
21
+ # # def legacy_method
22
+ # # old_implementation
23
+ # # end
24
+ #
25
+ # @safety
26
+ # Autocorrection deletes commented code. Review changes to ensure
27
+ # no important context is lost. Version control preserves history.
28
+ #
29
+ class NoCommentedCode < Base
30
+ extend AutoCorrector
31
+
32
+ MSG = 'Delete commented-out code instead of leaving it. Version control preserves history.'
33
+
34
+ # Patterns that strongly suggest commented-out Ruby code
35
+ CODE_PATTERNS = [
36
+ /\A\s*def\s+\w+/, # Method definitions
37
+ /\A\s*(?:class|module)\s+[A-Z]/, # Class/module definitions
38
+ /\A\s*(?:if|unless|case|while|until|begin|end)\b/, # Control flow
39
+ /\A\s*\w+\.\w+[!(?)]*\s*(?:\(|do\b|$)/, # Method calls with receiver
40
+ /\A\s*[a-z_]\w*[!(?)]\s*$/, # Bare method calls with ! or ?
41
+ /\A\s*[a-z_]\w+\s*$/, # Bare identifiers
42
+ %r{\A\s*(?:@{1,2}|\$)?\w+\s*[+\-*/]?=\s*.+}, # Assignments
43
+ /\A\s*(?:return|raise)\b/, # Return/raise statements
44
+ /\A\s*require(?:_relative)?\s+['"]/, # Require statements
45
+ /\A\s*\.\w+/, # Method chains
46
+ /\A\s*[A-Z][A-Z0-9_]*\s*=/ # Constants
47
+ ].freeze
48
+
49
+ # Patterns that suggest prose, not code
50
+ NON_CODE_PATTERNS = [
51
+ /\A\s*(?:TODO|FIXME|NOTE|HACK|OPTIMIZE|REVIEW)\b/i, # Annotations
52
+ /\A\s*[A-Z][a-z]+(?:\s+[a-z]+){3,}/, # Prose sentences
53
+ %r{\A\s*https?://}, # URLs
54
+ /\A\s*rubocop:/, # Rubocop directives
55
+ /\A\s*@(?:param|return|raise|see|note|deprecated)/ # YARD tags
56
+ ].freeze
57
+
58
+ YARD_EXAMPLE_START = /\A#\s*@example/
59
+ KEEP_PATTERN = /\A#\s*KEEP\s+\[(?:[\w\s]+-\s*)?@[\w-]+\]:/i
60
+
61
+ def on_new_investigation
62
+ @min_lines = cop_config.fetch('MinLines', 1)
63
+ @state = initial_state
64
+
65
+ processed_source.comments.each { |comment| process_comment(comment) }
66
+ report_pending_comments
67
+ end
68
+
69
+ private
70
+
71
+ def initial_state
72
+ {consecutive: [], in_yard_example: false, preceded_by_keep: false}
73
+ end
74
+
75
+ def process_comment(comment)
76
+ return if inline_comment?(comment)
77
+
78
+ raw_text = comment.text
79
+ return handle_yard_example_start if raw_text.match?(YARD_EXAMPLE_START)
80
+ return handle_keep_comment if allow_keep? && raw_text.match?(KEEP_PATTERN)
81
+ return if skip_yard_example_content?(raw_text)
82
+
83
+ accumulate_or_report(comment)
84
+ end
85
+
86
+ def handle_yard_example_start
87
+ report_and_reset
88
+ @state[:in_yard_example] = true
89
+ end
90
+
91
+ def handle_keep_comment
92
+ report_and_reset
93
+ @state[:preceded_by_keep] = true
94
+ end
95
+
96
+ def report_and_reset
97
+ report_pending_comments
98
+ @state[:consecutive] = []
99
+ @state[:preceded_by_keep] = false
100
+ end
101
+
102
+ def skip_yard_example_content?(raw_text)
103
+ return false unless @state[:in_yard_example]
104
+ return true if yard_example_content?(raw_text)
105
+
106
+ @state[:in_yard_example] = false
107
+ false
108
+ end
109
+
110
+ def accumulate_or_report(comment)
111
+ content = extract_content(comment)
112
+
113
+ if looks_like_code?(content)
114
+ @state[:consecutive] << comment
115
+ else
116
+ report_pending_comments
117
+ @state[:preceded_by_keep] = false
118
+ end
119
+ end
120
+
121
+ def report_pending_comments
122
+ comments = @state[:consecutive]
123
+ return if comments.length < @min_lines
124
+
125
+ # KEEP protection - clear without reporting
126
+ if @state[:preceded_by_keep]
127
+ @state[:consecutive] = []
128
+ return
129
+ end
130
+
131
+ add_offense(comments.first) { |corrector| remove_comment_block(corrector, comments) }
132
+ @state[:consecutive] = []
133
+ end
134
+
135
+ def yard_example_content?(raw_text)
136
+ return false if raw_text.match?(/\A#\s*@/)
137
+ return false if raw_text.match?(/\A#[^ ]/)
138
+ return false if raw_text.match?(/\A#\s?\S/) && !raw_text.match?(/\A#\s{2,}/)
139
+
140
+ raw_text.match?(/\A#\s{2,}/) || raw_text.match?(/\A#\s*$/)
141
+ end
142
+
143
+ def inline_comment?(comment)
144
+ line = processed_source.lines[comment.location.line - 1]
145
+ line[0...comment.location.column].match?(/\S/)
146
+ end
147
+
148
+ def extract_content(comment)
149
+ comment.text.sub(/\A#\s?/, '')
150
+ end
151
+
152
+ def looks_like_code?(content)
153
+ return false if content.strip.empty?
154
+ return false if NON_CODE_PATTERNS.any? { |p| content.match?(p) }
155
+
156
+ CODE_PATTERNS.any? { |p| content.match?(p) }
157
+ end
158
+
159
+ def remove_comment_block(corrector, comments)
160
+ source = comments.first.location.expression.source_buffer
161
+ begin_pos = source.line_range(comments.first.location.line).begin_pos
162
+ end_pos = calculate_end_pos(source, comments.last.location.line)
163
+
164
+ corrector.remove(Parser::Source::Range.new(source, begin_pos, end_pos))
165
+ end
166
+
167
+ def calculate_end_pos(source, last_line)
168
+ next_line = last_line + 1
169
+ if next_line <= source.last_line
170
+ source.line_range(next_line).begin_pos
171
+ else
172
+ source.line_range(last_line).end_pos
173
+ end
174
+ end
175
+
176
+ def allow_keep?
177
+ @allow_keep ||= cop_config.fetch('AllowKeep', true)
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Claude
6
+ # Detects non-standard Unicode characters that may cause issues.
7
+ #
8
+ # Flags characters outside the standard set of language characters
9
+ # and keyboard symbols. This catches emoji, fancy typography (curly
10
+ # quotes, em-dashes), and mathematical symbols that look like ASCII
11
+ # operators but aren't.
12
+ #
13
+ # The allowed character set is:
14
+ # - Letters from any script (Latin, Chinese, Japanese, Cyrillic, etc.)
15
+ # - Numbers from any script
16
+ # - Combining marks (for accented characters)
17
+ # - Standard ASCII printable characters (keyboard symbols)
18
+ # - Whitespace
19
+ #
20
+ # @safety
21
+ # This cop's autocorrection removes the offending character, which
22
+ # may change the meaning of strings. Review the changes carefully.
23
+ #
24
+ # @example AllowInStrings: false (default)
25
+ # # bad - emoji
26
+ # puts "Success! 🎉"
27
+ #
28
+ # # bad - fancy curly quotes
29
+ # puts "Hello world"
30
+ #
31
+ # # bad - mathematical symbols instead of ASCII
32
+ # return false if x ≠ y
33
+ #
34
+ # # bad - em-dash instead of double-hyphen
35
+ # # See section 3 — Implementation
36
+ #
37
+ # # good - standard ASCII
38
+ # puts "Success!"
39
+ # puts "Hello world"
40
+ # return false if x != y
41
+ # # See section 3 -- Implementation
42
+ #
43
+ # # good - international text
44
+ # greeting = "你好"
45
+ # message = "Привет мир"
46
+ # text = "café"
47
+ #
48
+ # @example AllowInStrings: true
49
+ # # bad - still catches in comments
50
+ # # TODO: Fix bug 🐛
51
+ #
52
+ # # bad - still catches in symbols
53
+ # status = :"done_✅"
54
+ #
55
+ # # good - strings can have fancy unicode
56
+ # puts "Success! 🎉"
57
+ # message = "Use → for arrows"
58
+ #
59
+ # @example AllowedUnicode: ['→', '←', '•']
60
+ # # bad - not in allowed list
61
+ # puts "Check: ✅"
62
+ #
63
+ # # good - in allowed list
64
+ # # Click → to continue
65
+ # puts "• Item one"
66
+ #
67
+ # @example Allowed character set
68
+ # \p{L} # Letters (any script: Latin, Chinese, Japanese, Cyrillic, etc.)
69
+ # \p{M} # Marks (combining diacritics for accented characters)
70
+ # \p{N} # Numbers (any script)
71
+ # \x20-\x7E # ASCII printable (space through tilde - all keyboard symbols)
72
+ # \t\n\r # Whitespace
73
+ #
74
+ # @see https://www.compart.com/en/unicode/category
75
+ class NoFancyUnicode < Base
76
+ extend AutoCorrector
77
+
78
+ MSG = 'Avoid fancy Unicode `%<char>s` (U+%<codepoint>s). ' \
79
+ 'Use standard ASCII or add to AllowedUnicode.'
80
+
81
+ ALLOWED_PATTERN = /[\p{L}\p{M}\p{N}\x20-\x7E\t\n\r]/
82
+
83
+ def on_new_investigation
84
+ process_comments unless allow_in_comments?
85
+ end
86
+
87
+ def on_str(node)
88
+ return if allow_in_strings?
89
+
90
+ check_for_fancy_unicode(node, node.value)
91
+ end
92
+
93
+ def on_dstr(node)
94
+ return if allow_in_strings?
95
+
96
+ node.each_child_node(:str) do |str_node|
97
+ check_for_fancy_unicode(str_node, str_node.value)
98
+ end
99
+ end
100
+
101
+ def on_sym(node)
102
+ check_for_fancy_unicode(node, node.value.to_s)
103
+ end
104
+
105
+ def on_dsym(node)
106
+ node.each_child_node(:str) do |str_node|
107
+ check_for_fancy_unicode(str_node, str_node.value)
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ def process_comments
114
+ processed_source.comments.each do |comment|
115
+ fancy_chars = find_fancy_unicode(comment.text)
116
+ next if fancy_chars.empty?
117
+
118
+ # Report first char but fix ALL chars in one correction
119
+ add_offense(comment, message: format_message(fancy_chars.first)) do |corrector|
120
+ corrector.replace(comment, clean_text(comment.text, fancy_chars))
121
+ end
122
+ end
123
+ end
124
+
125
+ def check_for_fancy_unicode(node, value)
126
+ fancy_chars = find_fancy_unicode(value)
127
+ return if fancy_chars.empty?
128
+
129
+ # Report first char but fix ALL chars in one correction
130
+ add_offense(node, message: format_message(fancy_chars.first)) do |corrector|
131
+ corrector.replace(node, clean_text(node.source, fancy_chars))
132
+ end
133
+ end
134
+
135
+ def clean_text(text, chars)
136
+ result = text.dup
137
+ Array(chars).each { |char| result.gsub!(char, '') }
138
+ result
139
+ .gsub(/_+\z/, '') # Remove trailing underscores at end
140
+ .gsub(/_+(['"])/, '\1') # Remove trailing underscores before closing quote
141
+ .gsub(/\s{2,}/, ' ') # Collapse multiple spaces to single space
142
+ .gsub(/\s+(['"])/, '\1') # Remove trailing space before closing quote
143
+ .gsub(/\s+\z/, '') # Remove trailing whitespace at end
144
+ end
145
+
146
+ def find_fancy_unicode(text)
147
+ # Find all characters that don't match the allowed pattern
148
+ fancy = text.chars.reject { |char| char.match?(ALLOWED_PATTERN) }
149
+
150
+ # Filter out explicitly allowed unicode characters
151
+ fancy.reject { |char| allowed_unicode.include?(char) }.uniq
152
+ end
153
+
154
+ def format_message(char)
155
+ codepoint = char.ord.to_s(16).upcase.rjust(4, '0')
156
+ format(MSG, char: char, codepoint: codepoint)
157
+ end
158
+
159
+ def allow_in_strings?
160
+ @allow_in_strings ||= cop_config.fetch('AllowInStrings', false)
161
+ end
162
+
163
+ def allow_in_comments?
164
+ @allow_in_comments ||= cop_config.fetch('AllowInComments', false)
165
+ end
166
+
167
+ def allowed_unicode
168
+ @allowed_unicode ||= Array(cop_config.fetch('AllowedUnicode', [])).freeze
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Claude
6
+ # Detects hardcoded line numbers in comments and strings.
7
+ #
8
+ # Hardcoded line numbers become stale when code shifts. References
9
+ # like "see line 42" or "foo.rb:123" break silently as the codebase
10
+ # evolves. Use method names, class names, or other stable references.
11
+ #
12
+ # @example CheckComments: true (default)
13
+ # # bad
14
+ # # see line 42 for details
15
+ # # Error defined at foo.rb:123
16
+ # # Check L42 for the fix
17
+ # # at line 15, we handle errors
18
+ #
19
+ # # good
20
+ # # see #validate_input for details
21
+ # # Error defined in FooError class
22
+ # # Check the validate_input method for the fix
23
+ #
24
+ # @example CheckStrings: true (default)
25
+ # # bad
26
+ # raise "Error at line 42"
27
+ # expect(error.message).to include("foo.rb:55")
28
+ #
29
+ # # good
30
+ # raise "Error in validate_input"
31
+ # expect(error.message).to include("validate_input")
32
+ #
33
+ # @example MinLineNumber: 1 (default)
34
+ # # Only flags line numbers >= MinLineNumber
35
+ # # With MinLineNumber: 10, "line 5" would not be flagged
36
+ #
37
+ class NoHardcodedLineNumbers < Base
38
+ MSG = 'Avoid hardcoded line number `%<line>s`. ' \
39
+ 'Line numbers shift when code changes.'
40
+
41
+ # Patterns that look like line number references
42
+ # Order matters - more specific patterns first
43
+ LINE_PATTERNS = [
44
+ /\bL(\d+)\b/, # "L42" (GitHub style)
45
+ /\.(?:rb|erb|rake|ru):(\d+)\b/, # "foo.rb:42", "app.erb:10"
46
+ /\blines?\s+(\d+)/i # "line 42" or "lines 42"
47
+ ].freeze
48
+
49
+ # Patterns that look like line refs but aren't
50
+ IGNORE_PATTERNS = [
51
+ /ruby\s+\d+\.\d+/i, # "Ruby 3.1"
52
+ /version\s+\d+/i, # "version 2"
53
+ /port\s+\d+/i, # "port 8080"
54
+ /\bv\d+\.\d+/i, # "v1.2.3"
55
+ /\d+\.\d+\.\d+/, # "1.2.3" semver
56
+ /pid[:\s]+\d+/i, # "pid: 1234"
57
+ /id[:\s]+\d+/i, # "id: 42"
58
+ /\d+\s*(?:ms|seconds?|minutes?)/i, # "42ms", "5 seconds"
59
+ /\d+\s*(?:bytes?|kb|mb|gb)/i, # "100 bytes", "5mb"
60
+ /\d+%/, # "50%"
61
+ /\$\d+/, # "$42"
62
+ /#\d+/, # "#42" (issue reference)
63
+ %r{://[^/]*:\d+} # URLs with ports "http://localhost:8080"
64
+ ].freeze
65
+
66
+ def on_new_investigation
67
+ return unless check_comments?
68
+
69
+ processed_source.comments.each do |comment|
70
+ check_for_line_numbers(comment.text, comment)
71
+ end
72
+ end
73
+
74
+ def on_str(node)
75
+ return unless check_strings?
76
+ return if inside_heredoc?(node)
77
+
78
+ check_for_line_numbers(node.value, node)
79
+ end
80
+
81
+ def on_dstr(node)
82
+ return unless check_strings?
83
+ return if inside_heredoc?(node)
84
+
85
+ node.each_child_node(:str) do |str_node|
86
+ check_for_line_numbers(str_node.value, str_node)
87
+ end
88
+ end
89
+
90
+ private
91
+
92
+ def check_for_line_numbers(text, node)
93
+ return if text.nil? || text.empty?
94
+ return if matches_ignore_pattern?(text)
95
+
96
+ match = find_first_line_number(text)
97
+ return unless match
98
+
99
+ add_offense(node, message: format(MSG, line: match))
100
+ end
101
+
102
+ def find_first_line_number(text)
103
+ min = min_line_number
104
+ LINE_PATTERNS.each do |pattern|
105
+ text.scan(pattern) do |capture|
106
+ line_num = Array(capture).first
107
+ next unless line_num && line_num.to_i >= min
108
+
109
+ return Regexp.last_match[0]
110
+ end
111
+ end
112
+ nil
113
+ end
114
+
115
+ def matches_ignore_pattern?(text)
116
+ IGNORE_PATTERNS.any? { |pattern| text.match?(pattern) }
117
+ end
118
+
119
+ def inside_heredoc?(node)
120
+ return true if node.respond_to?(:heredoc?) && node.heredoc?
121
+
122
+ node.each_ancestor do |ancestor|
123
+ return true if ancestor.respond_to?(:heredoc?) && ancestor.heredoc?
124
+ end
125
+ false
126
+ end
127
+
128
+ def check_comments?
129
+ @check_comments ||= cop_config.fetch('CheckComments', true)
130
+ end
131
+
132
+ def check_strings?
133
+ @check_strings ||= cop_config.fetch('CheckStrings', true)
134
+ end
135
+
136
+ def min_line_number
137
+ @min_line_number ||= cop_config.fetch('MinLineNumber', 1)
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end