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,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module RubocopClaude
6
+ class InitWizard
7
+ # Handles installing Claude Code hooks for auto-linting
8
+ class HooksInstaller
9
+ def initialize(wizard)
10
+ @wizard = wizard
11
+ end
12
+
13
+ def run
14
+ create_hooks_directory
15
+ create_hook_script
16
+ create_hooks_settings
17
+ end
18
+
19
+ private
20
+
21
+ def create_hooks_directory
22
+ return if Dir.exist?('.claude/hooks')
23
+
24
+ FileUtils.mkdir_p('.claude/hooks')
25
+ end
26
+
27
+ def create_hook_script
28
+ dest = '.claude/hooks/ruby-lint.sh'
29
+ File.write(dest, generate_hook_script)
30
+ FileUtils.chmod(0o755, dest)
31
+ @wizard.add_change('Created .claude/hooks/ruby-lint.sh')
32
+ puts " Created #{dest}"
33
+ end
34
+
35
+ def generate_hook_script
36
+ linter_command = if @wizard.hook_linter == 'standardrb'
37
+ 'standardrb --fix "$file" 2>&1'
38
+ else
39
+ # Prefer binstub, fall back to bundle exec
40
+ # Use $CLAUDE_PROJECT_DIR to ensure correct path regardless of cwd
41
+ <<~CMD.chomp
42
+ if [[ -x "$CLAUDE_PROJECT_DIR/bin/rubocop" ]]; then
43
+ "$CLAUDE_PROJECT_DIR/bin/rubocop" -a "$file" --format simple 2>&1
44
+ else
45
+ bundle exec rubocop -a "$file" --format simple 2>&1
46
+ fi
47
+ CMD
48
+ end
49
+
50
+ <<~SCRIPT
51
+ #!/usr/bin/env bash
52
+ # Claude Code hook: Auto-lint Ruby files after edits
53
+ # Generated by rubocop-claude init --hooks
54
+
55
+ set -e
56
+
57
+ file="$CLAUDE_EDITED_FILE"
58
+ [[ -z "$file" ]] && exit 0
59
+ [[ "$file" != *.rb ]] && exit 0
60
+ [[ ! -f "$file" ]] && exit 0
61
+
62
+ # Autocorrect and report remaining issues
63
+ #{linter_command}
64
+ SCRIPT
65
+ end
66
+
67
+ def create_hooks_settings
68
+ dest = '.claude/settings.local.json'
69
+ source = File.join(CLI::TEMPLATES_DIR, 'hooks', 'settings.local.json')
70
+
71
+ if File.exist?(dest)
72
+ merge_hooks_settings(dest, source)
73
+ else
74
+ FileUtils.cp(source, dest)
75
+ @wizard.add_change('Created .claude/settings.local.json with hooks')
76
+ puts " Created #{dest}"
77
+ end
78
+ end
79
+
80
+ def merge_hooks_settings(dest, source)
81
+ existing = load_json(dest)
82
+ new_hooks = load_json(source)
83
+
84
+ ensure_hooks_structure(existing)
85
+ warn_if_existing_lint_hook(existing['hooks']['PostToolUse'])
86
+ existing['hooks']['PostToolUse'].concat(new_hooks['hooks']['PostToolUse'])
87
+
88
+ File.write(dest, JSON.pretty_generate(existing))
89
+ @wizard.add_change('Added Ruby lint hook to .claude/settings.local.json')
90
+ puts " Updated #{dest}"
91
+ end
92
+
93
+ def load_json(path)
94
+ JSON.parse(File.read(path))
95
+ end
96
+
97
+ def ensure_hooks_structure(config)
98
+ config['hooks'] ||= {}
99
+ config['hooks']['PostToolUse'] ||= []
100
+ end
101
+
102
+ def warn_if_existing_lint_hook(post_tool_use_hooks)
103
+ existing = find_existing_lint_command(post_tool_use_hooks)
104
+ return unless existing
105
+
106
+ truncated = (existing.length > 60) ? "#{existing[0, 57]}..." : existing
107
+ puts " [!] Found existing lint hook: #{truncated}"
108
+ puts ' You may want to remove duplicate hooks from settings.local.json'
109
+ end
110
+
111
+ def find_existing_lint_command(post_tool_use_hooks)
112
+ lint_patterns = [/rubocop/i, /standardrb/i, /lint/i, /\.rb/]
113
+
114
+ post_tool_use_hooks.each do |hook_config|
115
+ next unless hook_config['matcher'].to_s.match?(/Edit|Write/i)
116
+
117
+ (hook_config['hooks'] || []).each do |hook|
118
+ command = hook['command'].to_s
119
+ return command if lint_patterns.any? { |pat| command.match?(pat) }
120
+ end
121
+ end
122
+
123
+ nil
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubocopClaude
4
+ class InitWizard
5
+ # Handles linter config detection and setup (StandardRB or RuboCop)
6
+ class LinterConfigurer
7
+ def initialize(wizard)
8
+ @wizard = wizard
9
+ end
10
+
11
+ def run
12
+ config_type = detect_config_type
13
+ case config_type
14
+ when :standard then setup_standard_config
15
+ when :rubocop then setup_rubocop_config
16
+ else create_new_config
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def detect_config_type
23
+ if File.exist?('.standard.yml')
24
+ :standard
25
+ elsif File.exist?('.rubocop.yml')
26
+ :rubocop
27
+ else
28
+ :none
29
+ end
30
+ end
31
+
32
+ def setup_standard_config
33
+ @wizard.using_standard = true
34
+ puts 'Detected: StandardRB (.standard.yml)'
35
+ add_to_linter_config('.standard.yml', 'plugins', 'plugin')
36
+ end
37
+
38
+ def setup_rubocop_config
39
+ puts 'Detected: RuboCop (.rubocop.yml)'
40
+ add_to_linter_config('.rubocop.yml', 'require', 'require')
41
+ end
42
+
43
+ def add_to_linter_config(file, key, prompt_label)
44
+ config = @wizard.load_yaml(file)
45
+ existing = config[key] || []
46
+
47
+ if existing.include?('rubocop-claude')
48
+ puts ' rubocop-claude already configured'
49
+ elsif @wizard.prompt_yes?(" Add rubocop-claude #{prompt_label}?", default: true)
50
+ config[key] = existing + ['rubocop-claude']
51
+ @wizard.save_yaml(file, config)
52
+ @wizard.add_change("Added rubocop-claude to #{file} #{key}")
53
+ end
54
+ puts
55
+ end
56
+
57
+ def create_new_config
58
+ puts 'No linter config found.'
59
+ choice = @wizard.prompt_choice(
60
+ 'Which linter do you use?',
61
+ {'s' => 'StandardRB', 'r' => 'RuboCop', 'n' => 'Skip'},
62
+ default: 's'
63
+ )
64
+
65
+ case choice
66
+ when 's' then create_standard_config
67
+ when 'r' then create_rubocop_config
68
+ end
69
+ puts
70
+ end
71
+
72
+ def create_standard_config
73
+ @wizard.using_standard = true
74
+ config = {'plugins' => ['rubocop-claude']}
75
+ @wizard.save_yaml('.standard.yml', config)
76
+ @wizard.add_change('Created .standard.yml with rubocop-claude')
77
+ puts ' Created .standard.yml'
78
+ end
79
+
80
+ def create_rubocop_config
81
+ config = {'require' => ['rubocop-claude']}
82
+ @wizard.save_yaml('.rubocop.yml', config)
83
+ @wizard.add_change('Created .rubocop.yml with rubocop-claude')
84
+ puts ' Created .rubocop.yml'
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubocopClaude
4
+ class InitWizard
5
+ # Handles gathering user preferences for cop configuration
6
+ class PreferencesGatherer
7
+ def initialize(wizard)
8
+ @wizard = wizard
9
+ end
10
+
11
+ def run
12
+ puts 'Configuration options:'
13
+ puts
14
+
15
+ gather_ai_defaults
16
+ gather_visibility_style
17
+ gather_emoji_preference
18
+ gather_commented_code_preference
19
+ gather_hooks_preference
20
+ puts
21
+ end
22
+
23
+ private
24
+
25
+ def gather_ai_defaults
26
+ if @wizard.using_standard
27
+ puts ' Metrics cops (MethodLength, ClassLength, etc.) will be enabled.'
28
+ puts ' (StandardRB handles style; we add metrics enforcement)'
29
+ @wizard.add_change('Metrics cops enabled (StandardRB mode)')
30
+ else
31
+ puts ' AI Defaults: Stricter metrics, guard clauses, frozen constants, etc.'
32
+ puts ' (See README "Suggested RuboCop Defaults" for full list)'
33
+ ai_defaults = @wizard.prompt_yes?(' Enable AI defaults?', default: true)
34
+ @wizard.add_change(ai_defaults ? 'AI defaults enabled' : 'AI defaults disabled (Claude cops only)')
35
+ end
36
+ end
37
+
38
+ def gather_visibility_style
39
+ choice = @wizard.prompt_choice(
40
+ ' Visibility style?',
41
+ {'g' => 'grouped (private section)', 'm' => 'modifier (private def)'},
42
+ default: 'g'
43
+ )
44
+
45
+ style = (choice == 'm') ? 'modifier' : 'grouped'
46
+ @wizard.add_config_override('Claude/ExplicitVisibility', {'EnforcedStyle' => style})
47
+ @wizard.add_change("Visibility style: #{style}")
48
+ end
49
+
50
+ def gather_emoji_preference
51
+ allow_emoji = @wizard.prompt_yes?(' Allow emoji in strings?', default: false)
52
+
53
+ if allow_emoji
54
+ @wizard.add_config_override('Claude/NoFancyUnicode', {'AllowInStrings' => true})
55
+ @wizard.add_change('Emoji allowed in strings')
56
+ else
57
+ @wizard.add_change('Emoji not allowed anywhere')
58
+ end
59
+ end
60
+
61
+ def gather_commented_code_preference
62
+ choice = @wizard.prompt_choice(
63
+ ' Flag commented code:',
64
+ {'1' => 'Single lines too', '2' => 'Only multi-line blocks'},
65
+ default: '2'
66
+ )
67
+
68
+ min_lines = choice.to_i
69
+ @wizard.add_config_override('Claude/NoCommentedCode', {'MinLines' => min_lines})
70
+ @wizard.add_change("Commented code detection: #{(min_lines == 1) ? "single lines" : "multi-line only"}")
71
+ end
72
+
73
+ def gather_hooks_preference
74
+ puts
75
+ puts ' Claude Code hooks auto-lint Ruby files after each edit.'
76
+ @wizard.install_hooks = @wizard.prompt_yes?(' Install Claude Code hooks?', default: false)
77
+ return unless @wizard.install_hooks
78
+
79
+ gather_hook_linter_preference
80
+ @wizard.add_change("Claude Code hooks enabled (#{@wizard.hook_linter})")
81
+ end
82
+
83
+ def gather_hook_linter_preference
84
+ choice = @wizard.prompt_choice(
85
+ ' Linter for hooks?',
86
+ {'r' => 'RuboCop', 's' => 'StandardRB'},
87
+ default: 'r'
88
+ )
89
+
90
+ @wizard.hook_linter = (choice == 's') ? 'standardrb' : 'rubocop'
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'lint_roller'
4
+
5
+ module RubocopClaude
6
+ class Plugin < LintRoller::Plugin
7
+ def about
8
+ LintRoller::About.new(
9
+ name: 'rubocop-claude',
10
+ version: VERSION,
11
+ homepage: 'https://github.com/nabm/rubocop-claude',
12
+ description: 'AI-focused Ruby linting - catches common AI coding anti-patterns'
13
+ )
14
+ end
15
+
16
+ def supported?(context)
17
+ context.engine == :rubocop
18
+ end
19
+
20
+ def rules(_context)
21
+ LintRoller::Rules.new(
22
+ type: :path,
23
+ config_format: :rubocop,
24
+ value: config_path
25
+ )
26
+ end
27
+
28
+ private
29
+
30
+ def config_path
31
+ File.expand_path('../../config/default.yml', __dir__)
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubocopClaude
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/rubocop_claude/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'rubocop-claude'
7
+ spec.version = RubocopClaude::VERSION
8
+ spec.authors = ['Nicholas Marshall']
9
+ spec.email = ['nialbima@users.noreply.github.com']
10
+
11
+ spec.summary = 'AI-focused Ruby linting via StandardRB plugin'
12
+ spec.description = 'A Rubocop/StandardRB plugin providing automated Rubocop foot-rake feedback to AI coding agents.'
13
+ spec.homepage = 'https://github.com/nabm/rubocop-claude'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.1.0'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = spec.homepage
19
+ spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/main/CHANGELOG.md"
20
+ spec.metadata['rubygems_mfa_required'] = 'true'
21
+ spec.metadata['default_lint_roller_plugin'] = 'RubocopClaude::Plugin'
22
+
23
+ spec.files = Dir.glob('{config,lib,templates,exe}/**/*') + %w[
24
+ LICENSE.txt
25
+ README.md
26
+ CHANGELOG.md
27
+ rubocop-claude.gemspec
28
+ ]
29
+ spec.bindir = 'exe'
30
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
31
+ spec.require_paths = ['lib']
32
+
33
+ spec.add_dependency 'lint_roller', '~> 1.1'
34
+ spec.add_dependency 'rubocop', '>= 1.50'
35
+
36
+ spec.add_development_dependency 'rake', '~> 13.0'
37
+ spec.add_development_dependency 'rspec', '~> 3.0'
38
+ spec.add_development_dependency 'rubocop-packaging', '~> 0.6.0'
39
+ spec.add_development_dependency 'rubocop-rspec', '~> 3.9'
40
+ spec.add_development_dependency 'standard', '~> 1.40'
41
+ end
@@ -0,0 +1,58 @@
1
+ # Layout/ClassStructure
2
+
3
+ **What it catches:** Class/module contents in wrong order.
4
+
5
+ **Why it matters:** Consistent ordering makes code easier to navigate.
6
+
7
+ ## Expected Order
8
+
9
+ 1. `include`, `extend`, `prepend`
10
+ 2. Constants
11
+ 3. Class methods (`def self.foo`)
12
+ 4. `initialize`
13
+ 5. Public instance methods
14
+ 6. Protected methods
15
+ 7. Private methods
16
+
17
+ ## How to Fix
18
+
19
+ ```ruby
20
+ # BAD
21
+ class User
22
+ def greet
23
+ "Hello"
24
+ end
25
+
26
+ include Comparable
27
+
28
+ ROLE = "admin"
29
+
30
+ def initialize(name)
31
+ @name = name
32
+ end
33
+ end
34
+
35
+ # GOOD
36
+ class User
37
+ include Comparable
38
+
39
+ ROLE = "admin"
40
+
41
+ def initialize(name)
42
+ @name = name
43
+ end
44
+
45
+ def greet
46
+ "Hello"
47
+ end
48
+ end
49
+ ```
50
+
51
+ ## Autocorrection
52
+
53
+ **This cop autocorrects** - it will reorder methods automatically.
54
+
55
+ ## Decision Criteria
56
+
57
+ - Let the autocorrect handle it
58
+ - If it produces odd results, review manually and adjust
@@ -0,0 +1,33 @@
1
+ # Style/DisableCopsWithinSourceCodeDirective
2
+
3
+ **What it catches:** `# rubocop:disable` comments inline.
4
+
5
+ **Why it matters:** Disabling cops hides problems rather than fixing them.
6
+
7
+ ## How to Fix
8
+
9
+ **Don't disable the cop.** Fix the underlying issue instead.
10
+
11
+ ```ruby
12
+ # BAD
13
+ # rubocop:disable Metrics/MethodLength
14
+ def very_long_method
15
+ # ... lots of code ...
16
+ end
17
+ # rubocop:enable Metrics/MethodLength
18
+
19
+ # GOOD - refactor the method
20
+ def method_part_one
21
+ # ...
22
+ end
23
+
24
+ def method_part_two
25
+ # ...
26
+ end
27
+ ```
28
+
29
+ ## Decision Criteria
30
+
31
+ - If you genuinely can't fix the issue, **ask the human**
32
+ - Never add `# rubocop:disable` without explicit human approval
33
+ - If the cop seems wrong for this codebase, discuss with the human about configuring it globally
@@ -0,0 +1,52 @@
1
+ # Claude/ExplicitVisibility
2
+
3
+ **What it catches:** (in grouped mode) Inline `private def foo` instead of grouped `private` sections.
4
+
5
+ **Why it matters:** Grouped visibility is the dominant Ruby convention. All private methods appear under the `private` keyword, making it easy to scan a class and see its public interface at the top.
6
+
7
+ ## How to Fix
8
+
9
+ ```ruby
10
+ # BAD - modifier style (inline)
11
+ class User
12
+ def public_method
13
+ end
14
+
15
+ private def secret_method
16
+ end
17
+
18
+ private def another_secret
19
+ end
20
+ end
21
+
22
+ # GOOD - grouped style
23
+ class User
24
+ def public_method
25
+ end
26
+
27
+ private
28
+
29
+ def secret_method
30
+ end
31
+
32
+ def another_secret
33
+ end
34
+ end
35
+ ```
36
+
37
+ ## Configuration
38
+
39
+ ```yaml
40
+ Claude/ExplicitVisibility:
41
+ Enabled: true
42
+ EnforcedStyle: grouped # default - or 'modifier' for inline style
43
+ ```
44
+
45
+ ## For AI Assistants
46
+
47
+ **When adding private methods:**
48
+ 1. Place them after the `private` keyword at the bottom of the class
49
+ 2. If no `private` section exists, add one before your new method
50
+ 3. Don't use `private def` inline style unless the project consistently uses it
51
+
52
+ **When you see `private def`:** The project may prefer modifier style. Check other files before "fixing" it.
@@ -0,0 +1,73 @@
1
+ # Metrics/* Cops
2
+
3
+ **What they catch:** Methods that are too complex or too long.
4
+
5
+ - `Metrics/CyclomaticComplexity` - Too many branching paths
6
+ - `Metrics/AbcSize` - Too many assignments, branches, conditions
7
+ - `Metrics/MethodLength` - Too many lines
8
+
9
+ **Why it matters:** Complex methods are hard to understand, test, and maintain.
10
+
11
+ ## How to Fix
12
+
13
+ ### Extract Helper Methods
14
+
15
+ ```ruby
16
+ # BAD - one big method
17
+ def process_order(order)
18
+ # validate order
19
+ # calculate totals
20
+ # apply discounts
21
+ # process payment
22
+ # send notifications
23
+ end
24
+
25
+ # GOOD - broken into focused methods
26
+ def process_order(order)
27
+ validate_order(order)
28
+ totals = calculate_totals(order)
29
+ totals = apply_discounts(totals, order.customer)
30
+ process_payment(order, totals)
31
+ send_notifications(order)
32
+ end
33
+ ```
34
+
35
+ ### Reduce Nesting
36
+
37
+ ```ruby
38
+ # BAD - deep nesting
39
+ def process(data)
40
+ if data.valid?
41
+ if data.complete?
42
+ if data.authorized?
43
+ # actual work
44
+ end
45
+ end
46
+ end
47
+ end
48
+
49
+ # GOOD - early returns
50
+ def process(data)
51
+ return unless data.valid?
52
+ return unless data.complete?
53
+ return unless data.authorized?
54
+
55
+ # actual work
56
+ end
57
+ ```
58
+
59
+ ### Simplify Conditionals
60
+
61
+ ```ruby
62
+ # BAD - complex conditional
63
+ if user.admin? || (user.moderator? && post.reported?) || user.id == post.author_id
64
+
65
+ # GOOD - named method
66
+ if can_moderate_post?(user, post)
67
+ ```
68
+
69
+ ## Decision Criteria
70
+
71
+ - If a method triggers these, it probably needs refactoring
72
+ - Ask if you're not sure how to split it up
73
+ - Don't just add `# rubocop:disable` - that's never the answer
@@ -0,0 +1,54 @@
1
+ # Claude/MysteryRegex
2
+
3
+ **What it catches:** Inline regexes longer than 25 characters (configurable).
4
+
5
+ **Why it matters:** Complex regexes are cryptic. A named constant with a descriptive name makes the intent clear. Comments can explain what the pattern matches.
6
+
7
+ ## How to Fix
8
+
9
+ ```ruby
10
+ # BAD - what does this match?
11
+ text.match?(/\A[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z/)
12
+
13
+ # BAD - still cryptic even in a method
14
+ def valid_email?(text)
15
+ text.match?(/\A[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z/)
16
+ end
17
+
18
+ # GOOD - name explains intent
19
+ EMAIL_PATTERN = /\A[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z/
20
+
21
+ def valid_email?(text)
22
+ text.match?(EMAIL_PATTERN)
23
+ end
24
+
25
+ # BETTER - comment explains the pattern
26
+ # Matches email addresses: local-part@domain.tld
27
+ # Simplified from RFC 5322 - allows common characters only
28
+ EMAIL_PATTERN = /\A[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\z/
29
+ ```
30
+
31
+ ## Naming Conventions
32
+
33
+ | Pattern Type | Naming Examples |
34
+ |--------------|-----------------|
35
+ | Email | `EMAIL_PATTERN`, `EMAIL_REGEX` |
36
+ | URL | `URL_PATTERN`, `HTTP_URL_REGEX` |
37
+ | Phone | `PHONE_NUMBER_PATTERN` |
38
+ | Date | `ISO_DATE_PATTERN`, `DATE_REGEX` |
39
+ | Validation | `VALID_USERNAME_PATTERN` |
40
+ | Parsing | `LOG_LINE_PATTERN`, `CSV_ROW_REGEX` |
41
+
42
+ ## When Short Regexes Are Fine
43
+
44
+ These are OK inline:
45
+ - `/\A\d+\z/` - just digits
46
+ - `/\s+/` - whitespace
47
+ - `/[,;]/` - simple character class
48
+ - `/\.rb\z/` - file extension
49
+
50
+ ## Decision Criteria
51
+
52
+ - Extract to constant if you'd need to think about what it matches
53
+ - Always add a comment for non-obvious patterns
54
+ - Use `_PATTERN` or `_REGEX` suffix consistently in the project