metz-scan 0.5.3 → 0.6.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/metz_scan/analyzers/inheritance_descendants.rb +1 -1
  3. data/lib/metz_scan/analyzers/namespace_leak_pressure.rb +1 -1
  4. data/lib/metz_scan/analyzers/package_dependency_pressure.rb +1 -1
  5. data/lib/metz_scan/analyzers/package_map.rb +1 -1
  6. data/lib/metz_scan/analyzers/test_calls_private_method/call_site_collector.rb +79 -0
  7. data/lib/metz_scan/analyzers/test_calls_private_method/finding.rb +23 -0
  8. data/lib/metz_scan/analyzers/test_calls_private_method/metadata.rb +27 -0
  9. data/lib/metz_scan/analyzers/test_calls_private_method/node_predicates.rb +57 -0
  10. data/lib/metz_scan/analyzers/test_calls_private_method/receiver_resolution.rb +80 -0
  11. data/lib/metz_scan/analyzers/test_calls_private_method/scope_facts.rb +76 -0
  12. data/lib/metz_scan/analyzers/test_calls_private_method/scope_resolution.rb +56 -0
  13. data/lib/metz_scan/analyzers/test_calls_private_method/triage.rb +23 -0
  14. data/lib/metz_scan/analyzers/test_calls_private_method.rb +125 -0
  15. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/readiness_catalog.rb +7 -3
  16. data/lib/metz_scan/cli.rb +7 -13
  17. data/lib/metz_scan/commands/explain.rb +11 -2
  18. data/lib/metz_scan/commands/project_analyzers.rb +3 -2
  19. data/lib/metz_scan/commands/report.rb +6 -5
  20. data/lib/metz_scan/commands/rules.rb +16 -4
  21. data/lib/metz_scan/commands/scan/auto_fix.rb +9 -7
  22. data/lib/metz_scan/commands/scan/config_exclusions.rb +76 -0
  23. data/lib/metz_scan/commands/scan/exclude_entries.rb +59 -0
  24. data/lib/metz_scan/commands/scan/exclude_entry_locator.rb +74 -0
  25. data/lib/metz_scan/commands/scan/exclude_provenance.rb +48 -0
  26. data/lib/metz_scan/commands/scan/inline_directive_locator.rb +53 -0
  27. data/lib/metz_scan/commands/scan/project_analyzer_runner.rb +6 -3
  28. data/lib/metz_scan/commands/scan/project_config_scope.rb +26 -4
  29. data/lib/metz_scan/commands/scan/runner.rb +9 -44
  30. data/lib/metz_scan/commands/scan/suppression_ledger.rb +76 -0
  31. data/lib/metz_scan/commands/scan/suppression_ledger_formatter.rb +48 -0
  32. data/lib/metz_scan/commands/scan/text_renderer.rb +5 -4
  33. data/lib/metz_scan/commands/scan.rb +5 -5
  34. data/lib/metz_scan/project_index/rubydex_backend/method_declarations.rb +33 -12
  35. data/lib/metz_scan/project_index/rubydex_backend/method_visibility.rb +152 -0
  36. data/lib/metz_scan/project_index/rubydex_backend.rb +2 -1
  37. data/lib/metz_scan/project_index.rb +1 -1
  38. data/lib/metz_scan/version.rb +1 -1
  39. data/metz-scan.gemspec +11 -10
  40. metadata +23 -5
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "optparse"
4
4
  require "yaml"
5
+ require_relative "../cli"
5
6
 
6
7
  module MetzScan
7
8
  module Commands
@@ -23,6 +24,8 @@ module MetzScan
23
24
  return missing_argument_error if args.empty?
24
25
 
25
26
  emit_or_error(args.first)
27
+ rescue OptionParser::ParseError => e
28
+ parser_error(e)
26
29
  end
27
30
 
28
31
  def emit_or_error(cop_name)
@@ -52,17 +55,23 @@ module MetzScan
52
55
  cop.cop_name.start_with?("Metz/") && cop.respond_to?(:metz_metadata)
