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,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