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,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../analyzers/repeated_branching"
|
|
4
|
+
require_relative "../../analyzers/service_soup"
|
|
5
|
+
require_relative "../../analyzers/inheritance_descendants"
|
|
6
|
+
require_relative "../../analyzers/package_dependency_pressure"
|
|
7
|
+
require_relative "../../analyzers/namespace_leak_pressure"
|
|
8
|
+
require_relative "../../analyzers/implicit_context_pressure"
|
|
9
|
+
require_relative "../../analyzers/repeated_query_criteria"
|
|
10
|
+
require_relative "../../analyzers/subclass_override_pressure"
|
|
11
|
+
require_relative "../../project_index"
|
|
12
|
+
require_relative "runner"
|
|
13
|
+
require_relative "project_analyzer_metadata"
|
|
14
|
+
require_relative "project_analyzer_offenses"
|
|
15
|
+
|
|
16
|
+
module MetzScan
|
|
17
|
+
module Commands
|
|
18
|
+
class Scan
|
|
19
|
+
# Runs wrapper-level project analyzers and merges their findings into the
|
|
20
|
+
# same report shape produced by RuboCop's JSON formatter.
|
|
21
|
+
module ProjectAnalyzerRunner
|
|
22
|
+
ANALYZERS = [
|
|
23
|
+
Analyzers::RepeatedBranching,
|
|
24
|
+
Analyzers::ServiceSoup,
|
|
25
|
+
Analyzers::InheritanceDescendants,
|
|
26
|
+
Analyzers::PackageDependencyPressure,
|
|
27
|
+
Analyzers::NamespaceLeakPressure,
|
|
28
|
+
Analyzers::ImplicitContextPressure,
|
|
29
|
+
Analyzers::RepeatedQueryCriteria,
|
|
30
|
+
Analyzers::SubclassOverridePressure
|
|
31
|
+
].freeze
|
|
32
|
+
INDEX_BACKED_ANALYZERS = [Analyzers::InheritanceDescendants, Analyzers::PackageDependencyPressure,
|
|
33
|
+
Analyzers::NamespaceLeakPressure, Analyzers::SubclassOverridePressure].freeze
|
|
34
|
+
DEFAULT_OUTPUT_STATUS = "validated"
|
|
35
|
+
DEFAULT_OUTPUT_CONFIDENCE = "medium"
|
|
36
|
+
DEFAULT_OUTPUT_TRIAGE_SEVERITY = "design pressure"
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
def merge!(parsed, paths, **)
|
|
41
|
+
findings = project_findings_for(paths, **)
|
|
42
|
+
return parsed if findings.empty?
|
|
43
|
+
|
|
44
|
+
merge_findings(parsed, findings)
|
|
45
|
+
parsed
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def project_findings_for(paths, index: nil, default_output: false, force_default_config: false)
|
|
49
|
+
paths = analyzer_paths(paths, index: index, force_default_config: force_default_config)
|
|
50
|
+
return [] if paths.empty? && !index
|
|
51
|
+
|
|
52
|
+
findings = project_findings(paths, index: index, default_output: default_output)
|
|
53
|
+
default_output ? findings.select { |finding| default_output_finding?(finding) } : findings
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def merge_findings(parsed, findings)
|
|
57
|
+
offense_set = ProjectAnalyzerOffenses.build(findings)
|
|
58
|
+
merge_offenses(parsed, offense_set.by_path)
|
|
59
|
+
update_summary(parsed, findings, offense_set.offenses)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def project_findings(paths, index: nil, default_output: false)
|
|
63
|
+
analyzers = analyzers_for(default_output: default_output)
|
|
64
|
+
index = project_index_for(paths, index, analyzers)
|
|
65
|
+
analyzers.flat_map { |analyzer| analyzer.new(paths: paths, index: index).call }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def analyzers_for(default_output:)
|
|
69
|
+
return ANALYZERS unless default_output
|
|
70
|
+
|
|
71
|
+
ANALYZERS.select { |analyzer| default_output_analyzer?(analyzer) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def default_output_analyzer?(analyzer)
|
|
75
|
+
analyzer.const_defined?(:DEFAULT_OUTPUT_ELIGIBLE) &&
|
|
76
|
+
analyzer::DEFAULT_OUTPUT_ELIGIBLE &&
|
|
77
|
+
analyzer.const_defined?(:PROJECT_ANALYZER_STATUS) &&
|
|
78
|
+
analyzer::PROJECT_ANALYZER_STATUS == DEFAULT_OUTPUT_STATUS
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def project_index_for(paths, index, analyzers)
|
|
82
|
+
return index if index
|
|
83
|
+
return nil unless analyzers.any? { |analyzer| INDEX_BACKED_ANALYZERS.include?(analyzer) }
|
|
84
|
+
|
|
85
|
+
ProjectIndex.build(paths)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def default_output_finding?(finding)
|
|
89
|
+
finding.project_analyzer_status == DEFAULT_OUTPUT_STATUS &&
|
|
90
|
+
finding.confidence == DEFAULT_OUTPUT_CONFIDENCE &&
|
|
91
|
+
finding.triage_severity == DEFAULT_OUTPUT_TRIAGE_SEVERITY
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def analyzer_paths(paths, index: nil, force_default_config: false)
|
|
95
|
+
return Array(paths) if index
|
|
96
|
+
|
|
97
|
+
rubocop_target_files(paths, force_default_config: force_default_config)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def rubocop_target_files(paths, force_default_config: false)
|
|
101
|
+
return TargetFileDiscovery.with_forced_defaults(paths) if force_default_config
|
|
102
|
+
|
|
103
|
+
TargetFileDiscovery.for_project_config(paths)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def merge_offenses(parsed, grouped_offenses)
|
|
107
|
+
grouped_offenses.each do |path, offenses|
|
|
108
|
+
file_for(parsed, path)["offenses"].concat(offenses)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def file_for(parsed, path)
|
|
113
|
+
files = parsed["files"] ||= []
|
|
114
|
+
files.find { |file| same_path?(file["path"], path) } || append_file(files, path)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def same_path?(left, right)
|
|
118
|
+
File.expand_path(left) == File.expand_path(right)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def append_file(files, path)
|
|
122
|
+
{ "path" => display_path(path), "offenses" => [] }.tap { |file| files << file }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def display_path(path)
|
|
126
|
+
expanded_path = File.expand_path(path)
|
|
127
|
+
cwd = "#{File.expand_path(Dir.pwd)}#{File::SEPARATOR}"
|
|
128
|
+
return expanded_path.delete_prefix(cwd) if expanded_path.start_with?(cwd)
|
|
129
|
+
|
|
130
|
+
path
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def update_summary(parsed, findings, project_offenses)
|
|
134
|
+
summary = parsed["summary"] ||= {}
|
|
135
|
+
files = Array(parsed["files"])
|
|
136
|
+
summary["offense_count"] = files.sum { |file| Array(file["offenses"]).size }
|
|
137
|
+
update_file_counts(summary, files.size)
|
|
138
|
+
summary["project_analyzers"] = ProjectAnalyzerMetadata.summary(findings, project_offenses)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def update_file_counts(summary, file_count)
|
|
142
|
+
summary["target_file_count"] = [summary["target_file_count"].to_i, file_count].max
|
|
143
|
+
summary["inspected_file_count"] = [summary["inspected_file_count"].to_i, file_count].max
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Commands
|
|
5
|
+
class Scan
|
|
6
|
+
class ProjectAnalyzerSummaryAggregateFormatter
|
|
7
|
+
def initialize(summary)
|
|
8
|
+
@summary = summary
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def lines
|
|
12
|
+
[counts_line("Analyzer counts", analyzer_counts),
|
|
13
|
+
counts_line("Confidence counts", breakdown_counts("confidence")),
|
|
14
|
+
counts_line("Severity counts", breakdown_counts("triage_severity")),
|
|
15
|
+
counts_line("Category counts", category_counts)].compact
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :summary
|
|
21
|
+
|
|
22
|
+
def analyzer_counts
|
|
23
|
+
rules.to_h { |rule| [rule.fetch("cop_name"), rule.fetch("finding_count").to_i] }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def breakdown_counts(key)
|
|
27
|
+
counts = counts_from_rule_breakdowns(key)
|
|
28
|
+
counts.empty? ? counts_from_rule_field(key) : counts
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def category_counts
|
|
32
|
+
counts = counts_from_rule_breakdowns("project_analyzer_category", metadata: true)
|
|
33
|
+
counts.empty? ? counts_from_all_metadata_breakdowns : counts
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def counts_from_rule_breakdowns(key, metadata: false)
|
|
37
|
+
rules.each_with_object(Hash.new(0)) do |rule, counts|
|
|
38
|
+
entries = metadata ? rule.dig("breakdowns", "metadata", key) : rule.dig("breakdowns", key)
|
|
39
|
+
Array(entries).each { |entry| counts[entry.fetch("value")] += entry.fetch("finding_count").to_i }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def counts_from_all_metadata_breakdowns
|
|
44
|
+
rules.each_with_object(Hash.new(0)) do |rule, counts|
|
|
45
|
+
rule.dig("breakdowns", "metadata").to_h.each_value do |entries|
|
|
46
|
+
Array(entries).each { |entry| counts[entry.fetch("value")] += entry.fetch("finding_count").to_i }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def counts_from_rule_field(key)
|
|
52
|
+
rules.each_with_object(Hash.new(0)) do |rule, counts|
|
|
53
|
+
value = rule[key]
|
|
54
|
+
counts[value] += rule.fetch("finding_count").to_i if value
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def counts_line(label, counts)
|
|
59
|
+
return if counts.empty?
|
|
60
|
+
|
|
61
|
+
" #{label}: #{sorted_counts(counts).map { |value, count| "#{value}=#{count}" }.join(', ')}"
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def sorted_counts(counts)
|
|
65
|
+
counts.sort_by { |value, count| [-count, value.to_s] }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def rules
|
|
69
|
+
@rules ||= Array(summary["rules"])
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Commands
|
|
5
|
+
class Scan
|
|
6
|
+
class ProjectAnalyzerSummaryBreakdownFormatter
|
|
7
|
+
def initialize(rule)
|
|
8
|
+
@rule = rule
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
return if segments.empty?
|
|
13
|
+
|
|
14
|
+
"mix: #{segments.join('; ')}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :rule
|
|
20
|
+
|
|
21
|
+
def segments
|
|
22
|
+
[mixed_segment("severity", breakdowns["triage_severity"]),
|
|
23
|
+
*metadata_segments].compact
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def breakdowns
|
|
27
|
+
rule.fetch("breakdowns", {})
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def metadata_segments
|
|
31
|
+
breakdowns.fetch("metadata", {}).map { |key, values| mixed_segment(key, values) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def mixed_segment(label, values)
|
|
35
|
+
return unless values.to_a.size > 1
|
|
36
|
+
|
|
37
|
+
"#{label} #{values.map { |value| label_for(value) }.join(', ')}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def label_for(value)
|
|
41
|
+
"#{value.fetch('value')}=#{value.fetch('finding_count')}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Commands
|
|
5
|
+
class Scan
|
|
6
|
+
module ProjectAnalyzerTriageFormatter
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def line(metadata)
|
|
10
|
+
return unless metadata
|
|
11
|
+
|
|
12
|
+
line_for(triage_details(metadata), metadata["triage_summary"])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def line_for(details, summary)
|
|
16
|
+
return unless details.any? || !blank?(summary)
|
|
17
|
+
return summary.to_s if details.empty?
|
|
18
|
+
|
|
19
|
+
["Triage: #{details.join('; ')}.", summary].compact.join(" ")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def triage_details(metadata)
|
|
23
|
+
[
|
|
24
|
+
labeled("status", metadata["status"]),
|
|
25
|
+
labeled("confidence", metadata["confidence"]),
|
|
26
|
+
labeled("severity", metadata["triage_severity"])
|
|
27
|
+
].compact
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def labeled(name, value)
|
|
31
|
+
"#{name}: #{value}" unless blank?(value)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def blank?(value)
|
|
35
|
+
value.nil? || value.to_s.empty?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Commands
|
|
5
|
+
class Scan
|
|
6
|
+
module ProjectAnalyzerTriagePriority
|
|
7
|
+
PRIORITIES = {
|
|
8
|
+
"status" => { "validated" => 0, "candidate" => 1, "experimental" => 2 },
|
|
9
|
+
"confidence" => { "high" => 0, "medium" => 1, "early" => 2, "low" => 3 },
|
|
10
|
+
"triage_severity" => {
|
|
11
|
+
"design pressure" => 0,
|
|
12
|
+
"manual review" => 1,
|
|
13
|
+
"context required" => 2,
|
|
14
|
+
"broad base" => 3,
|
|
15
|
+
"shared dependency" => 4,
|
|
16
|
+
"shared namespace" => 5,
|
|
17
|
+
"setup orchestration" => 6
|
|
18
|
+
}
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def sort_key(metadata)
|
|
24
|
+
[
|
|
25
|
+
priority_for("status", metadata["status"]),
|
|
26
|
+
priority_for("confidence", metadata["confidence"]),
|
|
27
|
+
priority_for("triage_severity", metadata["triage_severity"])
|
|
28
|
+
]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def priority_for(name, value)
|
|
32
|
+
priorities = PRIORITIES.fetch(name)
|
|
33
|
+
priorities.fetch(value, priorities.size)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "rubocop"
|
|
5
|
+
|
|
6
|
+
module MetzScan
|
|
7
|
+
module Commands
|
|
8
|
+
class Scan
|
|
9
|
+
# DDR: docs/ddrs/2026-07-08-rubocop-scope-only-config.md explains why default mode bypasses ConfigStore here.
|
|
10
|
+
module ProjectConfigScope
|
|
11
|
+
SCOPE_KEYS = %w[Include Exclude Includes Excludes].freeze
|
|
12
|
+
ALL_COPS_PROJECT_KEYS = (SCOPE_KEYS + %w[RubyInterpreters TargetRubyVersion]).freeze
|
|
13
|
+
DEFAULT_FILE = RuboCop::ConfigLoader::DEFAULT_FILE
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def store
|
|
18
|
+
ConfigStore.new
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def target_ruby_version(paths)
|
|
22
|
+
TargetRubyVersion.new(paths).version
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Accumulates [config_path, gem_name] pairs whenever an `inherit_gem`
|
|
26
|
+
# reference can't be resolved during scope-only config loading, so
|
|
27
|
+
# Runner can warn once per gem after a scan (see Next Queue item 1).
|
|
28
|
+
def unresolved_inherit_gems
|
|
29
|
+
@unresolved_inherit_gems ||= []
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reset_unresolved_inherit_gems!
|
|
33
|
+
@unresolved_inherit_gems = []
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Default mode drops file-scope Exclude from an inherit_gem whose gem
|
|
37
|
+
# isn't installed (docs/ddrs/2026-07-08-rubocop-scope-only-config.md);
|
|
38
|
+
# this turns that silent degrade into a one-line stderr note per gem.
|
|
39
|
+
def warn_unresolved_inherit_gems(stderr)
|
|
40
|
+
unresolved_inherit_gems.uniq { |(_path, gem_name)| gem_name }.each do |(path, gem_name)|
|
|
41
|
+
stderr.puts(unresolved_inherit_gem_message(path, gem_name))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def unresolved_inherit_gem_message(path, gem_name)
|
|
46
|
+
"metz-scan: note: #{path} inherits from gem `#{gem_name}` which is not installed; " \
|
|
47
|
+
"its file-scope Exclude is not applied — install the gem or use --all-cops"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
class TargetRubyVersion
|
|
51
|
+
def initialize(paths, store: ConfigStore.new, loader: ConfigLoader.new)
|
|
52
|
+
@paths = paths
|
|
53
|
+
@store = store
|
|
54
|
+
@loader = loader
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def version
|
|
58
|
+
config.target_ruby_version
|
|
59
|
+
rescue RuboCop::Error, Psych::Exception
|
|
60
|
+
nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
attr_reader :paths, :store, :loader
|
|
66
|
+
|
|
67
|
+
def config
|
|
68
|
+
loaded = store.for(target_path)
|
|
69
|
+
return loaded unless loaded.loaded_path == DEFAULT_FILE
|
|
70
|
+
|
|
71
|
+
loader.default_for(target_dir)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def target_path
|
|
75
|
+
Array(paths).first || RuboCop::PathUtil.pwd
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def target_dir
|
|
79
|
+
expanded = File.expand_path(target_path)
|
|
80
|
+
File.directory?(expanded) ? expanded : File.dirname(expanded)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
class ConfigStore
|
|
85
|
+
def initialize(loader: ConfigLoader.new)
|
|
86
|
+
@loader = loader
|
|
87
|
+
@path_cache = {}
|
|
88
|
+
@object_cache = {}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def for_file(file)
|
|
92
|
+
for_dir(File.dirname(file))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def for_pwd
|
|
96
|
+
for_dir(RuboCop::PathUtil.pwd)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def for(file_or_dir)
|
|
100
|
+
for_dir(File.directory?(file_or_dir) ? file_or_dir : File.dirname(file_or_dir))
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def for_dir(dir)
|
|
104
|
+
path = @path_cache[dir] ||= RuboCop::ConfigLoader.configuration_file_for(dir)
|
|
105
|
+
@object_cache[path] ||= @loader.load(path)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
class ConfigLoader
|
|
110
|
+
def default_for(dir)
|
|
111
|
+
path = File.join(File.expand_path(dir), ".rubocop.yml")
|
|
112
|
+
config = RuboCop::Config.new({}, path)
|
|
113
|
+
RuboCop::ConfigLoader.merge_with_default(config, path)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def load(path)
|
|
117
|
+
return RuboCop::ConfigLoader.default_configuration if path == DEFAULT_FILE
|
|
118
|
+
|
|
119
|
+
config = RuboCop::Config.new(scope_hash(path), path)
|
|
120
|
+
config.deprecation_check { |_message| nil }
|
|
121
|
+
config.make_excludes_absolute
|
|
122
|
+
RuboCop::ConfigLoader.merge_with_default(config, path)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
private
|
|
126
|
+
|
|
127
|
+
def scope_hash(path, seen = [])
|
|
128
|
+
absolute_path = File.expand_path(path)
|
|
129
|
+
return {} if seen.include?(absolute_path)
|
|
130
|
+
|
|
131
|
+
raw_hash = RuboCop::ConfigLoader.load_yaml_configuration(absolute_path)
|
|
132
|
+
inherited = inherited_scope_hash(raw_hash, absolute_path, [*seen, absolute_path])
|
|
133
|
+
RuboCop::ConfigLoader.merge(inherited, local_scope_hash(raw_hash))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def inherited_scope_hash(raw_hash, path, seen)
|
|
137
|
+
inherited_paths(raw_hash, path).each_with_object({}) do |inherited_path, merged|
|
|
138
|
+
merged.replace(RuboCop::ConfigLoader.merge(merged, scope_hash(inherited_path, seen)))
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def inherited_paths(raw_hash, path)
|
|
143
|
+
gem_inherited_paths(raw_hash["inherit_gem"], path) + local_inherited_paths(raw_hash["inherit_from"], path)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def local_inherited_paths(inherit_from, path)
|
|
147
|
+
Array(inherit_from).compact.flat_map do |entry|
|
|
148
|
+
next [] if remote_config?(entry)
|
|
149
|
+
|
|
150
|
+
inherited_path = absolute_path?(entry) ? entry : File.expand_path(entry, File.dirname(path))
|
|
151
|
+
glob_path?(inherited_path) ? Dir.glob(inherited_path) : [inherited_path]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def gem_inherited_paths(inherit_gem, path)
|
|
156
|
+
return [] unless inherit_gem.is_a?(Hash)
|
|
157
|
+
|
|
158
|
+
inherit_gem.flat_map { |gem_name, config_paths| paths_for_gem(gem_name, config_paths, path) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def paths_for_gem(gem_name, config_paths, path)
|
|
162
|
+
gem_dir = gem_dir_for(gem_name, path)
|
|
163
|
+
return [] unless gem_dir
|
|
164
|
+
|
|
165
|
+
Array(config_paths).map { |config_path| File.join(gem_dir, config_path) }
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def gem_dir_for(gem_name, path)
|
|
169
|
+
bundled_spec = bundled_specs[gem_name].first if defined?(Bundler)
|
|
170
|
+
return bundled_spec.full_gem_path if bundled_spec
|
|
171
|
+
|
|
172
|
+
Gem::Specification.find_by_name(gem_name).gem_dir
|
|
173
|
+
rescue Gem::LoadError
|
|
174
|
+
record_unresolved_gem(gem_name, path)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def record_unresolved_gem(gem_name, path)
|
|
178
|
+
ProjectConfigScope.unresolved_inherit_gems << [path, gem_name]
|
|
179
|
+
nil
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def bundled_specs
|
|
183
|
+
Bundler.load.specs
|
|
184
|
+
rescue StandardError
|
|
185
|
+
{}
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def local_scope_hash(raw_hash)
|
|
189
|
+
raw_hash.each_with_object({}) do |(key, value), scope_hash|
|
|
190
|
+
scope = scope_for(key, value)
|
|
191
|
+
scope_hash[key] = scope unless scope.empty?
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def scope_for(key, value)
|
|
196
|
+
return copy_value(value) if key == "inherit_mode" && value.is_a?(Hash)
|
|
197
|
+
return {} unless value.is_a?(Hash)
|
|
198
|
+
|
|
199
|
+
scoped_settings(key, value)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def scoped_settings(key, value)
|
|
203
|
+
keys = key == "AllCops" ? ALL_COPS_PROJECT_KEYS : SCOPE_KEYS
|
|
204
|
+
value.each_with_object({}) do |(setting, setting_value), scope|
|
|
205
|
+
scope[setting] = copy_value(setting_value) if keys.include?(setting)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def copy_value(value)
|
|
210
|
+
return value.map { |item| copy_value(item) } if value.is_a?(Array)
|
|
211
|
+
return value.transform_values { |item| copy_value(item) } if value.is_a?(Hash)
|
|
212
|
+
|
|
213
|
+
value
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def remote_config?(path)
|
|
217
|
+
path.to_s.start_with?("http://", "https://")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def absolute_path?(path)
|
|
221
|
+
Pathname.new(path.to_s).absolute?
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def glob_path?(path)
|
|
225
|
+
path.match?(/[*?{}\[\]]/)
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|