53
56
  end
54
57
 
58
+ def parser_error(err)
59
+ stderr.puts "metz-scan explain: #{err.message}"
60
+ stderr.puts USAGE
61
+ CLI::USAGE_ERROR
62
+ end
63
+
55
64
  def missing_argument_error
56
65
  stderr.puts "metz-scan explain: missing required COP_NAME argument."
57
66
  stderr.puts USAGE
58
67
  stderr.puts "Run `metz-scan rules` to see the list of available Metz cops."
59
- 1
68
+ CLI::USAGE_ERROR
60
69
  end
61
70
 
62
71
  def unknown_cop_error(name)
63
72
  stderr.puts "metz-scan explain: no such cop '#{name}'."
64
73
  stderr.puts "Run `metz-scan rules` to see the list of available Metz cops."
65
- 1
74
+ CLI::USAGE_ERROR
66
75
  end
67
76
 
68
77
  def emit(cop)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "optparse"
5
+ require_relative "../cli"
5
6
 
6
7
  require_relative "scan/project_analyzer_runner"
7
8
 
@@ -21,7 +22,7 @@ module MetzScan
21
22
 
22
23
  def run(argv)
23
24
  options = parse_options(argv)
24
- return 1 unless options
25
+ return CLI::USAGE_ERROR unless options
25
26
 
26
27
  entries = analyzer_entries
27
28
  options[:json] ? emit_json(entries) : emit_table(entries)
@@ -41,7 +42,7 @@ module MetzScan
41
42
  end
42
43
 
43
44
  def parse_error(error)
44
- stderr.puts error.message
45
+ stderr.puts "metz-scan project-analyzers: #{error.message}"
45
46
  stderr.puts option_parser.help
46
47
  nil
47
48
  end
@@ -8,6 +8,7 @@ require_relative "scan/compliance_scorecard"
8
8
  require_relative "scan/text_renderer"
9
9
  require_relative "scan/sarif_renderer"
10
10
  require_relative "scan/github_annotations_renderer"
11
+ require_relative "../cli"
11
12
 
12
13
  module MetzScan
13
14
  module Commands
@@ -87,28 +88,28 @@ module MetzScan
87
88
  def parser_error(err)
88
89
  stderr.puts "metz-scan report: #{err.message}"
89
90
  stderr.puts USAGE
90
- 1
91
+ CLI::USAGE_ERROR
91
92
  end
92
93
 
93
94
  def missing_path_arg
94
95
  stderr.puts "metz-scan report: missing PATH argument."
95
96
  stderr.puts USAGE
96
- 1
97
+ CLI::USAGE_ERROR
97
98
  end
98
99
 
99
100
  def missing_file(path)
100
101
  stderr.puts "metz-scan report: no such file: #{path}"
101
- 1
102
+ CLI::USAGE_ERROR
102
103
  end
103
104
 
104
105
  def invalid_format(fmt)
105
106
  stderr.puts "metz-scan report: invalid --format '#{fmt}'. Valid formats: text, json, sarif, gh-annotations."
106
- 1
107
+ CLI::USAGE_ERROR
107
108
  end
108
109
 
109
110
  def invalid_json(path, detail)
110
111
  stderr.puts "metz-scan report: invalid JSON in #{path}: #{detail}"
111
- 1
112
+ CLI::USAGE_ERROR
112
113
  end
113
114
  end
114
115
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "optparse"
5
+ require_relative "../cli"
5
6
 
6
7
  module MetzScan
7
8
  module Commands
@@ -23,16 +24,27 @@ module MetzScan
23
24
  end
24
25
 
25
26
  def run(argv)
26
- options = parse_options(argv)
27
- cops = load_cops
28
- options[:json] ? emit_json(cops) : emit_table(cops)
29
- 0
27
+ emit(parse_options(argv))
28
+ rescue OptionParser::ParseError => e
29
+ parser_error(e)
30
30
  end
