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.

Files changed (98) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/lib/metz_scan/analyzers/contextual_node_walker.rb +82 -0
  4. data/lib/metz_scan/analyzers/cross_package_reference_metadata.rb +36 -0
  5. data/lib/metz_scan/analyzers/cross_package_reference_set.rb +33 -0
  6. data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_collector.rb +54 -0
  7. data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_reference.rb +37 -0
  8. data/lib/metz_scan/analyzers/implicit_context_pressure/current_attribute_collector.rb +73 -0
  9. data/lib/metz_scan/analyzers/implicit_context_pressure/reference_set.rb +37 -0
  10. data/lib/metz_scan/analyzers/implicit_context_pressure/thread_current_collector.rb +76 -0
  11. data/lib/metz_scan/analyzers/implicit_context_pressure/triage.rb +109 -0
  12. data/lib/metz_scan/analyzers/implicit_context_pressure.rb +139 -0
  13. data/lib/metz_scan/analyzers/inheritance_descendants/finding.rb +18 -0
  14. data/lib/metz_scan/analyzers/inheritance_descendants/metadata.rb +23 -0
  15. data/lib/metz_scan/analyzers/inheritance_descendants/root_kind.rb +58 -0
  16. data/lib/metz_scan/analyzers/inheritance_descendants/triage.rb +30 -0
  17. data/lib/metz_scan/analyzers/inheritance_descendants.rb +137 -0
  18. data/lib/metz_scan/analyzers/namespace_leak_pressure/finding.rb +20 -0
  19. data/lib/metz_scan/analyzers/namespace_leak_pressure/metadata.rb +40 -0
  20. data/lib/metz_scan/analyzers/namespace_leak_pressure/namespace.rb +41 -0
  21. data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_collector.rb +50 -0
  22. data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_set.rb +12 -0
  23. data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage/exception_declaration.rb +117 -0
  24. data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage.rb +69 -0
  25. data/lib/metz_scan/analyzers/namespace_leak_pressure.rb +139 -0
  26. data/lib/metz_scan/analyzers/occurrence.rb +28 -0
  27. data/lib/metz_scan/analyzers/package_dependency_pressure/finding.rb +20 -0
  28. data/lib/metz_scan/analyzers/package_dependency_pressure/metadata.rb +27 -0
  29. data/lib/metz_scan/analyzers/package_dependency_pressure/package_map.rb +11 -0
  30. data/lib/metz_scan/analyzers/package_dependency_pressure/reference_collector.rb +38 -0
  31. data/lib/metz_scan/analyzers/package_dependency_pressure/reference_set.rb +12 -0
  32. data/lib/metz_scan/analyzers/package_dependency_pressure/shared_dependency_triage.rb +124 -0
  33. data/lib/metz_scan/analyzers/package_dependency_pressure.rb +139 -0
  34. data/lib/metz_scan/analyzers/package_map.rb +81 -0
  35. data/lib/metz_scan/analyzers/project_analyzer_triage.rb +20 -0
  36. data/lib/metz_scan/analyzers/repeated_branching/branch_site_collector.rb +138 -0
  37. data/lib/metz_scan/analyzers/repeated_branching/branch_value.rb +27 -0
  38. data/lib/metz_scan/analyzers/repeated_branching/contextual_node_walker.rb +11 -0
  39. data/lib/metz_scan/analyzers/repeated_branching/decision_subject.rb +46 -0
  40. data/lib/metz_scan/analyzers/repeated_branching/predicate_chain.rb +58 -0
  41. data/lib/metz_scan/analyzers/repeated_branching/triage.rb +28 -0
  42. data/lib/metz_scan/analyzers/repeated_branching.rb +135 -0
  43. data/lib/metz_scan/analyzers/repeated_query_criteria/triage.rb +103 -0
  44. data/lib/metz_scan/analyzers/repeated_query_criteria.rb +285 -0
  45. data/lib/metz_scan/analyzers/ruby_file_enumerator.rb +37 -0
  46. data/lib/metz_scan/analyzers/service_soup/service_call_pattern.rb +53 -0
  47. data/lib/metz_scan/analyzers/service_soup/setup_workflow_triage.rb +48 -0
  48. data/lib/metz_scan/analyzers/service_soup/workflow_collector.rb +130 -0
  49. data/lib/metz_scan/analyzers/service_soup.rb +128 -0
  50. data/lib/metz_scan/analyzers/subclass_override_pressure/family_builder.rb +69 -0
  51. data/lib/metz_scan/analyzers/subclass_override_pressure/finding.rb +30 -0
  52. data/lib/metz_scan/analyzers/subclass_override_pressure/metadata.rb +74 -0
  53. data/lib/metz_scan/analyzers/subclass_override_pressure/method_body_facts.rb +100 -0
  54. data/lib/metz_scan/analyzers/subclass_override_pressure/triage.rb +106 -0
  55. data/lib/metz_scan/analyzers/subclass_override_pressure.rb +133 -0
  56. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/artifact_writer.rb +51 -0
  57. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/baseline_delta.rb +231 -0
  58. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/collaborators.rb +76 -0
  59. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/markdown_renderer.rb +353 -0
  60. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/readiness_catalog.rb +123 -0
  61. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/summary.rb +333 -0
  62. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/target_set.rb +139 -0
  63. data/lib/metz_scan/calibration/project_analyzer_evidence_runner.rb +64 -0
  64. data/lib/metz_scan/calibration/rubydex_drift_formatter.rb +91 -0
  65. data/lib/metz_scan/cli.rb +120 -0
  66. data/lib/metz_scan/commands/explain.rb +128 -0
  67. data/lib/metz_scan/commands/project_analyzers.rb +105 -0
  68. data/lib/metz_scan/commands/report.rb +115 -0
  69. data/lib/metz_scan/commands/rules.rb +92 -0
  70. data/lib/metz_scan/commands/scan/auto_fix.rb +138 -0
  71. data/lib/metz_scan/commands/scan/compliance_scorecard.rb +124 -0
  72. data/lib/metz_scan/commands/scan/github_annotations_renderer.rb +55 -0
  73. data/lib/metz_scan/commands/scan/offense_extractor.rb +42 -0
  74. data/lib/metz_scan/commands/scan/project_analyzer_breakdown.rb +55 -0
  75. data/lib/metz_scan/commands/scan/project_analyzer_metadata.rb +78 -0
  76. data/lib/metz_scan/commands/scan/project_analyzer_offenses.rb +85 -0
  77. data/lib/metz_scan/commands/scan/project_analyzer_runner.rb +148 -0
  78. data/lib/metz_scan/commands/scan/project_analyzer_summary_aggregate_formatter.rb +74 -0
  79. data/lib/metz_scan/commands/scan/project_analyzer_summary_breakdown_formatter.rb +46 -0
  80. data/lib/metz_scan/commands/scan/project_analyzer_triage_formatter.rb +40 -0
  81. data/lib/metz_scan/commands/scan/project_analyzer_triage_priority.rb +38 -0
  82. data/lib/metz_scan/commands/scan/project_config_scope.rb +231 -0
  83. data/lib/metz_scan/commands/scan/runner.rb +199 -0
  84. data/lib/metz_scan/commands/scan/sarif_renderer.rb +103 -0
  85. data/lib/metz_scan/commands/scan/sarif_rule_descriptors.rb +82 -0
  86. data/lib/metz_scan/commands/scan/sarif_severity.rb +27 -0
  87. data/lib/metz_scan/commands/scan/target_ruby_version.rb +56 -0
  88. data/lib/metz_scan/commands/scan/text_renderer.rb +145 -0
  89. data/lib/metz_scan/commands/scan.rb +201 -0
  90. data/lib/metz_scan/project_index/null_backend.rb +35 -0
  91. data/lib/metz_scan/project_index/rubydex_backend/file_discovery.rb +33 -0
  92. data/lib/metz_scan/project_index/rubydex_backend/location_formatting.rb +36 -0
  93. data/lib/metz_scan/project_index/rubydex_backend/method_declarations.rb +118 -0
  94. data/lib/metz_scan/project_index/rubydex_backend.rb +133 -0
  95. data/lib/metz_scan/project_index.rb +109 -0
  96. data/lib/metz_scan/version.rb +5 -0
  97. data/lib/metz_scan.rb +5 -0
  98. metadata +185 -0
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "stringio"
5
+ require "tempfile"
6
+
7
+ module MetzScan
8
+ module Commands
9
+ class Scan
10
+ class AutoFix
11
+ SAFE_FLAG = "-a"
12
+ UNSAFE_FLAG = "-A"
13
+ CommandResult = Struct.new(:status, :stdout, :stderr, keyword_init: true)
14
+
15
+ def initialize(stdout:, stderr:)
16
+ @stdout = stdout
17
+ @stderr = stderr
18
+ end
19
+
20
+ def run(options)
21
+ require "rubocop-metz"
22
+ options.dry_run ? run_dry(options) : run_in_place(options)
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :stdout, :stderr
28
+
29
+ def run_in_place(options)
30
+ with_redirected_io { RuboCop::CLI.new.run(rubocop_argv(options)) }
31
+ end
32
+
33
+ def with_redirected_io
34
+ previous = redirect_to(stdout, stderr)
35
+ yield
36
+ ensure
37
+ restore_io(previous) if previous
38
+ end
39
+
40
+ def redirect_to(out, err)
41
+ previous = [$stdout, $stderr]
42
+ $stdout = out
43
+ $stderr = err
44
+ previous
45
+ end
46
+
47
+ def restore_io(previous)
48
+ $stdout = previous[0]
49
+ $stderr = previous[1]
50
+ end
51
+
52
+ def run_dry(options)
53
+ snapshot = capture_files(options.paths)
54
+ dry_run_result(snapshot, capture_rubocop(rubocop_argv(options)))
55
+ ensure
56
+ restore_files(snapshot)
57
+ end
58
+
59
+ def dry_run_result(snapshot, result)
60
+ print_diffs(snapshot)
61
+ print_rubocop_failure(result) unless result.status.zero?
62
+ result.status
63
+ end
64
+
65
+ 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]
68
+ end
69
+
70
+ def capture_rubocop(argv)
71
+ out = StringIO.new
72
+ err = StringIO.new
73
+ status = with_captured_io(out, err) { RuboCop::CLI.new.run(argv) }
74
+ CommandResult.new(status: status, stdout: out.string, stderr: err.string)
75
+ end
76
+
77
+ def with_captured_io(out, err)
78
+ previous = redirect_to(out, err)
79
+ yield
80
+ ensure
81
+ restore_io(previous) if previous
82
+ end
83
+
84
+ def capture_files(paths)
85
+ files_under(paths).to_h { |path| [path, File.binread(path)] }
86
+ end
87
+
88
+ def files_under(paths)
89
+ paths.flat_map { |path| File.directory?(path) ? regular_files_in(path) : [path] }
90
+ end
91
+
92
+ def regular_files_in(dir)
93
+ Dir.glob(File.join(dir, "**/*")).select { |entry| File.file?(entry) }
94
+ end
95
+
96
+ def print_diffs(snapshot)
97
+ snapshot.each { |path, original| print_file_diff(path, original) }
98
+ end
99
+
100
+ def print_rubocop_failure(result)
101
+ stderr.print(result.stderr) unless result.stderr.empty?
102
+ stderr.print(result.stdout) unless result.stdout.empty?
103
+ end
104
+
105
+ def print_file_diff(path, original)
106
+ return unless File.exist?(path)
107
+ return if File.binread(path) == original
108
+
109
+ stdout.puts diff_against(path, original)
110
+ end
111
+
112
+ def diff_against(path, original)
113
+ Tempfile.create(["metz-orig-", File.basename(path)]) do |orig|
114
+ write_tempfile(orig, original)
115
+ run_diff(orig.path, path)
116
+ end
117
+ end
118
+
119
+ def write_tempfile(file, content)
120
+ file.binmode
121
+ file.write(content)
122
+ file.flush
123
+ end
124
+
125
+ def run_diff(orig_path, new_path)
126
+ labels = ["--label", "a/#{new_path}", "--label", "b/#{new_path}"]
127
+ Open3.capture3("diff", "-u", *labels, orig_path, new_path).first
128
+ end
129
+
130
+ def restore_files(snapshot)
131
+ return unless snapshot
132
+
133
+ snapshot.each { |path, content| File.binwrite(path, content) }
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Commands
5
+ class Scan
6
+ class ComplianceScorecard
7
+ RULE_COP_PREFIX = "Metz/"
8
+ TOP_FILE_LIMIT = 5
9
+
10
+ def self.add_to_summary!(parsed)
11
+ new(parsed).add_to_summary!
12
+ end
13
+
14
+ def initialize(parsed)
15
+ @parsed = parsed
16
+ end
17
+
18
+ def add_to_summary!
19
+ writable_summary["clean_file_count"] = clean_file_count
20
+ writable_summary["files_with_offenses"] = files_with_rule_offenses
21
+ writable_summary["offenses_by_cop"] = offenses_by_cop
22
+ parsed
23
+ end
24
+
25
+ def lines
26
+ ["Summary", "-------", compliance_line, *offense_summary_lines]
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :parsed
32
+
33
+ def offense_summary_lines
34
+ return ["No offenses found."] if offense_count.zero?
35
+
36
+ [offense_total_line, "", "By cop:", *formatted_rows(sorted_cop_counts), "",
37
+ "Most offenses:", *formatted_rows(top_file_counts)]
38
+ end
39
+
40
+ def compliance_line
41
+ return "Metz compliance: n/a (no files scanned)" if inspected_file_count.zero?
42
+
43
+ "Metz compliance: #{compliance_percentage}% (#{clean_file_count}/#{inspected_file_count} files clean)"
44
+ end
45
+
46
+ def compliance_percentage
47
+ ((clean_file_count.to_f / inspected_file_count) * 100).round
48
+ end
49
+
50
+ def clean_file_count
51
+ inspected_file_count - files_with_rule_offenses
52
+ end
53
+
54
+ def files_with_rule_offenses
55
+ files.count { |file| rule_offenses?(file) }
56
+ end
57
+
58
+ def rule_offenses?(file)
59
+ Array(file["offenses"]).any? { |offense| offense.fetch("cop_name").start_with?(RULE_COP_PREFIX) }
60
+ end
61
+
62
+ def offense_total_line
63
+ "#{count_label(offense_count, 'offense')} across #{count_label(sorted_cop_counts.size, 'cop')}"
64
+ end
65
+
66
+ def offense_count
67
+ offenses_by_cop.values.sum
68
+ end
69
+
70
+ def offenses_by_cop
71
+ @offenses_by_cop ||= sorted_cop_counts.to_h
72
+ end
73
+
74
+ def sorted_cop_counts
75
+ @sorted_cop_counts ||= cop_counts.sort_by { |cop_name, count| [-count, cop_name] }
76
+ end
77
+
78
+ def cop_counts
79
+ offenses.each_with_object(Hash.new(0)) { |offense, counts| counts[offense.fetch("cop_name")] += 1 }
80
+ end
81
+
82
+ def top_file_counts
83
+ file_counts.sort_by { |path, count| [-count, path] }.first(TOP_FILE_LIMIT)
84
+ end
85
+
86
+ def file_counts
87
+ files.filter_map do |file|
88
+ count = Array(file["offenses"]).size
89
+ [file.fetch("path"), count] if count.positive?
90
+ end
91
+ end
92
+
93
+ def formatted_rows(rows)
94
+ width = rows.map { |label, _count| label.length }.max
95
+ rows.map { |label, count| " #{label.ljust(width)} #{count}" }
96
+ end
97
+
98
+ def count_label(count, noun)
99
+ "#{count} #{noun}#{'s' unless count == 1}"
100
+ end
101
+
102
+ def offenses
103
+ @offenses ||= files.flat_map { |file| Array(file["offenses"]) }
104
+ end
105
+
106
+ def files
107
+ @files ||= Array(parsed["files"])
108
+ end
109
+
110
+ def inspected_file_count
111
+ @inspected_file_count ||= summary.fetch("inspected_file_count", files.size).to_i
112
+ end
113
+
114
+ def summary
115
+ @summary ||= parsed.fetch("summary", {}) || {}
116
+ end
117
+
118
+ def writable_summary
119
+ parsed["summary"] ||= {}
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "offense_extractor"
4
+ require_relative "project_analyzer_triage_formatter"
5
+
6
+ module MetzScan
7
+ module Commands
8
+ class Scan
9
+ class GithubAnnotationsRenderer
10
+ ERROR_SEVERITIES = %w[error fatal].freeze
11
+
12
+ def initialize(stdout, parsed)
13
+ @stdout = stdout
14
+ @parsed = parsed
15
+ end
16
+
17
+ def render
18
+ OffenseExtractor.offenses(@parsed).each { |offense| stdout.puts annotation_for(offense) }
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :stdout
24
+
25
+ def annotation_for(offense)
26
+ "::#{level_for(offense)} #{properties_for(offense)}::#{escape_data(message_for(offense))}"
27
+ end
28
+
29
+ def level_for(offense)
30
+ ERROR_SEVERITIES.include?(offense[:severity].to_s) ? "error" : "warning"
31
+ end
32
+
33
+ def properties_for(offense)
34
+ annotation_properties(offense).map { |key, value| "#{key}=#{escape_property(value)}" }.join(",")
35
+ end
36
+
37
+ def annotation_properties(offense)
38
+ { file: offense[:path], line: offense[:line], col: offense[:column], title: offense[:cop_name] }.compact
39
+ end
40
+
41
+ def message_for(offense)
42
+ [offense[:message], ProjectAnalyzerTriageFormatter.line(offense[:project_analyzer])].compact.join("\n")
43
+ end
44
+
45
+ def escape_property(value)
46
+ escape_data(value).gsub(":", "%3A").gsub(",", "%2C")
47
+ end
48
+
49
+ def escape_data(value)
50
+ value.to_s.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Commands
5
+ class Scan
6
+ module OffenseExtractor
7
+ module_function
8
+
9
+ def offenses(parsed)
10
+ Array(parsed["files"]).flat_map { |file| file_offenses(file) }
11
+ end
12
+
13
+ def file_offenses(file)
14
+ Array(file["offenses"]).map { |o| offense_struct(file["path"], o) }
15
+ end
16
+
17
+ def offense_struct(path, offense)
18
+ loc = offense["location"] || {}
19
+ base_offense(path, offense).merge(location_fields(loc))
20
+ end
21
+
22
+ def base_offense(path, offense)
23
+ { path: path, cop_name: offense["cop_name"], severity: offense["severity"],
24
+ message: offense["message"], why_it_matters: offense["why_it_matters"],
25
+ project_analyzer: offense["project_analyzer"] }
26
+ end
27
+
28
+ def location_fields(location)
29
+ { line: location_value(location, "start_line", "line"),
30
+ column: location_value(location, "start_column", "column") }
31
+ end
32
+
33
+ def location_value(location, primary_key, fallback_key)
34
+ return location[primary_key] if location.key?(primary_key)
35
+ return location[fallback_key] if location.key?(fallback_key)
36
+
37
+ raise KeyError, "RuboCop JSON location missing #{primary_key}/#{fallback_key}"
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Commands
5
+ class Scan
6
+ class ProjectAnalyzerBreakdown
7
+ def initialize(findings)
8
+ @findings = findings
9
+ end
10
+
11
+ def to_h
12
+ { "cop_name" => breakdown_for(&:rule_id),
13
+ "confidence" => breakdown_for { |finding| optional_value(finding, :confidence) },
14
+ "triage_severity" => breakdown_for { |finding| optional_value(finding, :triage_severity) },
15
+ "metadata" => metadata_breakdowns }.compact.reject { |_key, values| values.empty? }
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :findings
21
+
22
+ def metadata_breakdowns
23
+ category_metadata_keys.to_h { |key| [key, metadata_breakdown_for(key)] }
24
+ .reject { |_key, values| values.empty? }
25
+ .then { |values| values unless values.empty? }
26
+ end
27
+
28
+ def category_metadata_keys
29
+ ProjectAnalyzerMetadata.category_metadata_keys
30
+ end
31
+
32
+ def metadata_breakdown_for(key)
33
+ breakdown_for { |finding| analyzer_metadata(finding)[key] }
34
+ end
35
+
36
+ def analyzer_metadata(finding)
37
+ return {} unless finding.respond_to?(:project_analyzer_metadata)
38
+
39
+ finding.project_analyzer_metadata || {}
40
+ end
41
+
42
+ def breakdown_for(&block)
43
+ findings.filter_map { |finding| block.call(finding) }
44
+ .tally
45
+ .sort_by { |value, _count| value.to_s }
46
+ .map { |value, count| { "value" => value, "finding_count" => count } }
47
+ end
48
+
49
+ def optional_value(finding, name)
50
+ finding.public_send(name) if finding.respond_to?(name)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "project_analyzer_breakdown"
4
+ require_relative "project_analyzer_triage_priority"
5
+
6
+ module MetzScan
7
+ module Commands
8
+ class Scan
9
+ module ProjectAnalyzerMetadata
10
+ CATEGORY_METADATA_KEY = "project_analyzer_category"
11
+
12
+ module_function
13
+
14
+ def category_metadata_keys = [CATEGORY_METADATA_KEY]
15
+
16
+ def category_for(finding)
17
+ analyzer_metadata(finding)[CATEGORY_METADATA_KEY]
18
+ end
19
+
20
+ def offense_metadata(finding)
21
+ triage_metadata(finding).merge(analyzer_metadata(finding))
22
+ end
23
+
24
+ def summary(findings, offenses)
25
+ rules = rule_summaries(findings, offenses)
26
+ { "finding_count" => findings.size,
27
+ "offense_count" => rules.sum { |rule| rule.fetch("offense_count") },
28
+ "rules" => rules }
29
+ end
30
+
31
+ def triage_metadata(finding)
32
+ { "status" => optional_value(finding, :project_analyzer_status),
33
+ "confidence" => optional_value(finding, :confidence),
34
+ "triage_severity" => optional_value(finding, :triage_severity),
35
+ "triage_summary" => optional_value(finding, :triage_summary) }.compact
36
+ end
37
+
38
+ def analyzer_metadata(finding)
39
+ return {} unless finding.respond_to?(:project_analyzer_metadata)
40
+
41
+ finding.project_analyzer_metadata || {}
42
+ end
43
+
44
+ def rule_summaries(findings, offenses)
45
+ findings.group_by(&:rule_id).sort.map do |rule_id, rule_findings|
46
+ rule_summary(rule_id, rule_findings, offenses)
47
+ end
48
+ end
49
+
50
+ def rule_summary(rule_id, findings, offenses)
51
+ summary_metadata(findings).merge(rule_count_metadata(rule_id, findings, offenses),
52
+ "breakdowns" => ProjectAnalyzerBreakdown.new(findings).to_h)
53
+ end
54
+
55
+ def rule_count_metadata(rule_id, findings, offenses)
56
+ { "cop_name" => rule_id, "finding_count" => findings.size,
57
+ "offense_count" => offense_count(rule_id, offenses) }
58
+ end
59
+
60
+ def summary_metadata(findings)
61
+ triage_metadata(priority_finding(findings)).slice("status", "confidence", "triage_severity")
62
+ end
63
+
64
+ def priority_finding(findings)
65
+ Array(findings).min_by { |finding| ProjectAnalyzerTriagePriority.sort_key(triage_metadata(finding)) }
66
+ end
67
+
68
+ def offense_count(rule_id, offenses)
69
+ offenses.count { |offense| offense.fetch("cop_name") == rule_id }
70
+ end
71
+
72
+ def optional_value(finding, name)
73
+ finding.public_send(name) if finding.respond_to?(name)
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "project_analyzer_metadata"
4
+
5
+ module MetzScan
6
+ module Commands
7
+ class Scan
8
+ module ProjectAnalyzerOffenses
9
+ OffenseSet = Struct.new(:by_path, :offenses, keyword_init: true)
10
+
11
+ module_function
12
+
13
+ def build(findings)
14
+ entries = entries_for(findings)
15
+ OffenseSet.new(by_path: offenses_by_path(entries), offenses: entries.map { |entry| entry.fetch(:offense) })
16
+ end
17
+
18
+ def entries_for(findings)
19
+ findings.flat_map { |finding| offenses_for(finding) }
20
+ end
21
+
22
+ def offenses_for(finding)
23
+ locations_for(finding).map do |location|
24
+ { path: location.path, offense: offense_for(finding, location) }
25
+ end
26
+ end
27
+
28
+ def locations_for(finding)
29
+ Array(finding.report_occurrences).compact.uniq
30
+ end
31
+
32
+ def offense_for(finding, occurrence)
33
+ offense_metadata(finding, occurrence).merge("location" => location_hash(occurrence.report_line))
34
+ end
35
+
36
+ def offense_metadata(finding, occurrence)
37
+ add_project_analyzer_metadata(common_offense_metadata(finding), finding, occurrence)
38
+ end
39
+
40
+ def common_offense_metadata(finding)
41
+ basic_offense_metadata(finding).merge(explanation_metadata(finding))
42
+ end
43
+
44
+ def basic_offense_metadata(finding)
45
+ { "cop_name" => finding.rule_id, "message" => finding.message,
46
+ "severity" => "refactor", "corrected" => false, "correctable" => false }
47
+ end
48
+
49
+ def explanation_metadata(finding)
50
+ { "why_it_matters" => finding.why_it_matters, "fix_safety" => "manual",
51
+ "suggested_next_moves" => suggested_next_moves_for(finding) }
52
+ end
53
+
54
+ def add_project_analyzer_metadata(metadata, finding, occurrence)
55
+ project_metadata = ProjectAnalyzerMetadata.offense_metadata(finding)
56
+ return metadata if project_metadata.empty?
57
+
58
+ metadata.merge("project_analyzer" => project_metadata.merge(report_location_metadata(occurrence)))
59
+ end
60
+
61
+ def report_location_metadata(occurrence)
62
+ { "report_location" => { "line_source" => occurrence.line_source,
63
+ "context" => occurrence.context }.compact }
64
+ end
65
+
66
+ def suggested_next_moves_for(finding)
67
+ return [] unless finding.respond_to?(:suggested_next_moves)
68
+
69
+ Array(finding.suggested_next_moves).map(&:to_s)
70
+ end
71
+
72
+ def location_hash(line)
73
+ line ||= 1
74
+ { "start_line" => line, "start_column" => 1, "last_line" => line,
75
+ "last_column" => 1, "length" => 1, "line" => line, "column" => 1 }
76
+ end
77
+
78
+ def offenses_by_path(entries)
79
+ entries.group_by { |entry| entry.fetch(:path) }
80
+ .transform_values { |grouped_entries| grouped_entries.map { |entry| entry.fetch(:offense) } }
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end