rubocop-metz 0.5.2
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.
Potentially problematic release.
This version of rubocop-metz might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/lib/metz_scan/analyzers/contextual_node_walker.rb +82 -0
- data/lib/metz_scan/analyzers/cross_package_reference_metadata.rb +36 -0
- data/lib/metz_scan/analyzers/cross_package_reference_set.rb +33 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_collector.rb +54 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_reference.rb +37 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/current_attribute_collector.rb +73 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/reference_set.rb +37 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/thread_current_collector.rb +76 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/triage.rb +109 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure.rb +139 -0
- data/lib/metz_scan/analyzers/inheritance_descendants/finding.rb +18 -0
- data/lib/metz_scan/analyzers/inheritance_descendants/metadata.rb +23 -0
- data/lib/metz_scan/analyzers/inheritance_descendants/root_kind.rb +58 -0
- data/lib/metz_scan/analyzers/inheritance_descendants/triage.rb +30 -0
- data/lib/metz_scan/analyzers/inheritance_descendants.rb +137 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/finding.rb +20 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/metadata.rb +40 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/namespace.rb +41 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_collector.rb +50 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_set.rb +12 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage/exception_declaration.rb +117 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage.rb +69 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure.rb +139 -0
- data/lib/metz_scan/analyzers/occurrence.rb +28 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/finding.rb +20 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/metadata.rb +27 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/package_map.rb +11 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/reference_collector.rb +38 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/reference_set.rb +12 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/shared_dependency_triage.rb +124 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure.rb +139 -0
- data/lib/metz_scan/analyzers/package_map.rb +81 -0
- data/lib/metz_scan/analyzers/project_analyzer_triage.rb +20 -0
- data/lib/metz_scan/analyzers/repeated_branching/branch_site_collector.rb +138 -0
- data/lib/metz_scan/analyzers/repeated_branching/branch_value.rb +27 -0
- data/lib/metz_scan/analyzers/repeated_branching/contextual_node_walker.rb +11 -0
- data/lib/metz_scan/analyzers/repeated_branching/decision_subject.rb +46 -0
- data/lib/metz_scan/analyzers/repeated_branching/predicate_chain.rb +58 -0
- data/lib/metz_scan/analyzers/repeated_branching/triage.rb +28 -0
- data/lib/metz_scan/analyzers/repeated_branching.rb +135 -0
- data/lib/metz_scan/analyzers/repeated_query_criteria/triage.rb +103 -0
- data/lib/metz_scan/analyzers/repeated_query_criteria.rb +285 -0
- data/lib/metz_scan/analyzers/ruby_file_enumerator.rb +37 -0
- data/lib/metz_scan/analyzers/service_soup/service_call_pattern.rb +53 -0
- data/lib/metz_scan/analyzers/service_soup/setup_workflow_triage.rb +48 -0
- data/lib/metz_scan/analyzers/service_soup/workflow_collector.rb +130 -0
- data/lib/metz_scan/analyzers/service_soup.rb +128 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure/family_builder.rb +69 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure/finding.rb +30 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure/metadata.rb +74 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure/method_body_facts.rb +100 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure/triage.rb +106 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure.rb +133 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/artifact_writer.rb +51 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/baseline_delta.rb +231 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/collaborators.rb +76 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/markdown_renderer.rb +353 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/readiness_catalog.rb +123 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/summary.rb +333 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/target_set.rb +139 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner.rb +64 -0
- data/lib/metz_scan/calibration/rubydex_drift_formatter.rb +91 -0
- data/lib/metz_scan/cli.rb +120 -0
- data/lib/metz_scan/commands/explain.rb +128 -0
- data/lib/metz_scan/commands/project_analyzers.rb +105 -0
- data/lib/metz_scan/commands/report.rb +115 -0
- data/lib/metz_scan/commands/rules.rb +92 -0
- data/lib/metz_scan/commands/scan/auto_fix.rb +138 -0
- data/lib/metz_scan/commands/scan/compliance_scorecard.rb +124 -0
- data/lib/metz_scan/commands/scan/github_annotations_renderer.rb +55 -0
- data/lib/metz_scan/commands/scan/offense_extractor.rb +42 -0
- data/lib/metz_scan/commands/scan/project_analyzer_breakdown.rb +55 -0
- data/lib/metz_scan/commands/scan/project_analyzer_metadata.rb +78 -0
- data/lib/metz_scan/commands/scan/project_analyzer_offenses.rb +85 -0
- data/lib/metz_scan/commands/scan/project_analyzer_runner.rb +148 -0
- data/lib/metz_scan/commands/scan/project_analyzer_summary_aggregate_formatter.rb +74 -0
- data/lib/metz_scan/commands/scan/project_analyzer_summary_breakdown_formatter.rb +46 -0
- data/lib/metz_scan/commands/scan/project_analyzer_triage_formatter.rb +40 -0
- data/lib/metz_scan/commands/scan/project_analyzer_triage_priority.rb +38 -0
- data/lib/metz_scan/commands/scan/project_config_scope.rb +231 -0
- data/lib/metz_scan/commands/scan/runner.rb +199 -0
- data/lib/metz_scan/commands/scan/sarif_renderer.rb +103 -0
- data/lib/metz_scan/commands/scan/sarif_rule_descriptors.rb +82 -0
- data/lib/metz_scan/commands/scan/sarif_severity.rb +27 -0
- data/lib/metz_scan/commands/scan/target_ruby_version.rb +56 -0
- data/lib/metz_scan/commands/scan/text_renderer.rb +145 -0
- data/lib/metz_scan/commands/scan.rb +201 -0
- data/lib/metz_scan/project_index/null_backend.rb +35 -0
- data/lib/metz_scan/project_index/rubydex_backend/file_discovery.rb +33 -0
- data/lib/metz_scan/project_index/rubydex_backend/location_formatting.rb +36 -0
- data/lib/metz_scan/project_index/rubydex_backend/method_declarations.rb +118 -0
- data/lib/metz_scan/project_index/rubydex_backend.rb +133 -0
- data/lib/metz_scan/project_index.rb +109 -0
- data/lib/metz_scan/version.rb +5 -0
- data/lib/metz_scan.rb +5 -0
- metadata +185 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "psych"
|
|
5
|
+
require "rubocop"
|
|
6
|
+
require "stringio"
|
|
7
|
+
|
|
8
|
+
require "metz_scan/commands/scan/project_config_scope"
|
|
9
|
+
require "metz_scan/commands/scan/target_ruby_version"
|
|
10
|
+
|
|
11
|
+
module MetzScan
|
|
12
|
+
module Commands
|
|
13
|
+
class Scan
|
|
14
|
+
module Runner
|
|
15
|
+
FORMATTER = "RuboCop::Formatter::MetzJsonFormatter"
|
|
16
|
+
|
|
17
|
+
class Error < StandardError; end
|
|
18
|
+
|
|
19
|
+
Result = Struct.new(:stdout, :stderr, :status, keyword_init: true)
|
|
20
|
+
|
|
21
|
+
def self.invoke(paths, all_cops: false, stderr: $stderr)
|
|
22
|
+
with_errors { perform(paths, all_cops: all_cops, stderr: stderr) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.perform(paths, all_cops:, stderr:)
|
|
26
|
+
ProjectConfigScope.reset_unresolved_inherit_gems! unless all_cops
|
|
27
|
+
paths = inspection_paths(paths, all_cops: all_cops)
|
|
28
|
+
report = paths.empty? ? empty_report : scan(paths, all_cops: all_cops)
|
|
29
|
+
report.tap { ProjectConfigScope.warn_unresolved_inherit_gems(stderr) unless all_cops }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.scan(paths, all_cops:)
|
|
33
|
+
report = TargetRubyVersion.with_project_config(paths, all_cops: all_cops) do
|
|
34
|
+
parse_output(capture_output(rubocop_argv(paths, all_cops: all_cops)))
|
|
35
|
+
end
|
|
36
|
+
all_cops ? report : ProjectCopScope.honor(report)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.with_errors
|
|
40
|
+
yield
|
|
41
|
+
rescue LoadError, StandardError => e
|
|
42
|
+
raise Error, concise_message(e.message)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.rubocop_argv(paths, all_cops:)
|
|
46
|
+
require_metz_plugin
|
|
47
|
+
["--plugin", "rubocop-metz", *cop_selection_argv(all_cops), "--format", FORMATTER, *paths]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.require_metz_plugin
|
|
51
|
+
require "rubocop-metz"
|
|
52
|
+
rescue LoadError => e
|
|
53
|
+
raise Error, "could not load rubocop-metz: #{e.message}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.inspection_paths(paths, all_cops:)
|
|
57
|
+
return paths if all_cops
|
|
58
|
+
|
|
59
|
+
TargetFileDiscovery.for_project_config(paths).map { |path| display_path(path) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def self.cop_selection_argv(all_cops)
|
|
63
|
+
all_cops ? [] : ["--force-default-config", "--enable-all-cops", "--only", "Metz"]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def self.capture_output(argv)
|
|
67
|
+
out = StringIO.new
|
|
68
|
+
err = StringIO.new
|
|
69
|
+
status = with_redirected_io(out, err) { RuboCop::CLI.new.run(argv) }
|
|
70
|
+
Result.new(stdout: out.string, stderr: err.string, status: status)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def self.with_redirected_io(out, err)
|
|
74
|
+
original = redirect_to(out, err)
|
|
75
|
+
yield
|
|
76
|
+
ensure
|
|
77
|
+
restore_io(original) if original
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def self.redirect_to(out, err)
|
|
81
|
+
previous = [$stdout, $stderr]
|
|
82
|
+
$stdout = out
|
|
83
|
+
$stderr = err
|
|
84
|
+
previous
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.restore_io(previous)
|
|
88
|
+
$stdout = previous[0]
|
|
89
|
+
$stderr = previous[1]
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def self.parse_output(result)
|
|
93
|
+
JSON.parse(result.stdout)
|
|
94
|
+
rescue JSON::ParserError
|
|
95
|
+
raise Error, failure_message(result)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def self.failure_message(result)
|
|
99
|
+
detail = concise_message(result.stderr)
|
|
100
|
+
detail = concise_message(result.stdout) if detail.empty?
|
|
101
|
+
return detail unless detail.empty?
|
|
102
|
+
|
|
103
|
+
"RuboCop exited with status #{result.status} without JSON output"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.concise_message(text)
|
|
107
|
+
text.to_s.lines.map(&:strip)
|
|
108
|
+
.reject(&:empty?)
|
|
109
|
+
.reject { |line| stack_trace_line?(line) }
|
|
110
|
+
.first(3)
|
|
111
|
+
.join(" ")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.stack_trace_line?(line)
|
|
115
|
+
line == "Traceback (most recent call last):" || line.match?(/:\d+:in [`']/)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def self.exit_code_for(parsed)
|
|
119
|
+
Array(parsed["files"]).any? { |f| Array(f["offenses"]).any? } ? 1 : 0
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def self.empty_report
|
|
123
|
+
{ "metadata" => metadata, "files" => [],
|
|
124
|
+
"summary" => { "offense_count" => 0, "target_file_count" => 0, "inspected_file_count" => 0 } }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.metadata
|
|
128
|
+
{ "rubocop_version" => RuboCop::Version::STRING, "ruby_engine" => RUBY_ENGINE,
|
|
129
|
+
"ruby_version" => RUBY_VERSION, "ruby_patchlevel" => RUBY_PATCHLEVEL.to_s,
|
|
130
|
+
"ruby_platform" => RUBY_PLATFORM }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.display_path(path)
|
|
134
|
+
expanded_path = File.expand_path(path)
|
|
135
|
+
cwd = "#{File.expand_path(Dir.pwd)}#{File::SEPARATOR}"
|
|
136
|
+
return expanded_path.delete_prefix(cwd) if expanded_path.start_with?(cwd)
|
|
137
|
+
|
|
138
|
+
expanded_path
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Default mode forces stock Metz *tuning* (--force-default-config) but must
|
|
143
|
+
# still honor the project's per-cop file *scope* (Include/Exclude), the
|
|
144
|
+
# same way #33 honors AllCops: Exclude. RuboCop's own excluded_file?
|
|
145
|
+
# resolves the project config's scope per cop; offenses on files a cop is
|
|
146
|
+
# scoped off are dropped here. Invalid project config -> nothing to honor
|
|
147
|
+
# (matches the forced-default target-discovery fallback). See #37.
|
|
148
|
+
module ProjectCopScope
|
|
149
|
+
module_function
|
|
150
|
+
|
|
151
|
+
def honor(report)
|
|
152
|
+
store = ProjectConfigScope.store
|
|
153
|
+
files = Array(report["files"]).map { |file| reject_scoped_off(store, file) }
|
|
154
|
+
recount(report.merge("files" => files))
|
|
155
|
+
rescue RuboCop::Error, Psych::Exception
|
|
156
|
+
report
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def reject_scoped_off(store, file)
|
|
160
|
+
absolute = File.expand_path(file.fetch("path"))
|
|
161
|
+
kept = Array(file["offenses"]).reject { |o| scoped_off?(store, o.fetch("cop_name"), absolute) }
|
|
162
|
+
file.merge("offenses" => kept)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def scoped_off?(store, cop_name, absolute_path)
|
|
166
|
+
cop_class = RuboCop::Cop::Registry.global.find_by_cop_name(cop_name)
|
|
167
|
+
return false unless cop_class
|
|
168
|
+
|
|
169
|
+
cop_class.new(store.for_file(absolute_path)).excluded_file?(absolute_path)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def recount(report)
|
|
173
|
+
return report unless report["summary"]
|
|
174
|
+
|
|
175
|
+
count = Array(report["files"]).sum { |file| Array(file["offenses"]).size }
|
|
176
|
+
report.merge("summary" => report["summary"].merge("offense_count" => count))
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
module TargetFileDiscovery
|
|
181
|
+
module_function
|
|
182
|
+
|
|
183
|
+
def for_project_config(paths)
|
|
184
|
+
find(paths, ProjectConfigScope.store)
|
|
185
|
+
rescue RuboCop::Error, Psych::Exception
|
|
186
|
+
with_forced_defaults(paths)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def with_forced_defaults(paths)
|
|
190
|
+
find(paths, RuboCop::ConfigStore.new.tap(&:force_default_config!))
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def find(paths, store)
|
|
194
|
+
RuboCop::TargetFinder.new(store, {}).find(paths, :all_file_types)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "offense_extractor"
|
|
7
|
+
require_relative "sarif_rule_descriptors"
|
|
8
|
+
require_relative "sarif_severity"
|
|
9
|
+
require_relative "../../version"
|
|
10
|
+
|
|
11
|
+
module MetzScan
|
|
12
|
+
module Commands
|
|
13
|
+
class Scan
|
|
14
|
+
class SarifRenderer
|
|
15
|
+
SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
|
|
16
|
+
TOOL_URI = "https://github.com/fuentesjr/metz-scan"
|
|
17
|
+
ROOT_URI_BASE_ID = "ROOT"
|
|
18
|
+
|
|
19
|
+
def initialize(stdout, parsed)
|
|
20
|
+
@stdout = stdout
|
|
21
|
+
@parsed = parsed
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def render
|
|
25
|
+
@stdout.puts JSON.generate(to_h)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
{ "$schema" => SCHEMA, "version" => "2.1.0",
|
|
30
|
+
"runs" => [{ "tool" => tool, "results" => results,
|
|
31
|
+
"originalUriBaseIds" => original_uri_base_ids }] }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def tool
|
|
37
|
+
{ "driver" => { "name" => "metz-scan", "version" => MetzScan::VERSION,
|
|
38
|
+
"informationUri" => TOOL_URI, "rules" => reporting_descriptors } }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def results
|
|
42
|
+
offenses.map { |offense| result_for(offense) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def result_for(offense)
|
|
46
|
+
base_result(offense).tap { |result| add_project_analyzer_properties(result, offense) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def base_result(offense)
|
|
50
|
+
{ "ruleId" => offense[:cop_name], "level" => level_for(offense),
|
|
51
|
+
"message" => { "text" => offense[:message] },
|
|
52
|
+
"locations" => [physical_location(offense)],
|
|
53
|
+
"partialFingerprints" => partial_fingerprints_for(offense) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add_project_analyzer_properties(result, offense)
|
|
57
|
+
return unless offense[:project_analyzer]
|
|
58
|
+
|
|
59
|
+
result["properties"] = { "project_analyzer" => offense[:project_analyzer] }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def reporting_descriptors
|
|
63
|
+
SarifRuleDescriptors.new(offenses, tool_uri: TOOL_URI).to_a
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def level_for(offense)
|
|
67
|
+
SarifSeverity.level_for(offense[:severity])
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def partial_fingerprints_for(offense)
|
|
71
|
+
{ "primaryLocationLineHash" => Digest::SHA256.hexdigest(fingerprint_parts(offense).join("|")) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def fingerprint_parts(offense)
|
|
75
|
+
[offense[:cop_name], offense[:path], offense[:line], offense[:column], offense[:message]]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def physical_location(offense)
|
|
79
|
+
{ "physicalLocation" => {
|
|
80
|
+
"artifactLocation" => artifact_location(offense),
|
|
81
|
+
"region" => region_for(offense)
|
|
82
|
+
} }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def artifact_location(offense)
|
|
86
|
+
{ "uri" => offense[:path], "uriBaseId" => ROOT_URI_BASE_ID }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def region_for(offense)
|
|
90
|
+
{ "startLine" => offense[:line], "startColumn" => offense[:column] }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def original_uri_base_ids
|
|
94
|
+
{ ROOT_URI_BASE_ID => { "uri" => "file://#{File.expand_path(Dir.pwd)}/" } }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def offenses
|
|
98
|
+
@offenses ||= OffenseExtractor.offenses(@parsed)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sarif_severity"
|
|
4
|
+
|
|
5
|
+
module MetzScan
|
|
6
|
+
module Commands
|
|
7
|
+
class Scan
|
|
8
|
+
class SarifRuleDescriptors
|
|
9
|
+
def initialize(offenses, tool_uri:)
|
|
10
|
+
@offenses = offenses
|
|
11
|
+
@tool_uri = tool_uri
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def to_a
|
|
15
|
+
offenses_by_cop.map { |cop_name, offenses| descriptor_for(cop_name, offenses) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :offenses, :tool_uri
|
|
21
|
+
|
|
22
|
+
def descriptor_for(cop_name, offenses)
|
|
23
|
+
add_optional_texts(base_descriptor(cop_name, offenses), cop_name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def base_descriptor(cop_name, offenses)
|
|
27
|
+
{ "id" => cop_name, "name" => cop_name,
|
|
28
|
+
"shortDescription" => { "text" => offenses.first[:message].to_s },
|
|
29
|
+
"defaultConfiguration" => { "level" => descriptor_level(offenses) },
|
|
30
|
+
"helpUri" => "#{tool_uri}#rule-#{cop_name}" }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def add_optional_texts(descriptor, cop_name)
|
|
34
|
+
descriptor = add_text(descriptor, "help", help_text(cop_name))
|
|
35
|
+
add_text(descriptor, "fullDescription", full_description_for(cop_name))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def add_text(descriptor, key, text)
|
|
39
|
+
return descriptor if text.to_s.empty?
|
|
40
|
+
|
|
41
|
+
descriptor.merge(key => { "text" => text })
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def descriptor_level(offenses)
|
|
45
|
+
SarifSeverity.highest_level(offenses.map { |offense| offense[:severity] })
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def help_text(cop_name)
|
|
49
|
+
offense_for(cop_name)&.dig(:why_it_matters) || registry_metadata(cop_name).fetch("why_it_matters", nil)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def full_description_for(cop_name)
|
|
53
|
+
offense_for(cop_name)&.dig(:why_it_matters) || registry_metadata(cop_name).fetch("why_it_matters", nil)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def registry_metadata(cop_name)
|
|
57
|
+
return {} unless defined?(RuboCop::Cop::Registry)
|
|
58
|
+
|
|
59
|
+
metadata_for(cop_for(cop_name))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def metadata_for(cop)
|
|
63
|
+
return {} unless cop.respond_to?(:why_it_matters)
|
|
64
|
+
|
|
65
|
+
{ "why_it_matters" => cop.why_it_matters }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def cop_for(cop_name)
|
|
69
|
+
RuboCop::Cop::Registry.global.cops.find { |candidate| candidate.cop_name == cop_name }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def offense_for(cop_name)
|
|
73
|
+
offenses_by_cop.fetch(cop_name, []).first
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def offenses_by_cop
|
|
77
|
+
@offenses_by_cop ||= offenses.group_by { |offense| offense[:cop_name] }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Commands
|
|
5
|
+
class Scan
|
|
6
|
+
module SarifSeverity
|
|
7
|
+
LEVEL_BY_SEVERITY = {
|
|
8
|
+
"error" => "error", "fatal" => "error",
|
|
9
|
+
"warning" => "warning", "convention" => "warning", "refactor" => "warning",
|
|
10
|
+
"info" => "note", "information" => "note"
|
|
11
|
+
}.freeze
|
|
12
|
+
LEVEL_RANK = { "note" => 0, "warning" => 1, "error" => 2 }.freeze
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def level_for(severity)
|
|
17
|
+
LEVEL_BY_SEVERITY.fetch(severity.to_s, "warning")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def highest_level(severities)
|
|
21
|
+
severities.map { |severity| level_for(severity) }
|
|
22
|
+
.max_by { |level| LEVEL_RANK.fetch(level, 1) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "metz_scan/commands/scan/project_config_scope"
|
|
4
|
+
|
|
5
|
+
module MetzScan
|
|
6
|
+
module Commands
|
|
7
|
+
class Scan
|
|
8
|
+
module TargetRubyVersion
|
|
9
|
+
ENV_KEY = "RUBOCOP_TARGET_RUBY_VERSION"
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def with_project_config(paths, all_cops:, &)
|
|
14
|
+
return yield if all_cops
|
|
15
|
+
|
|
16
|
+
EnvOverride.new(ProjectConfigScope.target_ruby_version(paths)).with_env(&)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class EnvOverride
|
|
20
|
+
def initialize(value)
|
|
21
|
+
@value = value
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def with_env(&)
|
|
25
|
+
return yield unless value
|
|
26
|
+
|
|
27
|
+
with_override(&)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :value
|
|
33
|
+
|
|
34
|
+
def with_override
|
|
35
|
+
preserve
|
|
36
|
+
ENV[ENV_KEY] = value.to_s
|
|
37
|
+
yield
|
|
38
|
+
ensure
|
|
39
|
+
restore
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def preserve
|
|
43
|
+
@had_value = ENV.key?(ENV_KEY)
|
|
44
|
+
@previous = ENV.fetch(ENV_KEY, nil)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def restore
|
|
48
|
+
return ENV[ENV_KEY] = @previous if @had_value
|
|
49
|
+
|
|
50
|
+
ENV.delete(ENV_KEY)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "offense_extractor"
|
|
4
|
+
require_relative "compliance_scorecard"
|
|
5
|
+
require_relative "project_analyzer_summary_aggregate_formatter"
|
|
6
|
+
require_relative "project_analyzer_summary_breakdown_formatter"
|
|
7
|
+
require_relative "project_analyzer_triage_formatter"
|
|
8
|
+
require_relative "project_analyzer_triage_priority"
|
|
9
|
+
|
|
10
|
+
module MetzScan
|
|
11
|
+
module Commands
|
|
12
|
+
class Scan
|
|
13
|
+
class TextRenderer
|
|
14
|
+
ANSI_RESET = "\e[0m"
|
|
15
|
+
ANSI_BOLD = "\e[1m"
|
|
16
|
+
ANSI_CYAN = "\e[36m"
|
|
17
|
+
|
|
18
|
+
def initialize(stdout, parsed)
|
|
19
|
+
@stdout = stdout
|
|
20
|
+
@parsed = parsed
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def render
|
|
24
|
+
emit_project_analyzer_summary
|
|
25
|
+
sorted_offense_blocks.each do |cop_name, list|
|
|
26
|
+
render_block(cop_name, list)
|
|
27
|
+
end
|
|
28
|
+
ComplianceScorecard.new(parsed).lines.each { |line| stdout.puts line }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
attr_reader :stdout, :parsed
|
|
34
|
+
|
|
35
|
+
def render_block(cop_name, list)
|
|
36
|
+
stdout.puts heading(cop_name)
|
|
37
|
+
emit_block_metadata(cop_name, representative_offense(list))
|
|
38
|
+
emit_offense_lines(list)
|
|
39
|
+
stdout.puts
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def emit_project_analyzer_summary
|
|
43
|
+
return unless (summary = parsed.dig("summary", "project_analyzers"))
|
|
44
|
+
|
|
45
|
+
emit_project_analyzer_summary_heading(summary)
|
|
46
|
+
emit_project_analyzer_aggregate_summaries(summary)
|
|
47
|
+
emit_project_analyzer_rule_summaries(summary)
|
|
48
|
+
stdout.puts
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def emit_block_metadata(cop_name, offense)
|
|
52
|
+
emit_why_block(cop_name, offense[:why_it_matters])
|
|
53
|
+
emit_project_analyzer_triage(offense[:project_analyzer])
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def emit_offense_lines(list)
|
|
57
|
+
list.each { |o| stdout.puts " #{o[:path]}:#{o[:line]}:#{o[:column]} #{o[:message]}" }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def emit_project_analyzer_summary_heading(summary)
|
|
61
|
+
stdout.puts "Project analyzers: #{count_label(summary.fetch('finding_count'), 'finding')}, " \
|
|
62
|
+
"#{count_label(summary.fetch('offense_count'), 'offense')} " \
|
|
63
|
+
"(advisory signals; review in context)"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def emit_project_analyzer_rule_summaries(summary)
|
|
67
|
+
sorted_project_analyzer_rules(summary).each { |rule| stdout.puts " #{project_analyzer_rule_summary(rule)}" }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def emit_project_analyzer_aggregate_summaries(summary)
|
|
71
|
+
ProjectAnalyzerSummaryAggregateFormatter.new(summary).lines.each { |line| stdout.puts line }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def project_analyzer_rule_summary(rule)
|
|
75
|
+
summary = "#{rule.fetch('cop_name')}: #{count_label(rule.fetch('finding_count'), 'finding')}, " \
|
|
76
|
+
"#{count_label(rule.fetch('offense_count'), 'offense')}, status: #{rule.fetch('status')}, " \
|
|
77
|
+
"confidence: #{rule.fetch('confidence')}, severity: #{rule.fetch('triage_severity')}"
|
|
78
|
+
[summary, mixed_breakdown_hint(rule)].compact.join("; ")
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def mixed_breakdown_hint(rule)
|
|
82
|
+
ProjectAnalyzerSummaryBreakdownFormatter.new(rule).call
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def sorted_offense_blocks
|
|
86
|
+
OffenseExtractor.offenses(parsed)
|
|
87
|
+
.group_by { |offense| offense[:cop_name] }
|
|
88
|
+
.sort_by { |cop_name, list| offense_block_sort_key(cop_name, list) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def offense_block_sort_key(cop_name, list)
|
|
92
|
+
offense = representative_offense(list)
|
|
93
|
+
metadata = offense[:project_analyzer]
|
|
94
|
+
return [0, cop_name] unless metadata
|
|
95
|
+
|
|
96
|
+
[1, *project_analyzer_priority(metadata), cop_name]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def representative_offense(list)
|
|
100
|
+
list.min_by { |offense| project_analyzer_priority(offense[:project_analyzer] || {}) }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def sorted_project_analyzer_rules(summary)
|
|
104
|
+
Array(summary["rules"]).sort_by do |rule|
|
|
105
|
+
[*project_analyzer_priority(rule), rule.fetch("cop_name")]
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def project_analyzer_priority(metadata)
|
|
110
|
+
ProjectAnalyzerTriagePriority.sort_key(metadata)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def emit_why_block(cop_name, why)
|
|
114
|
+
return if why.nil? || why.empty?
|
|
115
|
+
|
|
116
|
+
stdout.puts " Why it matters: #{why}"
|
|
117
|
+
stdout.puts " Run `metz-scan explain #{cop_name}` for details." if explainable?(cop_name)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def emit_project_analyzer_triage(metadata)
|
|
121
|
+
line = ProjectAnalyzerTriageFormatter.line(metadata)
|
|
122
|
+
return unless line
|
|
123
|
+
|
|
124
|
+
stdout.puts " #{line}"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def count_label(count, noun)
|
|
128
|
+
"#{count} #{noun}#{'s' unless count == 1}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def explainable?(cop_name)
|
|
132
|
+
cop_name.start_with?("Metz/")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def heading(name)
|
|
136
|
+
tty? ? "#{ANSI_BOLD}#{ANSI_CYAN}#{name}#{ANSI_RESET}" : name
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def tty?
|
|
140
|
+
stdout.respond_to?(:tty?) && stdout.tty?
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|