31
31
 
32
32
  private
33
33
 
34
34
  attr_reader :stdout, :stderr
35
35
 
36
+ def emit(options)
37
+ cops = load_cops
38
+ options[:json] ? emit_json(cops) : emit_table(cops)
39
+ 0
40
+ end
41
+
42
+ def parser_error(err)
43
+ stderr.puts "metz-scan rules: #{err.message}"
44
+ stderr.puts USAGE
45
+ CLI::USAGE_ERROR
46
+ end
47
+
36
48
  def parse_options(argv)
37
49
  options = { json: false }
38
50
  OptionParser.new { |opts| configure_options(opts, options) }.parse!(argv)
@@ -8,8 +8,6 @@ module MetzScan
8
8
  module Commands
9
9
  class Scan
10
10
  class AutoFix
11
- SAFE_FLAG = "-a"
12
- UNSAFE_FLAG = "-A"
13
11
  CommandResult = Struct.new(:status, :stdout, :stderr, keyword_init: true)
14
12
 
15
13
  def initialize(stdout:, stderr:)
@@ -63,8 +61,10 @@ module MetzScan
63
61
  end
64
62
 
65
63
  def rubocop_argv(options)
66
- flag = options.unsafe ? UNSAFE_FLAG : SAFE_FLAG
67
- ["--plugin", "rubocop-metz", *Runner.cop_selection_argv(options.all_cops), flag, *options.paths]
64
+ flag = options.unsafe ? "-A" : "-a"
65
+ selection = Runner.cop_selection_argv(options.all_cops)
66
+ selection += ["--disable-pending-cops"] unless options.all_cops
67
+ ["--plugin", "rubocop-metz", *selection, flag, *options.paths]
68
68
  end
69
69
 
70
70
  def capture_rubocop(argv)
@@ -103,8 +103,7 @@ module MetzScan
103
103
  end
104
104
 
105
105
  def print_file_diff(path, original)
106
- return unless File.exist?(path)
107
- return if File.binread(path) == original
106
+ return unless File.exist?(path) && changed?(path, original)
108
107
 
109
108
  stdout.puts diff_against(path, original)
110
109
  end
@@ -130,8 +129,11 @@ module MetzScan
130
129
  def restore_files(snapshot)
131
130
  return unless snapshot
132
131
 
133
- snapshot.each { |path, content| File.binwrite(path, content) }
132
+ snapshot.each { |path, content| File.binwrite(path, content) if changed?(path, content) }
134
133
  end
134
+
135
+ # Only rewrite what RuboCop touched, so an unchanged read-only file cannot abort the restore.
136
+ def changed?(path, original) = !File.exist?(path) || File.binread(path) != original
135
137
  end
136
138
  end
