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,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