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.
- checksums.yaml +4 -4
- data/lib/metz_scan/analyzers/inheritance_descendants.rb +1 -1
- data/lib/metz_scan/analyzers/namespace_leak_pressure.rb +1 -1
- data/lib/metz_scan/analyzers/package_dependency_pressure.rb +1 -1
- data/lib/metz_scan/analyzers/package_map.rb +1 -1
- data/lib/metz_scan/analyzers/test_calls_private_method/call_site_collector.rb +79 -0
- data/lib/metz_scan/analyzers/test_calls_private_method/finding.rb +23 -0
- data/lib/metz_scan/analyzers/test_calls_private_method/metadata.rb +27 -0
- data/lib/metz_scan/analyzers/test_calls_private_method/node_predicates.rb +57 -0
- data/lib/metz_scan/analyzers/test_calls_private_method/receiver_resolution.rb +80 -0
- data/lib/metz_scan/analyzers/test_calls_private_method/scope_facts.rb +76 -0
- data/lib/metz_scan/analyzers/test_calls_private_method/scope_resolution.rb +56 -0
- data/lib/metz_scan/analyzers/test_calls_private_method/triage.rb +23 -0
- data/lib/metz_scan/analyzers/test_calls_private_method.rb +125 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/readiness_catalog.rb +7 -3
- data/lib/metz_scan/cli.rb +7 -13
- data/lib/metz_scan/commands/explain.rb +11 -2
- data/lib/metz_scan/commands/project_analyzers.rb +3 -2
- data/lib/metz_scan/commands/report.rb +6 -5
- data/lib/metz_scan/commands/rules.rb +16 -4
- data/lib/metz_scan/commands/scan/auto_fix.rb +9 -7
- data/lib/metz_scan/commands/scan/config_exclusions.rb +76 -0
- data/lib/metz_scan/commands/scan/exclude_entries.rb +59 -0
- data/lib/metz_scan/commands/scan/exclude_entry_locator.rb +74 -0
- data/lib/metz_scan/commands/scan/exclude_provenance.rb +48 -0
- data/lib/metz_scan/commands/scan/inline_directive_locator.rb +53 -0
- data/lib/metz_scan/commands/scan/project_analyzer_runner.rb +6 -3
- data/lib/metz_scan/commands/scan/project_config_scope.rb +26 -4
- data/lib/metz_scan/commands/scan/runner.rb +9 -44
- data/lib/metz_scan/commands/scan/suppression_ledger.rb +76 -0
- data/lib/metz_scan/commands/scan/suppression_ledger_formatter.rb +48 -0
- data/lib/metz_scan/commands/scan/text_renderer.rb +5 -4
- data/lib/metz_scan/commands/scan.rb +5 -5
- data/lib/metz_scan/project_index/rubydex_backend/method_declarations.rb +33 -12
- data/lib/metz_scan/project_index/rubydex_backend/method_visibility.rb +152 -0
- data/lib/metz_scan/project_index/rubydex_backend.rb +2 -1
- data/lib/metz_scan/project_index.rb +1 -1
- data/lib/metz_scan/version.rb +1 -1
- data/metz-scan.gemspec +11 -10
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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 ?
|
|
67
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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 =
|
|
142
|
+
raw_hash = provenance.load_yaml_configuration(absolute_path)
|
|
132
143
|
inherited = inherited_scope_hash(raw_hash, absolute_path, [*seen, absolute_path])
|
|
133
|
-
|
|
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(
|
|
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
|