137
139
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ require "metz_scan/commands/scan/exclude_entry_locator"
6
+
7
+ module MetzScan
8
+ module Commands
9
+ class Scan
10
+ # Default mode runs stock Metz config (--force-default-config) but must
11
+ # still honor the project's per-cop file scope (Include/Exclude), the
12
+ # same way #33 honors AllCops: Exclude. RuboCop's own excluded_file?
13
+ # resolves the project config's scope per cop (#37); offenses on files a
14
+ # cop is scoped off are hidden, and the project Exclude entry that hid
15
+ # one is credited in the suppression ledger.
16
+ class ConfigExclusions
17
+ def initialize(store)
18
+ @store = store
19
+ @locators = {}
20
+ end
21
+
22
+ def scoped_off?(path, offense)
23
+ cop_class = cop_class(offense)
24
+ return false unless cop_class
25
+
26
+ absolute = File.expand_path(path)
27
+ cop_class.new(store.for_file(absolute)).excluded_file?(absolute)
28
+ end
29
+
30
+ def record_fields(path, offense)
31
+ entry = store.applied_exclude(cop_class(offense).badge, File.expand_path(path))
32
+ return unless entry
33
+
34
+ location = locator(entry.config_path).locate(entry.key, entry.pattern)
35
+ { "suppressed_by" => "config_exclude", "reason" => location.reason,
36
+ "reason_status" => location.reason_status, "directive" => nil, "config" => config(entry, location) }
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :store
42
+
43
+ def cop_class(offense)
44
+ RuboCop::Cop::Registry.global.find_by_cop_name(offense.fetch("cop_name"))
45
+ end
46
+
47
+ def config(entry, location)
48
+ { "path" => Runner.display_path(entry.config_path), "line" => location.line,
49
+ "pattern" => display(entry.pattern) }
50
+ end
51
+
52
+ def display(pattern)
53
+ pattern.is_a?(Regexp) ? pattern.inspect : pattern
54
+ end
55
+
56
+ def locator(config_path)
57
+ @locators[config_path] ||= ExcludeEntryLocator.new(config_path)
58
+ end
59
+ end
60
+
61
+ # Invalid project config leaves no project scope to honor, matching the
62
+ # forced-default target-discovery fallback.
63
+ module NoConfigExclusions
64
+ module_function
65
+
66
+ def scoped_off?(_path, _offense)
67
+ false
68
+ end
69
+
70
+ def record_fields(_path, _offense)
71
+ nil
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ module MetzScan
6
+ module Commands
7
+ class Scan
8
+ module ProjectConfigScope
9
+ # The per-cop Exclude entries a scope-only config applies, each mapped
10
+ # back to the config file that wrote it. Entries are paired with their
11
+ # source by object identity before make_excludes_absolute rewrites
12
+ # them, so an entry a derived file overrides is never credited.
13
+ class ExcludeEntries
14
+ Entry = Struct.new(:config_path, :key, :pattern, keyword_init: true)
15
+
16
+ def self.patterns(config)
17
+ config.to_h.filter_map { |key, value| [key, value["Exclude"].dup] if per_cop_exclude?(key, value) }.to_h
18
+ end
19
+
20
+ def self.per_cop_exclude?(key, value)
21
+ key != "AllCops" && value.is_a?(Hash) && value["Exclude"].is_a?(Array)
22
+ end
23
+
24
+ def initialize(raw_patterns = {}, absolute_patterns = {}, sources = {})
25
+ @entries = {}
26
+ raw_patterns.each do |key, patterns|
27
+ patterns.zip(absolute_patterns.fetch(key)).each { |raw, absolute| index(key, raw, absolute, sources) }
28
+ end
29
+ end
30
+
31
+ # The first Exclude pattern a cop applies to `file` that a project
32
+ # config wrote; stock-default patterns have no source and are skipped.
33
+ def applied(config, badge, file)
34
+ relative = config.path_relative_to_config(file)
35
+ Array(config.for_badge(badge)["Exclude"]).lazy.filter_map do |pattern|
36
+ sourced_entry(badge, pattern) if matches?(pattern, relative, file)
37
+ end.first
38
+ end
39
+
40
+ private
41
+
42
+ def index(key, raw, absolute, sources)
43
+ return unless sources.key?(raw)
44
+
45
+ @entries[[key, absolute]] = Entry.new(config_path: sources.fetch(raw), key: key, pattern: raw)
46
+ end
47
+
48
+ def sourced_entry(badge, pattern)
49
+ @entries[[badge.to_s, pattern]] || @entries[[badge.department_name, pattern]]
50
+ end
51
+
52
+ def matches?(pattern, relative, file)
53
+ RuboCop::PathUtil.match_path?(pattern, relative) || RuboCop::PathUtil.match_path?(pattern, file)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "erb"
4
+ require "psych"
5
+
6
+ module MetzScan
7
+ module Commands
8
+ class Scan
9
+ # Finds where a config file writes one Exclude entry and the reason
10
+ # comment documenting it. ERB configs are read as RuboCop renders them;
11
+ # their rendered line numbers do not match the file, so no line is given.
12
+ class ExcludeEntryLocator
13
+ Location = Struct.new(:line, :reason, :reason_status, keyword_init: true)
14
+ UNCHECKED = Location.new(line: nil, reason: nil, reason_status: "unchecked").freeze
15
+ COMMENT_LINE = /\A\s*#/
16
+ TRAILING_COMMENT = /(?:\A|\s)#(.*)\z/
17
+
18
+ def initialize(config_path)
19
+ raw = File.read(config_path, encoding: Encoding::UTF_8)
20
+ rendered = Dir.chdir(File.dirname(config_path)) { ERB.new(raw).result }
21
+ @erb = rendered != raw
22
+ @lines = rendered.lines.map(&:chomp)
23
+ @root = Psych.parse(rendered).root
24
+ end
25
+
26
+ def locate(key, pattern)
27
+ exclude_key, entry = entry_nodes(key, pattern)
28
+ return UNCHECKED unless entry
29
+
30
+ reason = entry_reason(entry) || key_reason(exclude_key)
31
+ Location.new(line: (entry.start_line + 1 unless @erb), reason: reason,
32
+ reason_status: reason ? "present" : "missing")
33
+ end
34
+
35
+ private
36
+
37
+ def entry_nodes(key, pattern)
38
+ exclude_key, excludes = mapping_pair(mapping_pair(@root, key)&.last, "Exclude")
39
+ return unless excludes.is_a?(Psych::Nodes::Sequence)
40
+
41
+ [exclude_key, excludes.children.find { |node| node.is_a?(Psych::Nodes::Scalar) && node.value == pattern }]
42
+ end
43
+
44
+ def mapping_pair(node, key)
45
+ return unless node.is_a?(Psych::Nodes::Mapping)
46
+
47
+ node.children.each_slice(2).find { |name, _value| name.is_a?(Psych::Nodes::Scalar) && name.value == key }
48
+ end
49
+
50
+ def entry_reason(entry)
51
+ trailing_comment(entry) || comment_block_above(entry.start_line)
52
+ end
53
+
54
+ def key_reason(exclude_key)
55
+ trailing_comment(exclude_key) || comment_block_above(exclude_key.start_line)
56
+ end
57
+
58
+ def trailing_comment(node)
59
+ comment_text(@lines.fetch(node.end_line)[node.end_column..][TRAILING_COMMENT, 1])
60
+ end
61
+
62
+ def comment_block_above(line_index)
63
+ block = @lines.first(line_index).reverse.take_while { |line| line.match?(COMMENT_LINE) }
64
+ comment_text(block.reverse.map { |line| line.sub(COMMENT_LINE, "").strip }.join(" "))
65
+ end
66
+
67
+ def comment_text(text)
68
+ stripped = text.to_s.strip
69
+ stripped unless stripped.empty?
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ require "metz_scan/commands/scan/exclude_entries"
6
+
7
+ module MetzScan
8
+ module Commands
9
+ class Scan
10
+ module ProjectConfigScope
11
+ # Remembers which config file wrote each Exclude entry the scope-only
12
+ # loader reads, so the suppression ledger can credit the entry a cop
13
+ # actually applies. Raw YAML entries are tracked by object identity:
14
+ # two files writing the same pattern stay distinct.
15
+ class ExcludeProvenance
16
+ def initialize
17
+ @sources = {}.compare_by_identity
18
+ @entries = {}
19
+ end
20
+
21
+ def load_yaml_configuration(absolute_path)
22
+ RuboCop::ConfigLoader.load_yaml_configuration(absolute_path).tap do |raw_hash|
23
+ record_sources(raw_hash, absolute_path)
24
+ end
25
+ end
26
+
27
+ def make_excludes_absolute(config)
28
+ raw_patterns = ExcludeEntries.patterns(config)
29
+ config.make_excludes_absolute
30
+ @entries[config.loaded_path] = ExcludeEntries.new(raw_patterns, ExcludeEntries.patterns(config), @sources)
31
+ end
32
+
33
+ def entries_for(config)
34
+ @entries.fetch(config.loaded_path) { ExcludeEntries.new }
35
+ end
36
+
37
+ private
38
+
39
+ def record_sources(raw_hash, absolute_path)
40
+ raw_hash.each_value do |value|
41
+ Array(value["Exclude"]).each { |entry| @sources[entry] = absolute_path } if value.is_a?(Hash)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ module MetzScan
6
+ module Commands
7
+ class Scan
8
+ # Credits a suppressed offense to the innermost real directive comment
9
+ # covering it. Ranges RuboCop opens at -Infinity for opt-in cops are
10
+ # synthetic (CommentConfig::ConfigDisabledCopDirectiveComment), not a
11
+ # comment anyone wrote, so they credit nothing.
12
+ class InlineDirectiveLocator
13
+ def initialize
14
+ @ranges = {}
15
+ end
16
+
17
+ def record_fields(path, offense)
18
+ directive = covering_directive(path, offense)
19
+ return unless directive
20
+
21
+ reason = directive.reason
22
+ { "suppressed_by" => "inline_disable", "reason" => reason, "reason_status" => reason ? "present" : "missing",
23
+ "directive" => { "line" => directive.line_number }, "config" => nil }
24
+ end
25
+
26
+ private
27
+
28
+ def covering_directive(path, offense)
29
+ location = offense.fetch("location")
30
+ ranges_for(path).fetch(offense.fetch("cop_name"), [])
31
+ .select { |range| covers?(range, location) }
32
+ .max_by(&:begin)&.directive
33
+ end
34
+
35
+ def covers?(range, location)
36
+ range.is_a?(RuboCop::CommentConfig::DirectiveRange) && range.begin.finite? &&
37
+ range.end >= location.fetch("start_line") && range.begin <= location.fetch("last_line")
38
+ end
39
+
40
+ def ranges_for(path)
41
+ @ranges[path] ||= processed_source(path).comment_config.cop_disabled_line_ranges
42
+ end
43
+
44
+ def processed_source(path)
45
+ config = RuboCop::ConfigLoader.default_configuration
46
+ RuboCop::ProcessedSource.from_file(path, config.target_ruby_version, parser_engine: config.parser_engine)
47
+ .tap { |source| source.config = config }
48
+ .tap { |source| source.registry = RuboCop::Cop::Registry.global }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -8,6 +8,7 @@ require_relative "../../analyzers/namespace_leak_pressure"
8
8
  require_relative "../../analyzers/implicit_context_pressure"
