metz-scan 0.5.3

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.
Files changed (100) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/bin/metz-scan +10 -0
  4. data/lib/metz_scan/analyzers/contextual_node_walker.rb +82 -0
  5. data/lib/metz_scan/analyzers/cross_package_reference_metadata.rb +36 -0
  6. data/lib/metz_scan/analyzers/cross_package_reference_set.rb +33 -0
  7. data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_collector.rb +54 -0
  8. data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_reference.rb +37 -0
  9. data/lib/metz_scan/analyzers/implicit_context_pressure/current_attribute_collector.rb +73 -0
  10. data/lib/metz_scan/analyzers/implicit_context_pressure/reference_set.rb +37 -0
  11. data/lib/metz_scan/analyzers/implicit_context_pressure/thread_current_collector.rb +76 -0
  12. data/lib/metz_scan/analyzers/implicit_context_pressure/triage.rb +109 -0
  13. data/lib/metz_scan/analyzers/implicit_context_pressure.rb +139 -0
  14. data/lib/metz_scan/analyzers/inheritance_descendants/finding.rb +18 -0
  15. data/lib/metz_scan/analyzers/inheritance_descendants/metadata.rb +23 -0
  16. data/lib/metz_scan/analyzers/inheritance_descendants/root_kind.rb +58 -0
  17. data/lib/metz_scan/analyzers/inheritance_descendants/triage.rb +30 -0
  18. data/lib/metz_scan/analyzers/inheritance_descendants.rb +137 -0
  19. data/lib/metz_scan/analyzers/namespace_leak_pressure/finding.rb +20 -0
  20. data/lib/metz_scan/analyzers/namespace_leak_pressure/metadata.rb +40 -0
  21. data/lib/metz_scan/analyzers/namespace_leak_pressure/namespace.rb +41 -0
  22. data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_collector.rb +50 -0
  23. data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_set.rb +12 -0
  24. data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage/exception_declaration.rb +117 -0
  25. data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage.rb +69 -0
  26. data/lib/metz_scan/analyzers/namespace_leak_pressure.rb +139 -0
  27. data/lib/metz_scan/analyzers/occurrence.rb +28 -0
  28. data/lib/metz_scan/analyzers/package_dependency_pressure/finding.rb +20 -0
  29. data/lib/metz_scan/analyzers/package_dependency_pressure/metadata.rb +27 -0
  30. data/lib/metz_scan/analyzers/package_dependency_pressure/package_map.rb +11 -0
  31. data/lib/metz_scan/analyzers/package_dependency_pressure/reference_collector.rb +38 -0
  32. data/lib/metz_scan/analyzers/package_dependency_pressure/reference_set.rb +12 -0
  33. data/lib/metz_scan/analyzers/package_dependency_pressure/shared_dependency_triage.rb +124 -0
  34. data/lib/metz_scan/analyzers/package_dependency_pressure.rb +139 -0
  35. data/lib/metz_scan/analyzers/package_map.rb +81 -0
  36. data/lib/metz_scan/analyzers/project_analyzer_triage.rb +20 -0
  37. data/lib/metz_scan/analyzers/repeated_branching/branch_site_collector.rb +138 -0
  38. data/lib/metz_scan/analyzers/repeated_branching/branch_value.rb +27 -0
  39. data/lib/metz_scan/analyzers/repeated_branching/contextual_node_walker.rb +11 -0
  40. data/lib/metz_scan/analyzers/repeated_branching/decision_subject.rb +46 -0
  41. data/lib/metz_scan/analyzers/repeated_branching/predicate_chain.rb +58 -0
  42. data/lib/metz_scan/analyzers/repeated_branching/triage.rb +28 -0
  43. data/lib/metz_scan/analyzers/repeated_branching.rb +135 -0
  44. data/lib/metz_scan/analyzers/repeated_query_criteria/triage.rb +103 -0
  45. data/lib/metz_scan/analyzers/repeated_query_criteria.rb +285 -0
  46. data/lib/metz_scan/analyzers/ruby_file_enumerator.rb +37 -0
  47. data/lib/metz_scan/analyzers/service_soup/service_call_pattern.rb +53 -0
  48. data/lib/metz_scan/analyzers/service_soup/setup_workflow_triage.rb +48 -0
  49. data/lib/metz_scan/analyzers/service_soup/workflow_collector.rb +130 -0
  50. data/lib/metz_scan/analyzers/service_soup.rb +128 -0
  51. data/lib/metz_scan/analyzers/subclass_override_pressure/family_builder.rb +69 -0
  52. data/lib/metz_scan/analyzers/subclass_override_pressure/finding.rb +30 -0
  53. data/lib/metz_scan/analyzers/subclass_override_pressure/metadata.rb +74 -0
  54. data/lib/metz_scan/analyzers/subclass_override_pressure/method_body_facts.rb +100 -0
  55. data/lib/metz_scan/analyzers/subclass_override_pressure/triage.rb +106 -0
  56. data/lib/metz_scan/analyzers/subclass_override_pressure.rb +133 -0
  57. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/artifact_writer.rb +51 -0
  58. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/baseline_delta.rb +231 -0
  59. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/collaborators.rb +76 -0
  60. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/markdown_renderer.rb +353 -0
  61. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/readiness_catalog.rb +123 -0
  62. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/summary.rb +333 -0
  63. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/target_set.rb +139 -0
  64. data/lib/metz_scan/calibration/project_analyzer_evidence_runner.rb +64 -0
  65. data/lib/metz_scan/calibration/rubydex_drift_formatter.rb +91 -0
  66. data/lib/metz_scan/cli.rb +120 -0
  67. data/lib/metz_scan/commands/explain.rb +128 -0
  68. data/lib/metz_scan/commands/project_analyzers.rb +105 -0
  69. data/lib/metz_scan/commands/report.rb +115 -0
  70. data/lib/metz_scan/commands/rules.rb +92 -0
  71. data/lib/metz_scan/commands/scan/auto_fix.rb +138 -0
  72. data/lib/metz_scan/commands/scan/compliance_scorecard.rb +124 -0
  73. data/lib/metz_scan/commands/scan/github_annotations_renderer.rb +55 -0
  74. data/lib/metz_scan/commands/scan/offense_extractor.rb +42 -0
  75. data/lib/metz_scan/commands/scan/project_analyzer_breakdown.rb +55 -0
  76. data/lib/metz_scan/commands/scan/project_analyzer_metadata.rb +78 -0
  77. data/lib/metz_scan/commands/scan/project_analyzer_offenses.rb +85 -0
  78. data/lib/metz_scan/commands/scan/project_analyzer_runner.rb +148 -0
  79. data/lib/metz_scan/commands/scan/project_analyzer_summary_aggregate_formatter.rb +74 -0
  80. data/lib/metz_scan/commands/scan/project_analyzer_summary_breakdown_formatter.rb +46 -0
  81. data/lib/metz_scan/commands/scan/project_analyzer_triage_formatter.rb +40 -0
  82. data/lib/metz_scan/commands/scan/project_analyzer_triage_priority.rb +38 -0
  83. data/lib/metz_scan/commands/scan/project_config_scope.rb +231 -0
  84. data/lib/metz_scan/commands/scan/runner.rb +199 -0
  85. data/lib/metz_scan/commands/scan/sarif_renderer.rb +103 -0
  86. data/lib/metz_scan/commands/scan/sarif_rule_descriptors.rb +82 -0
  87. data/lib/metz_scan/commands/scan/sarif_severity.rb +27 -0
  88. data/lib/metz_scan/commands/scan/target_ruby_version.rb +56 -0
  89. data/lib/metz_scan/commands/scan/text_renderer.rb +145 -0
  90. data/lib/metz_scan/commands/scan.rb +201 -0
  91. data/lib/metz_scan/project_index/null_backend.rb +35 -0
  92. data/lib/metz_scan/project_index/rubydex_backend/file_discovery.rb +33 -0
  93. data/lib/metz_scan/project_index/rubydex_backend/location_formatting.rb +36 -0
  94. data/lib/metz_scan/project_index/rubydex_backend/method_declarations.rb +118 -0
  95. data/lib/metz_scan/project_index/rubydex_backend.rb +133 -0
  96. data/lib/metz_scan/project_index.rb +109 -0
  97. data/lib/metz_scan/version.rb +5 -0
  98. data/lib/metz_scan.rb +5 -0
  99. data/metz-scan.gemspec +41 -0
  100. metadata +160 -0
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "time"
6
+
7
+ module MetzScan
8
+ module Calibration
9
+ module ProjectAnalyzerEvidenceRunner
10
+ class ArtifactWriter
11
+ def initialize(summary:, output_dir:, run_id:)
12
+ @summary = summary
13
+ @output_dir = output_dir
14
+ @run_id = run_id || "project-analyzer-evidence-#{Time.now.utc.strftime('%Y%m%d-%H%M%S')}"
15
+ end
16
+
17
+ def call
18
+ FileUtils.mkdir_p(run_dir)
19
+ write_json
20
+ write_markdown
21
+ artifacts
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :summary, :output_dir, :run_id
27
+
28
+ def write_json
29
+ File.write(artifacts.fetch("json"), "#{JSON.pretty_generate(persisted_summary)}\n")
30
+ end
31
+
32
+ def write_markdown
33
+ File.write(artifacts.fetch("markdown"), MarkdownRenderer.new(persisted_summary).call)
34
+ end
35
+
36
+ def persisted_summary
37
+ summary.merge("artifacts" => artifacts)
38
+ end
39
+
40
+ def artifacts
41
+ { "directory" => run_dir, "json" => File.join(run_dir, "summary.json"),
42
+ "markdown" => File.join(run_dir, "summary.md") }
43
+ end
44
+
45
+ def run_dir
46
+ File.join(File.expand_path(output_dir), run_id)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module MetzScan
7
+ module Calibration
8
+ module ProjectAnalyzerEvidenceRunner
9
+ class BaselineDelta
10
+ COUNT_KEYS = %w[finding_count offense_count].freeze
11
+ private_constant :COUNT_KEYS
12
+
13
+ def initialize(summary:, baseline_file:)
14
+ @summary = summary
15
+ @baseline = BaselineFile.load(baseline_file)
16
+ end
17
+
18
+ def to_h
19
+ validate_scope!
20
+ delta_payload
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :summary, :baseline
26
+
27
+ def delta_payload
28
+ { "baseline" => baseline.identity,
29
+ "finding_count" => metric_delta("finding_count", current_summary, baseline_summary),
30
+ "offense_count" => metric_delta("offense_count", current_summary, baseline_summary),
31
+ "rules" => rule_deltas,
32
+ "breakdowns" => breakdown_deltas }.compact
33
+ end
34
+
35
+ def validate_scope!
36
+ BaselineFile.scope_keys.each { |key| validate_scope_value!(key) if baseline_scope.key?(key) }
37
+ end
38
+
39
+ def validate_scope_value!(key)
40
+ return if normalized_scope_value(key, baseline_scope[key]) == normalized_scope_value(key, summary[key])
41
+
42
+ raise Error, "baseline scope mismatch for #{key}: expected #{baseline_scope[key].inspect}, " \
43
+ "got #{summary[key].inspect}"
44
+ end
45
+
46
+ def normalized_scope_value(key, value)
47
+ case key
48
+ when "targets_file" then value && File.expand_path(value.to_s)
49
+ when "analyzer_filter" then Array(value).map(&:to_s).sort
50
+ else value
51
+ end
52
+ end
53
+
54
+ def rule_deltas
55
+ all_rule_names.map { |name| rule_delta_for(name) }
56
+ end
57
+
58
+ def rule_delta_for(name)
59
+ { "cop_name" => name,
60
+ "finding_count" => rule_metric_delta(name, "finding_count"),
61
+ "offense_count" => rule_metric_delta(name, "offense_count") }
62
+ end
63
+
64
+ def rule_metric_delta(name, key)
65
+ metric_delta(key, current_rules.fetch(name, {}), baseline_rules.fetch(name, {}))
66
+ end
67
+
68
+ def all_rule_names
69
+ (current_rules.keys | baseline_rules.keys).sort
70
+ end
71
+
72
+ def current_rules
73
+ @current_rules ||= rules_by_name(current_summary)
74
+ end
75
+
76
+ def baseline_rules
77
+ @baseline_rules ||= rules_by_name(baseline_summary)
78
+ end
79
+
80
+ def rules_by_name(source)
81
+ Array(rules_for(source)).to_h { |rule| [rule.fetch("cop_name"), rule] }
82
+ end
83
+
84
+ def rules_for(source)
85
+ BaselineFile.rules_for(source)
86
+ end
87
+
88
+ def breakdown_deltas
89
+ { "confidence" => breakdown_delta_for("confidence"),
90
+ "triage_severity" => breakdown_delta_for("triage_severity"),
91
+ "metadata" => metadata_breakdown_deltas }.compact
92
+ end
93
+
94
+ def metadata_breakdown_deltas
95
+ category_deltas = breakdown_delta_for("project_analyzer_category", metadata: true)
96
+ { "project_analyzer_category" => category_deltas } unless category_deltas.empty?
97
+ end
98
+
99
+ def breakdown_delta_for(key, metadata: false)
100
+ current = breakdown_counts(current_summary, key, metadata: metadata)
101
+ baseline = breakdown_counts(baseline_summary, key, metadata: metadata)
102
+
103
+ (current.keys | baseline.keys).sort.map do |value|
104
+ { "value" => value, "finding_count" => count_delta(current.fetch(value, 0), baseline.fetch(value, 0)) }
105
+ end
106
+ end
107
+
108
+ def breakdown_counts(source, key, metadata:)
109
+ values = metadata ? source.dig("breakdowns", "metadata", key) : source.dig("breakdowns", key)
110
+ Array(values).to_h { |entry| [entry.fetch("value"), entry.fetch("finding_count").to_i] }
111
+ end
112
+
113
+ def metric_delta(key, current, baseline)
114
+ count_delta(current.fetch(key, 0), baseline.fetch(key, 0))
115
+ end
116
+
117
+ def count_delta(current, baseline)
118
+ current = current.to_i
119
+ baseline = baseline.to_i
120
+ { "baseline" => baseline, "current" => current, "delta" => current - baseline }
121
+ end
122
+
123
+ def current_summary = summary
124
+
125
+ def baseline_summary
126
+ baseline.summary
127
+ end
128
+
129
+ def baseline_scope
130
+ baseline.scope
131
+ end
132
+ end
133
+
134
+ class BaselineFile
135
+ SCOPE_KEYS = %w[targets_file default_output analyzer_filter].freeze
136
+ private_constant :SCOPE_KEYS
137
+
138
+ def self.load(path)
139
+ new(path: path)
140
+ end
141
+
142
+ def self.from_summary(summary, label:, generated_from:, targets_file: nil)
143
+ document = document_for(summary, label: label, generated_from: generated_from, targets_file: targets_file)
144
+ new(document: document)
145
+ end
146
+
147
+ def self.scope_keys = SCOPE_KEYS
148
+
149
+ def self.rules_for(source)
150
+ source["rules"] || source.dig("project_analyzers", "rules") || []
151
+ end
152
+
153
+ def initialize(path: nil, document: nil)
154
+ @path = path && File.expand_path(path)
155
+ @document = document
156
+ end
157
+
158
+ attr_reader :path
159
+
160
+ def identity
161
+ { "file" => path, "label" => document["label"], "generated_from" => document["generated_from"] }.compact
162
+ end
163
+
164
+ def to_h
165
+ document
166
+ end
167
+
168
+ def to_yaml
169
+ YAML.dump(to_h)
170
+ end
171
+
172
+ def summary
173
+ document.fetch("summary", document)
174
+ end
175
+
176
+ def scope
177
+ document.fetch("scope", {})
178
+ end
179
+
180
+ class << self
181
+ private
182
+
183
+ def document_for(summary, label:, generated_from:, targets_file:)
184
+ { "label" => label, "generated_from" => generated_from,
185
+ "scope" => scope_for(summary, targets_file: targets_file),
186
+ "summary" => compact_summary_for(summary) }
187
+ end
188
+
189
+ def scope_for(summary, targets_file:)
190
+ { "targets_file" => targets_file || summary["targets_file"],
191
+ "default_output" => summary.fetch("default_output"),
192
+ "analyzer_filter" => summary.fetch("analyzer_filter", []) }
193
+ end
194
+
195
+ def compact_summary_for(summary)
196
+ { "finding_count" => summary.fetch("finding_count"),
197
+ "offense_count" => summary.fetch("offense_count"),
198
+ "rules" => compact_rules_for(summary),
199
+ "breakdowns" => summary.fetch("breakdowns", {}) }
200
+ end
201
+
202
+ def compact_rules_for(summary)
203
+ rules_for(summary).map { |rule| compact_rule_for(rule) }
204
+ end
205
+
206
+ def compact_rule_for(rule)
207
+ { "cop_name" => rule.fetch("cop_name"),
208
+ "finding_count" => rule.fetch("finding_count"),
209
+ "offense_count" => rule.fetch("offense_count") }
210
+ end
211
+ end
212
+
213
+ private
214
+
215
+ def document
216
+ @document ||= load_document
217
+ end
218
+
219
+ def load_document
220
+ JSON.parse(contents)
221
+ rescue JSON::ParserError
222
+ YAML.safe_load(contents, aliases: false)
223
+ end
224
+
225
+ def contents
226
+ @contents ||= File.read(path)
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../project_index"
4
+ require_relative "../../commands/scan/project_analyzer_runner"
5
+
6
+ module MetzScan
7
+ module Calibration
8
+ module ProjectAnalyzerEvidenceRunner
9
+ class AnalyzerSelection
10
+ def initialize(rule_ids)
11
+ @rule_ids = Array(rule_ids).compact
12
+ end
13
+
14
+ def call
15
+ return nil if rule_ids.empty?
16
+
17
+ rule_ids.map { |rule_id| analyzer_for(rule_id) }
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :rule_ids
23
+
24
+ def analyzer_for(rule_id)
25
+ analyzer = analyzers_by_rule_id[rule_id]
26
+ raise Error, "unknown project analyzer: #{rule_id}" unless analyzer
27
+
28
+ analyzer
29
+ end
30
+
31
+ def analyzers_by_rule_id
32
+ Commands::Scan::ProjectAnalyzerRunner::ANALYZERS.to_h { |analyzer| [analyzer::RULE_ID, analyzer] }
33
+ end
34
+ end
35
+
36
+ class FindingRunner
37
+ def initialize(analyzers: nil)
38
+ @analyzers = analyzers
39
+ end
40
+
41
+ def call(paths, index:, default_output:)
42
+ return project_findings(paths, index: index, default_output: default_output) unless analyzers
43
+
44
+ selected = selected_analyzers(default_output)
45
+ filter_default_output(run_selected_analyzers(selected, paths, index), default_output)
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :analyzers
51
+
52
+ def project_findings(paths, index:, default_output:)
53
+ Commands::Scan::ProjectAnalyzerRunner.project_findings_for(
54
+ paths, index: index, default_output: default_output
55
+ )
56
+ end
57
+
58
+ def selected_analyzers(default_output)
59
+ return analyzers unless default_output
60
+
61
+ analyzers.select { |analyzer| Commands::Scan::ProjectAnalyzerRunner.default_output_analyzer?(analyzer) }
62
+ end
63
+
64
+ def run_selected_analyzers(selected, paths, index)
65
+ selected.flat_map { |analyzer| analyzer.new(paths: paths, index: index).call }
66
+ end
67
+
68
+ def filter_default_output(findings, default_output)
69
+ return findings unless default_output
70
+
71
+ findings.select { |finding| Commands::Scan::ProjectAnalyzerRunner.default_output_finding?(finding) }
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,353 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Calibration
5
+ module ProjectAnalyzerEvidenceRunner
6
+ class MarkdownRenderer
7
+ def initialize(summary)
8
+ @summary = summary
9
+ end
10
+
11
+ def call
12
+ "#{sections.join("\n")}\n"
13
+ end
14
+
15
+ private
16
+
17
+ attr_reader :summary
18
+
19
+ def sections
20
+ section_renderers.flat_map { |renderer| renderer.new(summary).call }
21
+ end
22
+
23
+ def section_renderers
24
+ [HeaderMarkdown, TargetsMarkdown, RulesMarkdown, ReadinessMarkdown,
25
+ NotableFindingsMarkdown, BaselineDeltaMarkdown, BreakdownsMarkdown]
26
+ end
27
+ end
28
+
29
+ module MarkdownTableCells
30
+ private
31
+
32
+ def cell(value)
33
+ value.to_s.gsub("|", "\\|")
34
+ end
35
+ end
36
+
37
+ class HeaderMarkdown
38
+ def initialize(summary)
39
+ @summary = summary
40
+ end
41
+
42
+ def call
43
+ ["# Project analyzer evidence", "", *metadata_lines, "", *count_lines, ""]
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :summary
49
+
50
+ def metadata_lines
51
+ ["Generated: #{summary.fetch('generated_at')}",
52
+ "Default output filter: #{summary.fetch('default_output')}",
53
+ "Analyzer filter: #{analyzer_filter_label}", *targets_file_lines,
54
+ "Fixture root: `#{summary.fetch('fixture_root')}`"]
55
+ end
56
+
57
+ def count_lines
58
+ ["Findings: #{summary.fetch('finding_count')}", "Offenses: #{summary.fetch('offense_count')}"]
59
+ end
60
+
61
+ def analyzer_filter_label
62
+ analyzer_names = summary.fetch("analyzer_filter", [])
63
+ analyzer_names.empty? ? "none" : analyzer_names.join(", ")
64
+ end
65
+
66
+ def targets_file_lines
67
+ targets_file = summary["targets_file"]
68
+ targets_file ? ["Targets file: `#{targets_file}`"] : []
69
+ end
70
+ end
71
+
72
+ class TargetsMarkdown
73
+ def initialize(summary)
74
+ @summary = summary
75
+ end
76
+
77
+ def call
78
+ ["## Targets", "", header, *rows, ""]
79
+ end
80
+
81
+ private
82
+
83
+ attr_reader :summary
84
+
85
+ def header
86
+ "| Target | Revision | Scan paths | Backend | Findings | Offenses |\n" \
87
+ "| --- | --- | --- | --- | ---: | ---: |"
88
+ end
89
+
90
+ def rows
91
+ summary.fetch("targets").map { |target| row(target) }
92
+ end
93
+
94
+ def row(target)
95
+ "| #{target.fetch('name')} | #{revision_label(target)} | #{scan_paths_label(target)} | " \
96
+ "#{target.fetch('index').fetch('backend')} | #{target.fetch('finding_count')} | " \
97
+ "#{target.fetch('offense_count')} |"
98
+ end
99
+
100
+ def revision_label(target)
101
+ target.fetch("git").fetch("revision", "unknown")[0, 12]
102
+ end
103
+
104
+ def scan_paths_label(target)
105
+ target.fetch("scan_paths").map { |path| "`#{path}`" }.join("<br>")
106
+ end
107
+ end
108
+
109
+ class RulesMarkdown
110
+ def initialize(summary)
111
+ @summary = summary
112
+ end
113
+
114
+ def call
115
+ return ["## Rules", "", "No project-analyzer findings."] if rules.empty?
116
+
117
+ ["## Rules", "", header, *rows]
118
+ end
119
+
120
+ private
121
+
122
+ attr_reader :summary
123
+
124
+ def rules
125
+ summary.fetch("project_analyzers").fetch("rules", [])
126
+ end
127
+
128
+ def header
129
+ "| Rule | Findings | Offenses | Status | Confidence | Severity |\n" \
130
+ "| --- | ---: | ---: | --- | --- | --- |"
131
+ end
132
+
133
+ def rows
134
+ rules.map { |rule| row(rule) }
135
+ end
136
+
137
+ def row(rule)
138
+ "| #{rule.fetch('cop_name')} | #{rule.fetch('finding_count')} | #{rule.fetch('offense_count')} | " \
139
+ "#{rule.fetch('status', '?')} | #{rule.fetch('confidence', '?')} | " \
140
+ "#{rule.fetch('triage_severity', '?')} |"
141
+ end
142
+ end
143
+
144
+ class ReadinessMarkdown
145
+ include MarkdownTableCells
146
+
147
+ def initialize(summary)
148
+ @summary = summary
149
+ end
150
+
151
+ def call
152
+ return [] if readiness.empty?
153
+
154
+ ["", "## Readiness", "", header, *rows, ""]
155
+ end
156
+
157
+ private
158
+
159
+ attr_reader :summary
160
+
161
+ def readiness
162
+ summary.fetch("project_analyzers").fetch("readiness", [])
163
+ end
164
+
165
+ def header
166
+ "| Rule | Disposition | Evidence | Next | Not next |\n" \
167
+ "| --- | --- | --- | --- | --- |"
168
+ end
169
+
170
+ def rows
171
+ readiness.map { |entry| row(entry) }
172
+ end
173
+
174
+ def row(entry)
175
+ "| #{cell(entry.fetch('rule_id'))} | #{cell(entry.fetch('disposition'))} | " \
176
+ "#{cell(entry.fetch('evidence'))} | #{cell(entry.fetch('next'))} | " \
177
+ "#{cell(entry.fetch('not_next'))} |"
178
+ end
179
+ end
180
+
181
+ class NotableFindingsMarkdown
182
+ include MarkdownTableCells
183
+
184
+ def initialize(summary)
185
+ @notable_findings = summary.fetch("notable_findings", [])
186
+ end
187
+
188
+ def call
189
+ return [] if notable_findings.empty?
190
+
191
+ ["", "## Notable Findings", "", header, *rows, ""]
192
+ end
193
+
194
+ private
195
+
196
+ attr_reader :notable_findings
197
+
198
+ def header
199
+ "| Target | Rule | Confidence | Severity | Category | Location | Message |\n" \
200
+ "| --- | --- | --- | --- | --- | --- | --- |"
201
+ end
202
+
203
+ def rows
204
+ notable_findings.map { |finding| row(finding) }
205
+ end
206
+
207
+ def row(finding)
208
+ "| #{cell(finding.fetch('target'))} | #{cell(finding.fetch('rule_id'))} | " \
209
+ "#{cell(finding.fetch('confidence', '?'))} | #{cell(finding.fetch('triage_severity', '?'))} | " \
210
+ "#{cell(finding.fetch('category', '?'))} | #{cell(location_label(finding))} | " \
211
+ "#{cell(finding.fetch('message'))} |"
212
+ end
213
+
214
+ def location_label(finding)
215
+ occurrence = finding.fetch("occurrence", {})
216
+ [occurrence["path"], occurrence["line"]].compact.join(":")
217
+ end
218
+ end
219
+
220
+ class BreakdownsMarkdown
221
+ def initialize(summary)
222
+ @breakdowns = summary.fetch("breakdowns", {})
223
+ end
224
+
225
+ def call
226
+ return [] if breakdowns.empty?
227
+
228
+ ["", "## Breakdowns", "", *subsections.flatten]
229
+ end
230
+
231
+ private
232
+
233
+ attr_reader :breakdowns
234
+
235
+ def subsections
236
+ [table("Confidence", breakdowns["confidence"]),
237
+ table("Severity", breakdowns["triage_severity"]),
238
+ *metadata_tables].compact
239
+ end
240
+
241
+ def metadata_tables
242
+ breakdowns.fetch("metadata", {}).map { |key, values| table("Metadata: `#{key}`", values) }
243
+ end
244
+
245
+ def table(title, values)
246
+ return if values.to_a.empty?
247
+
248
+ ["### #{title}", "", "| Value | Findings |", "| --- | ---: |",
249
+ *values.map { |value| row(value) }, ""]
250
+ end
251
+
252
+ def row(value)
253
+ "| #{value.fetch('value')} | #{value.fetch('finding_count')} |"
254
+ end
255
+ end
256
+
257
+ class BaselineDeltaMarkdown
258
+ include MarkdownTableCells
259
+
260
+ def initialize(summary)
261
+ @delta = summary["baseline_delta"]
262
+ end
263
+
264
+ def call
265
+ return [] unless delta
266
+
267
+ header_lines + totals_lines + detail_lines
268
+ end
269
+
270
+ private
271
+
272
+ attr_reader :delta
273
+
274
+ def baseline_label
275
+ baseline = delta.fetch("baseline")
276
+ label = baseline["label"] ? "#{baseline['label']} " : ""
277
+ "#{label}`#{baseline.fetch('file')}`"
278
+ end
279
+
280
+ def header_lines
281
+ ["", "## Baseline Deltas", "", "Baseline: #{baseline_label}", ""]
282
+ end
283
+
284
+ def totals_lines
285
+ [totals_header, total_row("Findings", delta.fetch("finding_count")),
286
+ total_row("Offenses", delta.fetch("offense_count")), ""]
287
+ end
288
+
289
+ def detail_lines
290
+ analyzer_table + confidence_table + severity_table + category_table
291
+ end
292
+
293
+ def analyzer_table
294
+ table("Analyzer", "Rule", analyzer_rows)
295
+ end
296
+
297
+ def confidence_table
298
+ table("Confidence", "Value", breakdown_rows(delta.dig("breakdowns", "confidence")))
299
+ end
300
+
301
+ def severity_table
302
+ table("Severity", "Value", breakdown_rows(delta.dig("breakdowns", "triage_severity")))
303
+ end
304
+
305
+ def category_table
306
+ table("Category", "Value", breakdown_rows(delta.dig("breakdowns", "metadata",
307
+ "project_analyzer_category")))
308
+ end
309
+
310
+ def totals_header
311
+ "| Metric | Baseline | Current | Delta |\n| --- | ---: | ---: | ---: |"
312
+ end
313
+
314
+ def total_row(label, count)
315
+ "| #{label} | #{count.fetch('baseline')} | #{count.fetch('current')} | #{signed(count)} |"
316
+ end
317
+
318
+ def analyzer_rows
319
+ delta.fetch("rules", []).map do |rule|
320
+ ["#{cell(rule.fetch('cop_name'))} findings", rule.fetch("finding_count")]
321
+ end + delta.fetch("rules", []).map do |rule|
322
+ ["#{cell(rule.fetch('cop_name'))} offenses", rule.fetch("offense_count")]
323
+ end
324
+ end
325
+
326
+ def breakdown_rows(entries)
327
+ Array(entries).map { |entry| [cell(entry.fetch("value")), entry.fetch("finding_count")] }
328
+ end
329
+
330
+ def table(title, value_header, rows)
331
+ changed = rows.select { |_value, count| count.fetch("delta").nonzero? }
332
+ return ["### #{title}", "", "No #{title.downcase} count changes.", ""] if changed.empty?
333
+
334
+ ["### #{title}", "", "| #{value_header} | Baseline | Current | Delta |",
335
+ "| --- | ---: | ---: | ---: |",
336
+ *changed.map { |value, count| row(value, count) }, ""]
337
+ end
338
+
339
+ def row(value, count)
340
+ "| #{value} | #{count.fetch('baseline')} | #{count.fetch('current')} | #{signed(count)} |"
341
+ end
342
+
343
+ def signed(count)
344
+ delta_value = count.fetch("delta")
345
+ delta_value.positive? ? "+#{delta_value}" : delta_value.to_s
346
+ end
347
+ end
348
+
349
+ private_constant :MarkdownTableCells, :HeaderMarkdown, :TargetsMarkdown, :RulesMarkdown
350
+ private_constant :ReadinessMarkdown, :NotableFindingsMarkdown, :BaselineDeltaMarkdown, :BreakdownsMarkdown
351
+ end
352
+ end
353
+ end