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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module MetzScan
6
+ module Calibration
7
+ class RubydexDriftFormatter
8
+ def initialize(summary, analyzers:)
9
+ @summary = summary
10
+ @analyzers = analyzers
11
+ end
12
+
13
+ def json
14
+ JSON.pretty_generate(payload)
15
+ end
16
+
17
+ def text
18
+ [header_lines, rule_lines, breakdown_lines].flatten.compact.join("\n")
19
+ end
20
+
21
+ private
22
+
23
+ attr_reader :summary, :analyzers
24
+
25
+ def payload
26
+ compact_identity.merge(compact_results)
27
+ end
28
+
29
+ def compact_identity
30
+ { "targets_file" => summary["targets_file"],
31
+ "targets" => target_names,
32
+ "analyzers" => analyzers }.compact
33
+ end
34
+
35
+ def compact_results
36
+ { "finding_count" => summary.fetch("finding_count"),
37
+ "offense_count" => summary.fetch("offense_count"),
38
+ "rules" => rules,
39
+ "breakdowns" => summary.fetch("breakdowns", {}) }
40
+ end
41
+
42
+ def header_lines
43
+ ["rubydex drift check",
44
+ targets_file_line,
45
+ "targets: #{target_names.join(', ')}",
46
+ "analyzers: #{analyzers.join(', ')}",
47
+ "findings: #{summary.fetch('finding_count')} (offenses: #{summary.fetch('offense_count')})"]
48
+ end
49
+
50
+ def target_names
51
+ summary.fetch("targets").map { |target| target.fetch("name") }
52
+ end
53
+
54
+ def targets_file_line
55
+ "targets file: #{summary['targets_file']}" if summary["targets_file"]
56
+ end
57
+
58
+ def rule_lines
59
+ return ["rules: none"] if rules.empty?
60
+
61
+ ["rules:", *rules.map { |rule| rule_line(rule) }]
62
+ end
63
+
64
+ def rules
65
+ summary.fetch("project_analyzers", {}).fetch("rules", [])
66
+ end
67
+
68
+ def rule_line(rule)
69
+ " - #{rule.fetch('cop_name')}: #{rule.fetch('finding_count')} finding(s), " \
70
+ "#{rule.fetch('offense_count')} offense(s), status=#{rule.fetch('status', '?')}, " \
71
+ "confidence=#{rule.fetch('confidence', '?')}, severity=#{rule.fetch('triage_severity', '?')}"
72
+ end
73
+
74
+ def breakdown_lines
75
+ lines = [breakdown_line("confidence", breakdowns["confidence"]),
76
+ breakdown_line("severity", breakdowns["triage_severity"])].compact
77
+ lines.empty? ? [] : ["breakdowns:", *lines]
78
+ end
79
+
80
+ def breakdowns
81
+ summary.fetch("breakdowns", {})
82
+ end
83
+
84
+ def breakdown_line(label, values)
85
+ return if values.to_a.empty?
86
+
87
+ " - #{label}: #{values.map { |value| "#{value.fetch('value')}=#{value.fetch('finding_count')}" }.join(', ')}"
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ require_relative "version"
6
+
7
+ module MetzScan
8
+ class CLI
9
+ SUBCOMMAND_SUMMARIES = {
10
+ "rules" => "List all Metz/* cops with their one-line rationale",
11
+ "explain" => "Print full metadata for a single Metz cop",
12
+ "scan" => "Run rubocop-metz against PATH and render a report",
13
+ "report" => "Re-render an existing rubocop-metz JSON report",
14
+ "project-analyzers" => "List project analyzers with status and default-output eligibility"
15
+ }.freeze
16
+
17
+ SUBCOMMANDS = SUBCOMMAND_SUMMARIES.keys.freeze
18
+
19
+ def self.start(argv = ARGV, stdout: $stdout, stderr: $stderr)
20
+ new(stdout: stdout, stderr: stderr).run(argv)
21
+ end
22
+
23
+ def initialize(stdout: $stdout, stderr: $stderr)
24
+ @stdout = stdout
25
+ @stderr = stderr
26
+ end
27
+
28
+ def run(argv)
29
+ args = argv.dup
30
+ return show_help_and_fail if args.empty?
31
+
32
+ handle_global_flag(args.first) || dispatch(args)
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :stdout, :stderr
38
+
39
+ def handle_global_flag(arg)
40
+ case arg
41
+ when "-v", "--version" then print_version
42
+ when "-h", "--help" then print_help_and_succeed
43
+ end
44
+ end
45
+
46
+ def print_version
47
+ stdout.puts MetzScan::VERSION
48
+ 0
49
+ end
50
+
51
+ def print_help_and_succeed
52
+ stdout.puts help_text
53
+ 0
54
+ end
55
+
56
+ def show_help_and_fail
57
+ stderr.puts help_text
58
+ 1
59
+ end
60
+
61
+ def dispatch(args)
62
+ name = args.shift
63
+ return unknown_subcommand(name) unless SUBCOMMANDS.include?(name)
64
+
65
+ handler = subcommand_handler(name)
66
+ handler ? handler.run(args, stdout: stdout, stderr: stderr) : stub_subcommand(name)
67
+ end
68
+
69
+ SUBCOMMAND_HANDLERS = { "rules" => "Rules", "explain" => "Explain", "scan" => "Scan",
70
+ "report" => "Report", "project-analyzers" => "ProjectAnalyzers" }.freeze
71
+ private_constant :SUBCOMMAND_HANDLERS
72
+
73
+ def subcommand_handler(name)
74
+ klass_name = SUBCOMMAND_HANDLERS[name]
75
+ return unless klass_name
76
+
77
+ require_relative "commands/#{name.tr('-', '_')}"
78
+ Commands.const_get(klass_name)
79
+ end
80
+
81
+ def stub_subcommand(name)
82
+ stderr.puts "metz-scan: subcommand '#{name}' is not yet implemented."
83
+ 1
84
+ end
85
+
86
+ def unknown_subcommand(name)
87
+ stderr.puts "metz-scan: unknown subcommand '#{name}'."
88
+ stderr.puts help_text
89
+ 1
90
+ end
91
+
92
+ def help_text
93
+ option_parser.help
94
+ end
95
+
96
+ def option_parser
97
+ OptionParser.new do |opts|
98
+ opts.banner = "Usage: metz-scan [--version] [--help] <subcommand> [<args>]"
99
+ append_subcommands(opts)
100
+ append_options(opts)
101
+ end
102
+ end
103
+
104
+ SUBCOMMAND_ROW = " %<name>-9s %<summary>s"
105
+ private_constant :SUBCOMMAND_ROW
106
+
107
+ def append_subcommands(opts)
108
+ opts.separator ""
109
+ opts.separator "Subcommands:"
110
+ SUBCOMMAND_SUMMARIES.each { |name, summary| opts.separator(format(SUBCOMMAND_ROW, name: name, summary: summary)) }
111
+ end
112
+
113
+ def append_options(opts)
114
+ opts.separator ""
115
+ opts.separator "Options:"
116
+ opts.on("-v", "--version", "Print the metz-scan version and exit")
117
+ opts.on("-h", "--help", "Show this help message and exit")
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "yaml"
5
+
6
+ module MetzScan
7
+ module Commands
8
+ class Explain
9
+ USAGE = "Usage: metz-scan explain Metz/CopName"
10
+ NON_KNOB_KEYS = %w[Description VersionAdded StyleGuide Reference].freeze
11
+
12
+ def self.run(argv, stdout:, stderr:)
13
+ new(stdout: stdout, stderr: stderr).run(argv)
14
+ end
15
+
16
+ def initialize(stdout:, stderr:)
17
+ @stdout = stdout
18
+ @stderr = stderr
19
+ end
20
+
21
+ def run(argv)
22
+ args = parse_options(argv.dup)
23
+ return missing_argument_error if args.empty?
24
+
25
+ emit_or_error(args.first)
26
+ end
27
+
28
+ def emit_or_error(cop_name)
29
+ cop = lookup_metz_cop(cop_name)
30
+ return unknown_cop_error(cop_name) unless cop
31
+
32
+ emit(cop)
33
+ 0
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :stdout, :stderr
39
+
40
+ def parse_options(argv)
41
+ OptionParser.new { |opts| opts.banner = USAGE }.parse!(argv)
42
+ argv
43
+ end
44
+
45
+ def lookup_metz_cop(name)
46
+ require "rubocop-metz"
47
+ cop = RuboCop::Cop::Registry.global.find_by_cop_name(name)
48
+ cop if cop && metz_cop?(cop)
49
+ end
50
+
51
+ def metz_cop?(cop)
52
+ cop.cop_name.start_with?("Metz/") && cop.respond_to?(:metz_metadata)
53
+ end
54
+
55
+ def missing_argument_error
56
+ stderr.puts "metz-scan explain: missing required COP_NAME argument."
57
+ stderr.puts USAGE
58
+ stderr.puts "Run `metz-scan rules` to see the list of available Metz cops."
59
+ 1
60
+ end
61
+
62
+ def unknown_cop_error(name)
63
+ stderr.puts "metz-scan explain: no such cop '#{name}'."
64
+ stderr.puts "Run `metz-scan rules` to see the list of available Metz cops."
65
+ 1
66
+ end
67
+
68
+ def emit(cop)
69
+ emit_heading(cop)
70
+ emit_why(cop)
71
+ emit_fix_safety(cop)
72
+ emit_suggested_next_moves(cop)
73
+ emit_configuration(cop)
74
+ end
75
+
76
+ def emit_heading(cop)
77
+ stdout.puts cop.cop_name
78
+ stdout.puts("=" * cop.cop_name.length)
79
+ stdout.puts
80
+ end
81
+
82
+ def emit_why(cop)
83
+ stdout.puts "Why it matters:"
84
+ stdout.puts " #{cop.why_it_matters}"
85
+ stdout.puts
86
+ end
87
+
88
+ def emit_fix_safety(cop)
89
+ stdout.puts "Fix safety: #{cop.fix_safety}"
90
+ stdout.puts
91
+ end
92
+
93
+ def emit_suggested_next_moves(cop)
94
+ stdout.puts "Suggested next moves:"
95
+ cop.suggested_next_moves.each { |move| stdout.puts " - #{move}" }
96
+ stdout.puts
97
+ end
98
+
99
+ def emit_configuration(cop)
100
+ knobs = configuration_knobs(cop.cop_name)
101
+ return if knobs.empty?
102
+
103
+ stdout.puts "Configuration:"
104
+ knobs.each { |key, value| stdout.puts " #{key}: #{format_value(value)}" }
105
+ end
106
+
107
+ def configuration_knobs(cop_name)
108
+ entry = default_config[cop_name] || {}
109
+ entry.except(*NON_KNOB_KEYS)
110
+ end
111
+
112
+ def default_config
113
+ @default_config ||= YAML.load_file(default_config_path)
114
+ end
115
+
116
+ def default_config_path
117
+ RuboCop::Metz::Plugin.new.rules(nil).value.to_s
118
+ end
119
+
120
+ def format_value(value)
121
+ case value
122
+ when Array then value.empty? ? "[]" : value.join(", ")
123
+ else value.to_s
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ require_relative "scan/project_analyzer_runner"
7
+
8
+ module MetzScan
9
+ module Commands
10
+ class ProjectAnalyzers
11
+ USAGE = "Usage: metz-scan project-analyzers [--json]"
12
+
13
+ def self.run(argv, stdout:, stderr:)
14
+ new(stdout: stdout, stderr: stderr).run(argv)
15
+ end
16
+
17
+ def initialize(stdout:, stderr:)
18
+ @stdout = stdout
19
+ @stderr = stderr
20
+ end
21
+
22
+ def run(argv)
23
+ options = parse_options(argv)
24
+ return 1 unless options
25
+
26
+ entries = analyzer_entries
27
+ options[:json] ? emit_json(entries) : emit_table(entries)
28
+ 0
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :stdout, :stderr
34
+
35
+ def parse_options(argv)
36
+ options = { json: false }
37
+ OptionParser.new { |opts| configure_options(opts, options) }.parse!(argv)
38
+ options
39
+ rescue OptionParser::ParseError => e
40
+ parse_error(e)
41
+ end
42
+
43
+ def parse_error(error)
44
+ stderr.puts error.message
45
+ stderr.puts option_parser.help
46
+ nil
47
+ end
48
+
49
+ def configure_options(opts, options)
50
+ opts.banner = USAGE
51
+ opts.on("--json", "Emit a JSON array of project analyzers") { options[:json] = true }
52
+ end
53
+
54
+ def option_parser
55
+ OptionParser.new { |opts| configure_options(opts, {}) }
56
+ end
57
+
58
+ def analyzer_entries
59
+ Scan::ProjectAnalyzerRunner::ANALYZERS.map { |analyzer| analyzer_entry(analyzer) }
60
+ end
61
+
62
+ def analyzer_entry(analyzer)
63
+ { name: analyzer::RULE_ID, status: analyzer::PROJECT_ANALYZER_STATUS,
64
+ default_output: Scan::ProjectAnalyzerRunner.default_output_analyzer?(analyzer),
65
+ confidence: analyzer::CONFIDENCE, triage_severity: analyzer::TRIAGE_SEVERITY,
66
+ triage_summary: analyzer::TRIAGE_SUMMARY, why_it_matters: analyzer::WHY,
67
+ suggested_next_moves: analyzer::SUGGESTED_NEXT_MOVES }
68
+ end
69
+
70
+ def emit_json(entries)
71
+ stdout.puts JSON.generate(entries)
72
+ end
73
+
74
+ def emit_table(entries)
75
+ widths = column_widths(entries)
76
+ stdout.puts row(%w[ANALYZER STATUS DEFAULT CONFIDENCE TRIAGE], widths)
77
+ stdout.puts row(widths.map { |_key, width| "-" * width }, widths)
78
+ entries.each { |entry| stdout.puts entry_row(entry, widths) }
79
+ end
80
+
81
+ def column_widths(entries)
82
+ { name: max_width(entries, :name, "ANALYZER"), status: max_width(entries, :status, "STATUS"),
83
+ default: "DEFAULT".length, confidence: max_width(entries, :confidence, "CONFIDENCE"),
84
+ triage: max_width(entries, :triage_severity, "TRIAGE") }
85
+ end
86
+
87
+ def max_width(entries, key, header)
88
+ [header.length, entries.map { |entry| entry.fetch(key).length }.max].max
89
+ end
90
+
91
+ def entry_row(entry, widths)
92
+ row([entry.fetch(:name), entry.fetch(:status), default_label(entry),
93
+ entry.fetch(:confidence), entry.fetch(:triage_severity)], widths)
94
+ end
95
+
96
+ def default_label(entry)
97
+ entry.fetch(:default_output) ? "yes" : "no"
98
+ end
99
+
100
+ def row(values, widths)
101
+ values.zip(widths.values).map { |value, width| format("%-#{width}s", value) }.join(" ").rstrip
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ require_relative "scan/runner"
7
+ require_relative "scan/compliance_scorecard"
8
+ require_relative "scan/text_renderer"
9
+ require_relative "scan/sarif_renderer"
10
+ require_relative "scan/github_annotations_renderer"
11
+
12
+ module MetzScan
13
+ module Commands
14
+ class Report
15
+ USAGE = "Usage: metz-scan report PATH/TO/JSON [--format text|json|sarif|gh-annotations]"
16
+ VALID_FORMATS = %w[text json sarif gh-annotations].freeze
17
+ RENDERERS = { "sarif" => Scan::SarifRenderer, "gh-annotations" => Scan::GithubAnnotationsRenderer }.freeze
18
+ DEFAULT_FORMAT = "text"
19
+
20
+ Options = Struct.new(:format, :path, keyword_init: true)
21
+
22
+ def self.run(argv, stdout:, stderr:)
23
+ new(stdout: stdout, stderr: stderr).run(argv)
24
+ end
25
+
26
+ def initialize(stdout:, stderr:)
27
+ @stdout = stdout
28
+ @stderr = stderr
29
+ end
30
+
31
+ def run(argv)
32
+ options = parse_options(argv)
33
+ validate(options) || emit(options)
34
+ rescue OptionParser::ParseError => e
35
+ parser_error(e)
36
+ end
37
+
38
+ private
39
+
40
+ attr_reader :stdout, :stderr
41
+
42
+ def parse_options(argv)
43
+ flags = { format: DEFAULT_FORMAT }
44
+ rest = OptionParser.new { |opts| configure_parser(opts, flags) }.parse(argv)
45
+ Options.new(**flags, path: rest.first)
46
+ end
47
+
48
+ def configure_parser(opts, flags)
49
+ opts.banner = USAGE
50
+ opts.on("--format FORMAT", "Output format: text (default), json, sarif, gh-annotations") do |format|
51
+ flags[:format] = format
52
+ end
53
+ end
54
+
55
+ def validate(options)
56
+ return missing_path_arg unless options.path
57
+ return missing_file(options.path) unless File.file?(options.path)
58
+
59
+ invalid_format(options.format) unless VALID_FORMATS.include?(options.format)
60
+ end
61
+
62
+ def emit(options)
63
+ parsed = load_json(options.path)
64
+ return parsed if parsed.is_a?(Integer)
65
+
66
+ render(parsed, options.format)
67
+ Scan::Runner.exit_code_for(parsed)
68
+ end
69
+
70
+ def load_json(path)
71
+ JSON.parse(File.read(path))
72
+ rescue JSON::ParserError => e
73
+ invalid_json(path, e.message)
74
+ end
75
+
76
+ def render(parsed, format)
77
+ return render_json(parsed) if format == "json"
78
+
79
+ RENDERERS.fetch(format, Scan::TextRenderer).new(stdout, parsed).render
80
+ end
81
+
82
+ def render_json(parsed)
83
+ Scan::ComplianceScorecard.add_to_summary!(parsed)
84
+ stdout.puts JSON.generate(parsed)
85
+ end
86
+
87
+ def parser_error(err)
88
+ stderr.puts "metz-scan report: #{err.message}"
89
+ stderr.puts USAGE
90
+ 1
91
+ end
92
+
93
+ def missing_path_arg
94
+ stderr.puts "metz-scan report: missing PATH argument."
95
+ stderr.puts USAGE
96
+ 1
97
+ end
98
+
99
+ def missing_file(path)
100
+ stderr.puts "metz-scan report: no such file: #{path}"
101
+ 1
102
+ end
103
+
104
+ def invalid_format(fmt)
105
+ stderr.puts "metz-scan report: invalid --format '#{fmt}'. Valid formats: text, json, sarif, gh-annotations."
106
+ 1
107
+ end
108
+
109
+ def invalid_json(path, detail)
110
+ stderr.puts "metz-scan report: invalid JSON in #{path}: #{detail}"
111
+ 1
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+
6
+ module MetzScan
7
+ module Commands
8
+ class Rules
9
+ ANSI_RESET = "\e[0m"
10
+ ANSI_BOLD = "\e[1m"
11
+ ANSI_CYAN = "\e[36m"
12
+ ANSI_GREY = "\e[90m"
13
+
14
+ USAGE = "Usage: metz-scan rules [--json]"
15
+
16
+ def self.run(argv, stdout:, stderr:)
17
+ new(stdout: stdout, stderr: stderr).run(argv)
18
+ end
19
+
20
+ def initialize(stdout:, stderr:)
21
+ @stdout = stdout
22
+ @stderr = stderr
23
+ end
24
+
25
+ def run(argv)
26
+ options = parse_options(argv)
27
+ cops = load_cops
28
+ options[:json] ? emit_json(cops) : emit_table(cops)
29
+ 0
30
+ end
31
+
32
+ private
33
+
34
+ attr_reader :stdout, :stderr
35
+
36
+ def parse_options(argv)
37
+ options = { json: false }
38
+ OptionParser.new { |opts| configure_options(opts, options) }.parse!(argv)
39
+ options
40
+ end
41
+
42
+ def configure_options(opts, options)
43
+ opts.banner = USAGE
44
+ opts.on("--json", "Emit a JSON array of all Metz cops") { options[:json] = true }
45
+ end
46
+
47
+ def load_cops
48
+ require "rubocop-metz"
49
+ RuboCop::Cop::Registry.global.cops.select { |cop| cop.cop_name.start_with?("Metz/") }.sort_by(&:cop_name)
50
+ end
51
+
52
+ def emit_json(cops)
53
+ stdout.puts JSON.generate(cops.map { |cop| json_entry(cop) })
54
+ end
55
+
56
+ def json_entry(cop)
57
+ { name: cop.cop_name, why_it_matters: cop.why_it_matters,
58
+ fix_safety: cop.fix_safety.to_s, suggested_next_moves: Array(cop.suggested_next_moves) }
59
+ end
60
+
61
+ def emit_table(cops)
62
+ name_width = cops.map { |cop| cop.cop_name.length }.max
63
+ colorize = tty?
64
+ stdout.puts header_row(name_width, colorize)
65
+ stdout.puts separator_row(name_width, colorize)
66
+ cops.each { |cop| stdout.puts data_row(cop, name_width, colorize) }
67
+ end
68
+
69
+ def tty?
70
+ stdout.respond_to?(:tty?) && stdout.tty?
71
+ end
72
+
73
+ def header_row(name_width, colorize)
74
+ decorate(format("%-#{name_width}s %s", "COP", "WHY IT MATTERS"), ANSI_BOLD, colorize)
75
+ end
76
+
77
+ def separator_row(name_width, colorize)
78
+ dashes = ("-" * name_width)
79
+ decorate("#{dashes} #{'-' * 14}", ANSI_GREY, colorize)
80
+ end
81
+
82
+ def data_row(cop, name_width, colorize)
83
+ colored_name = decorate(format("%-#{name_width}s", cop.cop_name), ANSI_CYAN, colorize)
84
+ "#{colored_name} #{cop.why_it_matters}"
85
+ end
86
+
87
+ def decorate(text, code, colorize)
88
+ colorize ? "#{code}#{text}#{ANSI_RESET}" : text
89
+ end
90
+ end
91
+ end
92
+ end