9
9
  require_relative "../../analyzers/repeated_query_criteria"
10
10
  require_relative "../../analyzers/subclass_override_pressure"
11
+ require_relative "../../analyzers/test_calls_private_method"
11
12
  require_relative "../../project_index"
12
13
  require_relative "runner"
13
14
  require_relative "project_analyzer_metadata"
@@ -27,10 +28,12 @@ module MetzScan
27
28
  Analyzers::NamespaceLeakPressure,
28
29
  Analyzers::ImplicitContextPressure,
29
30
  Analyzers::RepeatedQueryCriteria,
30
- Analyzers::SubclassOverridePressure
31
+ Analyzers::SubclassOverridePressure,
32
+ Analyzers::TestCallsPrivateMethod
31
33
  ].freeze
32
34
  INDEX_BACKED_ANALYZERS = [Analyzers::InheritanceDescendants, Analyzers::PackageDependencyPressure,
33
- Analyzers::NamespaceLeakPressure, Analyzers::SubclassOverridePressure].freeze
35
+ Analyzers::NamespaceLeakPressure, Analyzers::SubclassOverridePressure,
36
+ Analyzers::TestCallsPrivateMethod].freeze
34
37
  DEFAULT_OUTPUT_STATUS = "validated"
35
38
  DEFAULT_OUTPUT_CONFIDENCE = "medium"
