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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +25 -0
- data/LICENSE.txt +21 -0
- data/README.md +267 -0
- data/config/default.yml +202 -0
- data/exe/rubocop-claude +7 -0
- data/lib/rubocop/cop/claude/explicit_visibility.rb +139 -0
- data/lib/rubocop/cop/claude/mystery_regex.rb +46 -0
- data/lib/rubocop/cop/claude/no_backwards_compat_hacks.rb +140 -0
- data/lib/rubocop/cop/claude/no_commented_code.rb +182 -0
- data/lib/rubocop/cop/claude/no_fancy_unicode.rb +173 -0
- data/lib/rubocop/cop/claude/no_hardcoded_line_numbers.rb +142 -0
- data/lib/rubocop/cop/claude/no_overly_defensive_code.rb +160 -0
- data/lib/rubocop/cop/claude/tagged_comments.rb +78 -0
- data/lib/rubocop-claude.rb +19 -0
- data/lib/rubocop_claude/cli.rb +246 -0
- data/lib/rubocop_claude/generator.rb +90 -0
- data/lib/rubocop_claude/init_wizard/hooks_installer.rb +127 -0
- data/lib/rubocop_claude/init_wizard/linter_configurer.rb +88 -0
- data/lib/rubocop_claude/init_wizard/preferences_gatherer.rb +94 -0
- data/lib/rubocop_claude/plugin.rb +34 -0
- data/lib/rubocop_claude/version.rb +5 -0
- data/rubocop-claude.gemspec +41 -0
- data/templates/cops/class-structure.md +58 -0
- data/templates/cops/disable-cops-directive.md +33 -0
- data/templates/cops/explicit-visibility.md +52 -0
- data/templates/cops/metrics.md +73 -0
- data/templates/cops/mystery-regex.md +54 -0
- data/templates/cops/no-backwards-compat-hacks.md +101 -0
- data/templates/cops/no-commented-code.md +74 -0
- data/templates/cops/no-fancy-unicode.md +72 -0
- data/templates/cops/no-hardcoded-line-numbers.md +70 -0
- data/templates/cops/no-overly-defensive-code.md +117 -0
- data/templates/cops/tagged-comments.md +74 -0
- data/templates/hooks/settings.local.json +15 -0
- data/templates/linting.md +81 -0
- 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
|