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,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Claude
|
|
6
|
+
# Flags overly defensive coding patterns.
|
|
7
|
+
#
|
|
8
|
+
# AI assistants often add excessive error handling and nil-checking
|
|
9
|
+
# "just in case." This obscures bugs and indicates distrust of the codebase.
|
|
10
|
+
#
|
|
11
|
+
# @example Error swallowing (flagged)
|
|
12
|
+
# # bad
|
|
13
|
+
# begin; do_something; rescue => e; nil; end
|
|
14
|
+
# result = do_something rescue nil
|
|
15
|
+
#
|
|
16
|
+
# @example Excessive safe navigation (flagged)
|
|
17
|
+
# # bad - 2+ chained &.
|
|
18
|
+
# user&.profile&.settings
|
|
19
|
+
#
|
|
20
|
+
# @example Defensive nil checks (flagged)
|
|
21
|
+
# # bad
|
|
22
|
+
# a && a.foo
|
|
23
|
+
# a.present? && a.foo
|
|
24
|
+
# foo.nil? ? default : foo
|
|
25
|
+
# foo ? foo : default
|
|
26
|
+
#
|
|
27
|
+
class NoOverlyDefensiveCode < Base
|
|
28
|
+
extend AutoCorrector
|
|
29
|
+
|
|
30
|
+
MSG_SWALLOW = "Trust internal code. Don't swallow errors with `rescue nil` or `rescue => e; nil`."
|
|
31
|
+
MSG_CHAIN = 'Trust internal code. Excessive safe navigation (%<count>d chained `&.`) suggests ' \
|
|
32
|
+
'uncertain data model. Use explicit nil checks or fix the source.'
|
|
33
|
+
MSG_NIL_CHECK = 'Trust internal code. `%<code>s` is a defensive nil check. ' \
|
|
34
|
+
'Use `%<replacement>s` instead.'
|
|
35
|
+
MSG_NIL_TERNARY = 'Trust internal code. `%<code>s` is a verbose nil check. ' \
|
|
36
|
+
'Use `%<replacement>s` instead.'
|
|
37
|
+
MSG_INVERSE_TERNARY = 'Trust internal code. `%<code>s` is verbose. ' \
|
|
38
|
+
'Use `%<replacement>s` instead.'
|
|
39
|
+
MSG_PRESENT_CHECK = 'Trust internal code. `%<code>s` is a defensive presence check. ' \
|
|
40
|
+
'Use `%<replacement>s` instead.'
|
|
41
|
+
|
|
42
|
+
BROAD_EXCEPTIONS = %w[Exception StandardError RuntimeError].freeze
|
|
43
|
+
|
|
44
|
+
def on_resbody(node)
|
|
45
|
+
add_offense(node, message: MSG_SWALLOW) if swallows_error?(node)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def on_and(node)
|
|
49
|
+
left, right = *node
|
|
50
|
+
return unless right.send_type?
|
|
51
|
+
|
|
52
|
+
check_presence_pattern(node, left, right) || check_nil_check_pattern(node, left, right)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def on_if(node)
|
|
56
|
+
return unless node.ternary?
|
|
57
|
+
|
|
58
|
+
condition, if_branch, else_branch = *node
|
|
59
|
+
check_nil_ternary(node, condition, if_branch, else_branch) ||
|
|
60
|
+
check_inverse_ternary(node, condition, if_branch, else_branch)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def on_csend(node)
|
|
64
|
+
return if node.parent&.csend_type?
|
|
65
|
+
|
|
66
|
+
chain_length = count_safe_nav_chain(node)
|
|
67
|
+
return unless chain_length > max_safe_navigation_chain
|
|
68
|
+
|
|
69
|
+
add_offense(node, message: format(MSG_CHAIN, count: chain_length))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Pattern: `a.present? && a.foo` -> `a.foo`
|
|
75
|
+
def check_presence_pattern(node, left, right)
|
|
76
|
+
return unless left.send_type? && left.method_name == :present?
|
|
77
|
+
return unless same_variable?(left.receiver, right.receiver)
|
|
78
|
+
|
|
79
|
+
register_offense(node, build_replacement(right), MSG_PRESENT_CHECK)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Pattern: `a && a.foo` -> `a.foo`
|
|
83
|
+
def check_nil_check_pattern(node, left, right)
|
|
84
|
+
return unless right.receiver && same_variable?(left, right.receiver)
|
|
85
|
+
|
|
86
|
+
register_offense(node, build_replacement(right), MSG_NIL_CHECK)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Pattern: `foo.nil? ? default : foo` -> `foo || default`
|
|
90
|
+
def check_nil_ternary(node, condition, if_branch, else_branch)
|
|
91
|
+
return unless condition.send_type? && %i[nil? blank?].include?(condition.method_name)
|
|
92
|
+
return unless same_variable?(condition.receiver, else_branch)
|
|
93
|
+
|
|
94
|
+
register_offense(node, "#{condition.receiver.source} || #{if_branch.source}", MSG_NIL_TERNARY)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Pattern: `foo ? foo : default` -> `foo || default`
|
|
98
|
+
def check_inverse_ternary(node, condition, if_branch, else_branch)
|
|
99
|
+
return unless same_variable?(condition, if_branch)
|
|
100
|
+
|
|
101
|
+
register_offense(node, "#{condition.source} || #{else_branch.source}", MSG_INVERSE_TERNARY)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def register_offense(node, replacement, msg_template)
|
|
105
|
+
message = format(msg_template, code: node.source, replacement: replacement)
|
|
106
|
+
add_offense(node, message: message) { |corrector| corrector.replace(node, replacement) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def swallows_error?(resbody_node)
|
|
110
|
+
exception_type, _var, body = *resbody_node
|
|
111
|
+
return false if exception_type && specific_exception?(exception_type)
|
|
112
|
+
|
|
113
|
+
body.nil? || body.nil_type? || empty_return?(body)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def empty_return?(body)
|
|
117
|
+
body.return_type? && (body.children.empty? || body.children.first.nil_type?)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def specific_exception?(exception_node)
|
|
121
|
+
exception_node.children.all? { |c| specific_exception_class?(c) }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def specific_exception_class?(const_node)
|
|
125
|
+
const_node.const_type? && !BROAD_EXCEPTIONS.include?(const_node.children.last.to_s)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def count_safe_nav_chain(node)
|
|
129
|
+
count = 1
|
|
130
|
+
current = node.receiver
|
|
131
|
+
while current.csend_type?
|
|
132
|
+
count += 1
|
|
133
|
+
current = current.receiver
|
|
134
|
+
end
|
|
135
|
+
count
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def same_variable?(node1, node2)
|
|
139
|
+
node1 && node2 && node1.source == node2.source
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def build_replacement(send_node)
|
|
143
|
+
return send_node.source unless add_safe_navigator?
|
|
144
|
+
|
|
145
|
+
args = send_node.arguments.map(&:source).join(', ')
|
|
146
|
+
base = "#{send_node.receiver.source}&.#{send_node.method_name}"
|
|
147
|
+
send_node.arguments.empty? ? base : "#{base}(#{args})"
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def add_safe_navigator?
|
|
151
|
+
@add_safe_navigator ||= cop_config.fetch('AddSafeNavigator', false)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def max_safe_navigation_chain
|
|
155
|
+
@max_safe_navigation_chain ||= cop_config.fetch('MaxSafeNavigationChain', 1)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Claude
|
|
6
|
+
# Enforces attribution on TODO/NOTE/FIXME/HACK comments.
|
|
7
|
+
#
|
|
8
|
+
# Anonymous TODO comments lose context over time. Who wrote it?
|
|
9
|
+
# When? What was the context? Attribution helps track ownership
|
|
10
|
+
# and distinguishes human-written comments from AI-generated ones.
|
|
11
|
+
#
|
|
12
|
+
# @example Default (attribution required)
|
|
13
|
+
# # bad
|
|
14
|
+
# # TODO: Fix this later
|
|
15
|
+
# # FIXME: Handle edge case
|
|
16
|
+
#
|
|
17
|
+
# # good - handle format (after colon, compatible with Style/CommentAnnotation)
|
|
18
|
+
# # TODO: [@username] Fix this later
|
|
19
|
+
# # FIXME: [Alice - @alice] Handle edge case
|
|
20
|
+
#
|
|
21
|
+
# @example Case insensitive (keywords matched regardless of case)
|
|
22
|
+
# # bad
|
|
23
|
+
# # todo: fix this
|
|
24
|
+
# # Todo: Fix this
|
|
25
|
+
#
|
|
26
|
+
# @example AI assistant attribution
|
|
27
|
+
# # good - AI-generated comments use @claude
|
|
28
|
+
# # TODO: [@claude] Refactor to reduce complexity
|
|
29
|
+
# # NOTE: [@claude] This pattern matches the factory in user.rb
|
|
30
|
+
#
|
|
31
|
+
# @example Keywords: ['TODO', 'FIXME'] (custom keyword list)
|
|
32
|
+
# # With custom keywords, only those are checked
|
|
33
|
+
# # bad - TODO is in the list
|
|
34
|
+
# # TODO: Fix this
|
|
35
|
+
#
|
|
36
|
+
# # good - NOTE not in the custom list, so not checked
|
|
37
|
+
# # NOTE: No attribution needed
|
|
38
|
+
#
|
|
39
|
+
# @example Valid attribution formats
|
|
40
|
+
# [@handle] # Just handle
|
|
41
|
+
# [Name - @handle] # Name and handle
|
|
42
|
+
# [First Last - @handle] # Full name and handle
|
|
43
|
+
#
|
|
44
|
+
class TaggedComments < Base
|
|
45
|
+
MSG = 'Comments need attribution. Use format: # %<keyword>s: [@handle] description'
|
|
46
|
+
|
|
47
|
+
ATTRIBUTION_PATTERN = /\[(?:[\w\s]+-\s*)?@[\w-]+\]/
|
|
48
|
+
|
|
49
|
+
def on_new_investigation
|
|
50
|
+
processed_source.comments.each do |comment|
|
|
51
|
+
check_comment(comment)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def check_comment(comment)
|
|
58
|
+
text = comment.text
|
|
59
|
+
match = text.match(keyword_regex)
|
|
60
|
+
return unless match
|
|
61
|
+
|
|
62
|
+
keyword = match[1].upcase
|
|
63
|
+
return if text.match?(ATTRIBUTION_PATTERN)
|
|
64
|
+
|
|
65
|
+
add_offense(comment, message: format(MSG, keyword: keyword))
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def keyword_regex
|
|
69
|
+
@keyword_regex ||= begin
|
|
70
|
+
keywords = cop_config.fetch('Keywords', %w[TODO FIXME NOTE HACK OPTIMIZE REVIEW])
|
|
71
|
+
pattern_str = "\\A#\\s*(#{keywords.join("|")}):?\\s+(?!\\[[@\\w])"
|
|
72
|
+
Regexp.new(pattern_str, Regexp::IGNORECASE)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'rubocop'
|
|
4
|
+
|
|
5
|
+
require_relative 'rubocop_claude/version'
|
|
6
|
+
require_relative 'rubocop_claude/plugin'
|
|
7
|
+
|
|
8
|
+
require_relative 'rubocop/cop/claude/no_fancy_unicode'
|
|
9
|
+
require_relative 'rubocop/cop/claude/tagged_comments'
|
|
10
|
+
require_relative 'rubocop/cop/claude/no_commented_code'
|
|
11
|
+
require_relative 'rubocop/cop/claude/no_backwards_compat_hacks'
|
|
12
|
+
require_relative 'rubocop/cop/claude/no_overly_defensive_code'
|
|
13
|
+
require_relative 'rubocop/cop/claude/explicit_visibility'
|
|
14
|
+
require_relative 'rubocop/cop/claude/mystery_regex'
|
|
15
|
+
require_relative 'rubocop/cop/claude/no_hardcoded_line_numbers'
|
|
16
|
+
|
|
17
|
+
module RubocopClaude
|
|
18
|
+
class Error < StandardError; end
|
|
19
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
require_relative 'version'
|
|
6
|
+
require_relative 'init_wizard/hooks_installer'
|
|
7
|
+
require_relative 'init_wizard/linter_configurer'
|
|
8
|
+
require_relative 'init_wizard/preferences_gatherer'
|
|
9
|
+
|
|
10
|
+
module RubocopClaude
|
|
11
|
+
# CLI for rubocop-claude commands
|
|
12
|
+
class CLI
|
|
13
|
+
TEMPLATES_DIR = File.expand_path('../../templates', __dir__).freeze
|
|
14
|
+
|
|
15
|
+
def self.run(args)
|
|
16
|
+
new.run(args)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run(args)
|
|
20
|
+
case args.first
|
|
21
|
+
when 'init' then InitWizard.new.run
|
|
22
|
+
when 'version', '-v', '--version' then puts "rubocop-claude #{VERSION}"
|
|
23
|
+
when 'help', '-h', '--help', nil then print_help
|
|
24
|
+
else
|
|
25
|
+
warn "Unknown command: #{args.first}"
|
|
26
|
+
print_help
|
|
27
|
+
exit 1
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def print_help
|
|
34
|
+
puts <<~HELP
|
|
35
|
+
Usage: rubocop-claude <command>
|
|
36
|
+
|
|
37
|
+
Commands:
|
|
38
|
+
init Interactive setup wizard
|
|
39
|
+
version Show version
|
|
40
|
+
help Show this help
|
|
41
|
+
|
|
42
|
+
Examples:
|
|
43
|
+
rubocop-claude init # Set up rubocop-claude in current project
|
|
44
|
+
rubocop-claude version # Show version
|
|
45
|
+
HELP
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Interactive setup wizard for rubocop-claude
|
|
50
|
+
class InitWizard
|
|
51
|
+
TEMPLATES_DIR = CLI::TEMPLATES_DIR
|
|
52
|
+
DEVELOPMENT_GROUP_PATTERN = /group\s+:development.*do/m
|
|
53
|
+
DEVELOPMENT_GROUP_CAPTURE = /^(\s*)(group\s+:development.*?do\s*$)/m
|
|
54
|
+
|
|
55
|
+
attr_accessor :using_standard, :install_hooks, :hook_linter
|
|
56
|
+
|
|
57
|
+
def initialize
|
|
58
|
+
@changes = []
|
|
59
|
+
@config_overrides = {}
|
|
60
|
+
@using_standard = false
|
|
61
|
+
@install_hooks = false
|
|
62
|
+
@hook_linter = 'rubocop'
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def run
|
|
66
|
+
print_welcome
|
|
67
|
+
check_gemfile
|
|
68
|
+
LinterConfigurer.new(self).run
|
|
69
|
+
PreferencesGatherer.new(self).run
|
|
70
|
+
create_claude_files
|
|
71
|
+
print_summary
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def add_change(description)
|
|
75
|
+
@changes << description
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def add_config_override(cop, settings)
|
|
79
|
+
@config_overrides[cop] = settings
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def prompt_yes?(question, default:)
|
|
83
|
+
suffix = default ? '[Y/n]' : '[y/N]'
|
|
84
|
+
print "#{question} #{suffix} "
|
|
85
|
+
|
|
86
|
+
answer = $stdin.gets
|
|
87
|
+
return default if answer.nil?
|
|
88
|
+
|
|
89
|
+
normalized = answer.strip.downcase
|
|
90
|
+
return default if normalized.empty?
|
|
91
|
+
|
|
92
|
+
normalized == 'y'
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def prompt_choice(question, options, default:)
|
|
96
|
+
options_str = options.map { |k, v| "#{k}=#{v}" }.join(', ')
|
|
97
|
+
print "#{question} (#{options_str}) [#{default}] "
|
|
98
|
+
|
|
99
|
+
answer = $stdin.gets
|
|
100
|
+
return default if answer.nil?
|
|
101
|
+
|
|
102
|
+
normalized = answer.strip.downcase
|
|
103
|
+
return default if normalized.empty?
|
|
104
|
+
|
|
105
|
+
options.key?(normalized) ? normalized : default
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def load_yaml(file)
|
|
109
|
+
return {} unless File.exist?(file)
|
|
110
|
+
|
|
111
|
+
YAML.load_file(file) || {}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def save_yaml(file, data)
|
|
115
|
+
File.write(file, YAML.dump(data))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
def print_welcome
|
|
121
|
+
puts <<~WELCOME
|
|
122
|
+
+-------------------------------------------+
|
|
123
|
+
| rubocop-claude #{VERSION.ljust(26)}|
|
|
124
|
+
| AI-focused Ruby linting setup wizard |
|
|
125
|
+
+-------------------------------------------+
|
|
126
|
+
|
|
127
|
+
WELCOME
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def check_gemfile
|
|
131
|
+
return unless File.exist?('Gemfile')
|
|
132
|
+
|
|
133
|
+
gemfile_content = File.read('Gemfile')
|
|
134
|
+
return if gemfile_content.include?('rubocop-claude')
|
|
135
|
+
return unless prompt_yes?('Add rubocop-claude to Gemfile?', default: true)
|
|
136
|
+
|
|
137
|
+
add_to_gemfile
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def add_to_gemfile
|
|
141
|
+
gemfile = File.read('Gemfile')
|
|
142
|
+
File.write('Gemfile', insert_gem_into_gemfile(gemfile))
|
|
143
|
+
@changes << 'Added rubocop-claude to Gemfile'
|
|
144
|
+
puts ' Added to Gemfile (run `bundle install`)'
|
|
145
|
+
puts
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def insert_gem_into_gemfile(content)
|
|
149
|
+
gem_line = "gem 'rubocop-claude', require: false"
|
|
150
|
+
return content + "\n#{gem_line}\n" unless content.match?(DEVELOPMENT_GROUP_PATTERN)
|
|
151
|
+
|
|
152
|
+
content.sub(DEVELOPMENT_GROUP_CAPTURE) do
|
|
153
|
+
"#{::Regexp.last_match(1)}#{::Regexp.last_match(2)}\n#{::Regexp.last_match(1)} #{gem_line}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def create_claude_files
|
|
158
|
+
puts 'Claude integration files:'
|
|
159
|
+
puts
|
|
160
|
+
|
|
161
|
+
create_claude_directory
|
|
162
|
+
create_linting_md
|
|
163
|
+
create_cop_guides
|
|
164
|
+
create_local_config
|
|
165
|
+
create_hooks if @install_hooks
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def create_claude_directory
|
|
169
|
+
return if Dir.exist?('.claude')
|
|
170
|
+
|
|
171
|
+
FileUtils.mkdir_p('.claude')
|
|
172
|
+
puts ' Created .claude/ directory'
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def create_linting_md
|
|
176
|
+
dest = '.claude/linting.md'
|
|
177
|
+
source = File.join(TEMPLATES_DIR, 'linting.md')
|
|
178
|
+
|
|
179
|
+
if File.exist?(dest)
|
|
180
|
+
return unless prompt_yes?(" #{dest} exists. Overwrite?", default: false)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
FileUtils.cp(source, dest)
|
|
184
|
+
@changes << 'Created .claude/linting.md'
|
|
185
|
+
puts " Created #{dest}"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def create_cop_guides
|
|
189
|
+
cops_dir = '.claude/cops'
|
|
190
|
+
FileUtils.mkdir_p(cops_dir)
|
|
191
|
+
|
|
192
|
+
source_dir = File.join(TEMPLATES_DIR, 'cops')
|
|
193
|
+
cop_files = Dir.glob(File.join(source_dir, '*.md'))
|
|
194
|
+
|
|
195
|
+
cop_files.each { |src| FileUtils.cp(src, cops_dir) }
|
|
196
|
+
@changes << "Created .claude/cops/ (#{cop_files.size} guides)"
|
|
197
|
+
puts " Created #{cops_dir}/ (#{cop_files.size} cop guides)"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def create_local_config
|
|
201
|
+
overrides = build_config_overrides
|
|
202
|
+
dest = '.rubocop_claude.yml'
|
|
203
|
+
save_yaml(dest, merge_with_existing(dest, overrides))
|
|
204
|
+
@changes << 'Created .rubocop_claude.yml with your preferences'
|
|
205
|
+
puts " Created #{dest}"
|
|
206
|
+
puts ' (Add `inherit_from: .rubocop_claude.yml` to your rubocop config)'
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def build_config_overrides
|
|
210
|
+
overrides = @config_overrides.dup
|
|
211
|
+
overrides.delete('inherit_from_ai_defaults')
|
|
212
|
+
overrides
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def merge_with_existing(file, overrides)
|
|
216
|
+
return overrides unless File.exist?(file)
|
|
217
|
+
|
|
218
|
+
load_yaml(file).merge(overrides)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def create_hooks
|
|
222
|
+
HooksInstaller.new(self).run
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def print_summary
|
|
226
|
+
puts '', '-' * 45, 'Setup complete!', ''
|
|
227
|
+
print_changes
|
|
228
|
+
print_next_steps
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def print_changes
|
|
232
|
+
puts 'Changes made:'
|
|
233
|
+
@changes.each { |c| puts " - #{c}" }
|
|
234
|
+
puts
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def print_next_steps
|
|
238
|
+
puts <<~STEPS
|
|
239
|
+
Next steps:
|
|
240
|
+
1. Run `bundle install` if Gemfile was updated
|
|
241
|
+
2. Run `standardrb` or `rubocop` to lint your code
|
|
242
|
+
3. Add .claude/linting.md to your AI assistant context
|
|
243
|
+
STEPS
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module RubocopClaude
|
|
6
|
+
class Generator
|
|
7
|
+
TEMPLATES_DIR = File.expand_path('../../templates', __dir__).freeze
|
|
8
|
+
|
|
9
|
+
def initialize(project_root)
|
|
10
|
+
@project_root = project_root
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run
|
|
14
|
+
ensure_directories
|
|
15
|
+
copy_linting_guide
|
|
16
|
+
copy_cop_guides
|
|
17
|
+
update_standard_yml if standard_yml_exists?
|
|
18
|
+
print_summary
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def ensure_directories
|
|
24
|
+
FileUtils.mkdir_p(claude_directory)
|
|
25
|
+
FileUtils.mkdir_p(cops_directory)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def claude_directory
|
|
29
|
+
File.join(@project_root, '.claude')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def cops_directory
|
|
33
|
+
File.join(claude_directory, 'cops')
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def copy_linting_guide
|
|
37
|
+
src = File.join(TEMPLATES_DIR, 'linting.md')
|
|
38
|
+
dest = File.join(claude_directory, 'linting.md')
|
|
39
|
+
FileUtils.cp(src, dest)
|
|
40
|
+
puts "Created #{relative_path(dest)}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def copy_cop_guides
|
|
44
|
+
cops_src_dir = File.join(TEMPLATES_DIR, 'cops')
|
|
45
|
+
Dir.glob(File.join(cops_src_dir, '*.md')).each do |src|
|
|
46
|
+
filename = File.basename(src)
|
|
47
|
+
dest = File.join(cops_directory, filename)
|
|
48
|
+
FileUtils.cp(src, dest)
|
|
49
|
+
puts "Created #{relative_path(dest)}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def standard_yml_exists?
|
|
54
|
+
File.exist?(standard_yml_path)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def standard_yml_path
|
|
58
|
+
File.join(@project_root, '.standard.yml')
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def update_standard_yml
|
|
62
|
+
content = File.read(standard_yml_path)
|
|
63
|
+
return puts "#{relative_path(standard_yml_path)} already includes rubocop-claude" if content.include?('rubocop-claude')
|
|
64
|
+
|
|
65
|
+
File.write(standard_yml_path, add_plugin_to_yaml(content))
|
|
66
|
+
puts "Updated #{relative_path(standard_yml_path)} to include rubocop-claude plugin"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def add_plugin_to_yaml(content)
|
|
70
|
+
return content.sub(/^(plugins:)\n/, "\\1\n - rubocop-claude\n") if content.include?('plugins:')
|
|
71
|
+
|
|
72
|
+
content.rstrip + "\n\nplugins:\n - rubocop-claude\n"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def print_summary
|
|
76
|
+
puts '', 'rubocop-claude initialized!', ''
|
|
77
|
+
puts 'Files created:'
|
|
78
|
+
puts ' .claude/linting.md - Main linting instructions'
|
|
79
|
+
puts ' .claude/cops/*.md - Per-cop fix guidance (loaded on-demand)'
|
|
80
|
+
puts '', 'Next steps:'
|
|
81
|
+
puts " 1. Add `gem 'rubocop-claude'` to your Gemfile"
|
|
82
|
+
puts ' 2. Run `bin/standardrb` to check for issues'
|
|
83
|
+
puts '', 'When Claude hits a lint error, it reads the relevant cop guide for fix instructions.'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def relative_path(path)
|
|
87
|
+
path.sub("#{@project_root}/", '')
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|