36
39
  DEFAULT_OUTPUT_TRIAGE_SEVERITY = "design pressure"
@@ -80,7 +83,7 @@ module MetzScan
80
83
 
81
84
  def project_index_for(paths, index, analyzers)
82
85
  return index if index
83
- return nil unless analyzers.any? { |analyzer| INDEX_BACKED_ANALYZERS.include?(analyzer) }
86
+ return nil unless analyzers.intersect?(INDEX_BACKED_ANALYZERS)
84
87
 
85
88
  ProjectIndex.build(paths)
86
89
  end
@@ -3,6 +3,8 @@
3
3
  require "pathname"
4
4
  require "rubocop"
5
5
 
6
+ require "metz_scan/commands/scan/exclude_provenance"
7
+
6
8
  module MetzScan
7
9
  module Commands
8
10
  class Scan
@@ -104,9 +106,18 @@ module MetzScan
104
106
  path = @path_cache[dir] ||= RuboCop::ConfigLoader.configuration_file_for(dir)
105
107
  @object_cache[path] ||= @loader.load(path)
106
108
  end
109
+
110
+ # The project Exclude entry (with its source config file) that scopes
111
+ # the cop off `file`, or nil when only a stock default does.
112
+ def applied_exclude(badge, file)
113
+ config = for_file(file)
114
+ @loader.provenance.entries_for(config).applied(config, badge, file)
115
+ end
107
116
  end
108
117
 
109
118
  class ConfigLoader
119
+ def provenance = @provenance ||= ExcludeProvenance.new
120
+
110
121
  def default_for(dir)
111
122
  path = File.join(File.expand_path(dir), ".rubocop.yml")
112
123
  config = RuboCop::Config.new({}, path)
@@ -118,7 +129,7 @@ module MetzScan
118
129
 
119
130
  config = RuboCop::Config.new(scope_hash(path), path)
120
131
  config.deprecation_check { |_message| nil }
121
- config.make_excludes_absolute
132
+ provenance.make_excludes_absolute(config)
122
133
  RuboCop::ConfigLoader.merge_with_default(config, path)
123
134
  end
124
135
 
@@ -128,17 +139,28 @@ module MetzScan
128
139
  absolute_path = File.expand_path(path)
129
140
  return {} if seen.include?(absolute_path)
130
141
 
131
- raw_hash = RuboCop::ConfigLoader.load_yaml_configuration(absolute_path)
142
+ raw_hash = provenance.load_yaml_configuration(absolute_path)
132
143
  inherited = inherited_scope_hash(raw_hash, absolute_path, [*seen, absolute_path])
133
- RuboCop::ConfigLoader.merge(inherited, local_scope_hash(raw_hash))
144
+ merge(inherited, local_scope_hash(raw_hash), raw_hash["inherit_mode"])
134
145
  end
135
146
 
136
147
  def inherited_scope_hash(raw_hash, path, seen)
137
148
  inherited_paths(raw_hash, path).each_with_object({}) do |inherited_path, merged|
138
- merged.replace(RuboCop::ConfigLoader.merge(merged, scope_hash(inherited_path, seen)))
149
+ merged.replace(merge(merged, scope_hash(inherited_path, seen), raw_hash["inherit_mode"]))
139
150
  end
140
151
  end
141
152
 
153
+ # RuboCop's resolve_inheritance merges every inherited file with the
154
+ # inheriting file's top-level inherit_mode, so sibling inherit_from
155
+ # files union under `merge: [Exclude]` too.
156
+ def merge(base_hash, derived_hash, inherit_mode)
157
+ resolver.merge(base_hash, derived_hash, inherit_mode: inherit_mode || {})
158
+ end
159
+
160
+ def resolver
161
+ @resolver ||= RuboCop::ConfigLoaderResolver.new
162
+ end
163
+
142
164
  def inherited_paths(raw_hash, path)
143
165
  gem_inherited_paths(raw_hash["inherit_gem"], path) + local_inherited_paths(raw_hash["inherit_from"], path)
144
166
  end