metz-scan 0.5.3 → 0.6.0
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.
- checksums.yaml +4 -4
- data/lib/metz_scan/analyzers/inheritance_descendants.rb +1 -1
- data/lib/metz_scan/analyzers/namespace_leak_pressure.rb +1 -1
- data/lib/metz_scan/analyzers/package_dependency_pressure.rb +1 -1
- data/lib/metz_scan/analyzers/package_map.rb +1 -1
- data/lib/metz_scan/analyzers/test_calls_private_method/call_site_collector.rb +79 -0
- data/lib/metz_scan/analyzers/test_calls_private_method/finding.rb +23 -0
- data/lib/metz_scan/analyzers/test_calls_private_method/metadata.rb +27 -0
- data/lib/metz_scan/analyzers/test_calls_private_method/node_predicates.rb +57 -0
- data/lib/metz_scan/analyzers/test_calls_private_method/receiver_resolution.rb +80 -0
- data/lib/metz_scan/analyzers/test_calls_private_method/scope_facts.rb +76 -0
- data/lib/metz_scan/analyzers/test_calls_private_method/scope_resolution.rb +56 -0
- data/lib/metz_scan/analyzers/test_calls_private_method/triage.rb +23 -0
- data/lib/metz_scan/analyzers/test_calls_private_method.rb +125 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/readiness_catalog.rb +7 -3
- data/lib/metz_scan/cli.rb +7 -13
- data/lib/metz_scan/commands/explain.rb +11 -2
- data/lib/metz_scan/commands/project_analyzers.rb +3 -2
- data/lib/metz_scan/commands/report.rb +6 -5
- data/lib/metz_scan/commands/rules.rb +16 -4
- data/lib/metz_scan/commands/scan/auto_fix.rb +9 -7
- data/lib/metz_scan/commands/scan/config_exclusions.rb +76 -0
- data/lib/metz_scan/commands/scan/exclude_entries.rb +59 -0
- data/lib/metz_scan/commands/scan/exclude_entry_locator.rb +74 -0
- data/lib/metz_scan/commands/scan/exclude_provenance.rb +48 -0
- data/lib/metz_scan/commands/scan/inline_directive_locator.rb +53 -0
- data/lib/metz_scan/commands/scan/project_analyzer_runner.rb +6 -3
- data/lib/metz_scan/commands/scan/project_config_scope.rb +26 -4
- data/lib/metz_scan/commands/scan/runner.rb +9 -44
- data/lib/metz_scan/commands/scan/suppression_ledger.rb +76 -0
- data/lib/metz_scan/commands/scan/suppression_ledger_formatter.rb +48 -0
- data/lib/metz_scan/commands/scan/text_renderer.rb +5 -4
- data/lib/metz_scan/commands/scan.rb +5 -5
- data/lib/metz_scan/project_index/rubydex_backend/method_declarations.rb +33 -12
- data/lib/metz_scan/project_index/rubydex_backend/method_visibility.rb +152 -0
- data/lib/metz_scan/project_index/rubydex_backend.rb +2 -1
- data/lib/metz_scan/project_index.rb +1 -1
- data/lib/metz_scan/version.rb +1 -1
- data/metz-scan.gemspec +11 -10
- metadata +23 -5
|
@@ -6,6 +6,7 @@ require "rubocop"
|
|
|
6
6
|
require "stringio"
|
|
7
7
|
|
|
8
8
|
require "metz_scan/commands/scan/project_config_scope"
|
|
9
|
+
require "metz_scan/commands/scan/suppression_ledger"
|
|
9
10
|
require "metz_scan/commands/scan/target_ruby_version"
|
|
10
11
|
|
|
11
12
|
module MetzScan
|
|
@@ -33,7 +34,7 @@ module MetzScan
|
|
|
33
34
|
report = TargetRubyVersion.with_project_config(paths, all_cops: all_cops) do
|
|
34
35
|
parse_output(capture_output(rubocop_argv(paths, all_cops: all_cops)))
|
|
35
36
|
end
|
|
36
|
-
all_cops ? report :
|
|
37
|
+
all_cops ? report : SuppressionLedger.apply(report)
|
|
37
38
|
end
|
|
38
39
|
|
|
39
40
|
def self.with_errors
|
|
@@ -44,7 +45,8 @@ module MetzScan
|
|
|
44
45
|
|
|
45
46
|
def self.rubocop_argv(paths, all_cops:)
|
|
46
47
|
require_metz_plugin
|
|
47
|
-
["--plugin", "rubocop-metz", *cop_selection_argv(all_cops), "--format", FORMATTER,
|
|
48
|
+
["--plugin", "rubocop-metz", *cop_selection_argv(all_cops), *ledger_argv(all_cops), "--format", FORMATTER,
|
|
49
|
+
*paths]
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
def self.require_metz_plugin
|
|
@@ -59,9 +61,10 @@ module MetzScan
|
|
|
59
61
|
TargetFileDiscovery.for_project_config(paths).map { |path| display_path(path) }
|
|
60
62
|
end
|
|
61
63
|
|
|
62
|
-
def self.cop_selection_argv(all_cops)
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
def self.cop_selection_argv(all_cops) = all_cops ? [] : ["--force-default-config", "--only", "Metz"]
|
|
65
|
+
|
|
66
|
+
# The suppression ledger needs inline-suppressed offenses; AutoFix reuses cop_selection_argv without it.
|
|
67
|
+
def self.ledger_argv(all_cops) = all_cops ? [] : ["--display-suppressed"]
|
|
65
68
|
|
|
66
69
|
def self.capture_output(argv)
|
|
67
70
|
out = StringIO.new
|
|
@@ -120,7 +123,7 @@ module MetzScan
|
|
|
120
123
|
end
|
|
121
124
|
|
|
122
125
|
def self.empty_report
|
|
123
|
-
{ "metadata" => metadata, "files" => [],
|
|
126
|
+
{ "metadata" => metadata, "files" => [], "suppressions" => [],
|
|
124
127
|
"summary" => { "offense_count" => 0, "target_file_count" => 0, "inspected_file_count" => 0 } }
|
|
125
128
|
end
|
|
126
129
|
|
|
@@ -139,44 +142,6 @@ module MetzScan
|
|
|
139
142
|
end
|
|
140
143
|
end
|
|
141
144
|
|
|
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
145
|
module TargetFileDiscovery
|
|
181
146
|
module_function
|
|
182
147
|
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "psych"
|
|
4
|
+
require "rubocop"
|
|
5
|
+
|
|
6
|
+
require "metz_scan/commands/scan/config_exclusions"
|
|
7
|
+
require "metz_scan/commands/scan/inline_directive_locator"
|
|
8
|
+
require "metz_scan/commands/scan/project_config_scope"
|
|
9
|
+
|
|
10
|
+
module MetzScan
|
|
11
|
+
module Commands
|
|
12
|
+
class Scan
|
|
13
|
+
# Default mode asks RuboCop to --display-suppressed, then partitions every
|
|
14
|
+
# offense: a project per-cop Exclude hides it (credited even when an
|
|
15
|
+
# inline disable also covers it), else an inline directive hides it, else
|
|
16
|
+
# it is live. Hidden offenses leave `files` and the counts; the ones a
|
|
17
|
+
# project wrote a suppression for are listed under `suppressions`.
|
|
18
|
+
class SuppressionLedger
|
|
19
|
+
SORT_KEYS = %w[path line column cop_name].freeze
|
|
20
|
+
|
|
21
|
+
def self.apply(report)
|
|
22
|
+
new(report, ConfigExclusions.new(ProjectConfigScope.store)).apply
|
|
23
|
+
rescue RuboCop::Error, Psych::Exception
|
|
24
|
+
new(report, NoConfigExclusions).apply
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(report, exclusions)
|
|
28
|
+
@report = report
|
|
29
|
+
@exclusions = exclusions
|
|
30
|
+
@directives = InlineDirectiveLocator.new
|
|
31
|
+
@records = []
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def apply
|
|
35
|
+
files = Array(report["files"]).map { |file| file.merge("offenses" => live_offenses(file)) }
|
|
36
|
+
recount(report.merge("files" => files, "suppressions" => records.sort_by { |r| r.values_at(*SORT_KEYS) }))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
attr_reader :report, :exclusions, :directives, :records
|
|
42
|
+
|
|
43
|
+
def live_offenses(file)
|
|
44
|
+
path = file.fetch("path")
|
|
45
|
+
sourced = Array(file["offenses"]).map { |offense| [offense, suppression_source(path, offense)] }
|
|
46
|
+
live, hidden = sourced.partition { |_offense, source| source.nil? }
|
|
47
|
+
hidden.each { |offense, source| record(path, offense, source.record_fields(path, offense)) }
|
|
48
|
+
live.map(&:first)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def suppression_source(path, offense)
|
|
52
|
+
return exclusions if exclusions.scoped_off?(path, offense)
|
|
53
|
+
|
|
54
|
+
directives if offense["suppressed"]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Hidden offenses with no project-written suppression (stock-default
|
|
58
|
+
# scope, synthetic opt-in coverage) produce no record.
|
|
59
|
+
def record(path, offense, fields)
|
|
60
|
+
records << finding(path, offense).merge(fields) if fields
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def finding(path, offense)
|
|
64
|
+
location = offense.fetch("location")
|
|
65
|
+
{ "cop_name" => offense.fetch("cop_name"), "path" => path, "line" => location.fetch("start_line"),
|
|
66
|
+
"column" => location.fetch("start_column"), "message" => offense.fetch("message") }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def recount(report)
|
|
70
|
+
count = report["files"].sum { |file| file["offenses"].size }
|
|
71
|
+
report.merge("summary" => report.fetch("summary").merge("offense_count" => count))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Commands
|
|
5
|
+
class Scan
|
|
6
|
+
class SuppressionLedgerFormatter
|
|
7
|
+
def initialize(parsed)
|
|
8
|
+
@records = Array(parsed["suppressions"])
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def lines
|
|
12
|
+
return [] if records.empty?
|
|
13
|
+
|
|
14
|
+
[heading, *records.flat_map { |record| record_lines(record) }, ""]
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
attr_reader :records
|
|
20
|
+
|
|
21
|
+
def heading
|
|
22
|
+
missing = records.count { |record| record.fetch("reason_status") == "missing" }
|
|
23
|
+
"Suppressed findings: #{records.size}, #{missing} without a reason"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def record_lines(record)
|
|
27
|
+
[" #{record.fetch('path')}:#{record.fetch('line')}:#{record.fetch('column')} #{record.fetch('message')}",
|
|
28
|
+
" #{source(record)}, #{reason(record)}"]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def source(record)
|
|
32
|
+
return "inline disable at line #{record.dig('directive', 'line')}" unless record.fetch("config")
|
|
33
|
+
|
|
34
|
+
config = record.fetch("config")
|
|
35
|
+
"#{[config.fetch('path'), config.fetch('line')].compact.join(':')} Exclude \"#{config.fetch('pattern')}\""
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def reason(record)
|
|
39
|
+
case record.fetch("reason_status")
|
|
40
|
+
when "present" then "reason: #{record.fetch('reason')}"
|
|
41
|
+
when "missing" then "no reason"
|
|
42
|
+
else "reason not checked"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -6,6 +6,7 @@ require_relative "project_analyzer_summary_aggregate_formatter"
|
|
|
6
6
|
require_relative "project_analyzer_summary_breakdown_formatter"
|
|
7
7
|
require_relative "project_analyzer_triage_formatter"
|
|
8
8
|
require_relative "project_analyzer_triage_priority"
|
|
9
|
+
require_relative "suppression_ledger_formatter"
|
|
9
10
|
|
|
10
11
|
module MetzScan
|
|
11
12
|
module Commands
|
|
@@ -22,16 +23,16 @@ module MetzScan
|
|
|
22
23
|
|
|
23
24
|
def render
|
|
24
25
|
emit_project_analyzer_summary
|
|
25
|
-
sorted_offense_blocks.each
|
|
26
|
-
|
|
27
|
-
end
|
|
28
|
-
ComplianceScorecard.new(parsed).lines.each { |line| stdout.puts line }
|
|
26
|
+
sorted_offense_blocks.each { |cop_name, list| render_block(cop_name, list) }
|
|
27
|
+
closing_lines.each { |line| stdout.puts line }
|
|
29
28
|
end
|
|
30
29
|
|
|
31
30
|
private
|
|
32
31
|
|
|
33
32
|
attr_reader :stdout, :parsed
|
|
34
33
|
|
|
34
|
+
def closing_lines = SuppressionLedgerFormatter.new(parsed).lines + ComplianceScorecard.new(parsed).lines
|
|
35
|
+
|
|
35
36
|
def render_block(cop_name, list)
|
|
36
37
|
stdout.puts heading(cop_name)
|
|
37
38
|
emit_block_metadata(cop_name, representative_offense(list))
|
|
@@ -11,6 +11,7 @@ require_relative "scan/text_renderer"
|
|
|
11
11
|
require_relative "scan/sarif_renderer"
|
|
12
12
|
require_relative "scan/github_annotations_renderer"
|
|
13
13
|
require_relative "scan/auto_fix"
|
|
14
|
+
require_relative "../cli"
|
|
14
15
|
|
|
15
16
|
module MetzScan
|
|
16
17
|
module Commands
|
|
@@ -128,7 +129,6 @@ module MetzScan
|
|
|
128
129
|
def validate(options)
|
|
129
130
|
return missing_path_arg if options.paths.empty?
|
|
130
131
|
return missing_paths(options.paths) unless options.paths.all? { |p| File.exist?(p) }
|
|
131
|
-
return nil if options.auto_fix
|
|
132
132
|
|
|
133
133
|
invalid_format(options.format) unless VALID_FORMATS.include?(options.format)
|
|
134
134
|
end
|
|
@@ -168,7 +168,7 @@ module MetzScan
|
|
|
168
168
|
def parser_error(err)
|
|
169
169
|
stderr.puts "metz-scan scan: #{err.message}"
|
|
170
170
|
stderr.puts USAGE
|
|
171
|
-
|
|
171
|
+
CLI::USAGE_ERROR
|
|
172
172
|
end
|
|
173
173
|
|
|
174
174
|
def print_help
|
|
@@ -179,17 +179,17 @@ module MetzScan
|
|
|
179
179
|
def missing_path_arg
|
|
180
180
|
stderr.puts "metz-scan scan: missing PATH argument."
|
|
181
181
|
stderr.puts USAGE
|
|
182
|
-
|
|
182
|
+
CLI::USAGE_ERROR
|
|
183
183
|
end
|
|
184
184
|
|
|
185
185
|
def missing_paths(paths)
|
|
186
186
|
paths.reject { |p| File.exist?(p) }.each { |p| stderr.puts "metz-scan scan: no such file or directory: #{p}" }
|
|
187
|
-
|
|
187
|
+
CLI::USAGE_ERROR
|
|
188
188
|
end
|
|
189
189
|
|
|
190
190
|
def invalid_format(fmt)
|
|
191
191
|
stderr.puts "metz-scan scan: invalid --format '#{fmt}'. Valid formats: text, json, sarif, gh-annotations."
|
|
192
|
-
|
|
192
|
+
CLI::USAGE_ERROR
|
|
193
193
|
end
|
|
194
194
|
|
|
195
195
|
def runner_error(err)
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "method_visibility"
|
|
4
|
+
|
|
3
5
|
module MetzScan
|
|
4
6
|
class ProjectIndex
|
|
5
7
|
class RubydexBackend
|
|
6
8
|
module MethodDeclarations
|
|
9
|
+
include MethodVisibility
|
|
10
|
+
|
|
7
11
|
def method_declarations
|
|
8
12
|
method_declaration_entries.sort_by do |declaration|
|
|
9
13
|
[declaration.owner_name.to_s, declaration.method_identity.to_s, declaration.path.to_s,
|
|
@@ -15,7 +19,7 @@ module MetzScan
|
|
|
15
19
|
|
|
16
20
|
def method_declaration_entries
|
|
17
21
|
graph.declarations.select { |declaration| method_declaration?(declaration) }
|
|
18
|
-
|
|
22
|
+
.filter_map { |declaration| method_declaration_for(declaration) }
|
|
19
23
|
end
|
|
20
24
|
|
|
21
25
|
def method_declaration?(declaration)
|
|
@@ -31,14 +35,13 @@ module MetzScan
|
|
|
31
35
|
owner_attributes = method_owner_attributes(declaration)
|
|
32
36
|
return unless owner_attributes
|
|
33
37
|
|
|
34
|
-
|
|
35
|
-
identity_attributes(declaration, owner_attributes, method_name)
|
|
36
|
-
.merge(location_attributes(declaration))
|
|
38
|
+
method_declaration_identity(declaration, owner_attributes)
|
|
37
39
|
end
|
|
38
40
|
|
|
39
|
-
def
|
|
40
|
-
|
|
41
|
-
|
|
41
|
+
def method_declaration_identity(declaration, owner_attributes)
|
|
42
|
+
location = location_attributes(declaration)
|
|
43
|
+
method_name = method_name_for(owner_attributes.fetch(:signature))
|
|
44
|
+
identity_attributes(declaration, owner_attributes, method_name, location).merge(location)
|
|
42
45
|
end
|
|
43
46
|
|
|
44
47
|
def method_owner_attributes(declaration)
|
|
@@ -50,11 +53,28 @@ module MetzScan
|
|
|
50
53
|
receiver_kind: receiver_kind_for(raw_owner_name) }
|
|
51
54
|
end
|
|
52
55
|
|
|
53
|
-
def identity_attributes(declaration, owner_attributes, method_name)
|
|
56
|
+
def identity_attributes(declaration, owner_attributes, method_name, location_attributes)
|
|
57
|
+
# For supported Ruby visibility forms, Rubydex::Method#visibility is this analyzer's one index assumption.
|
|
58
|
+
method_identity_attributes(declaration, owner_attributes, method_name, location_attributes)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def method_identity_attributes(declaration, owner_attributes, method_name, location_attributes)
|
|
54
62
|
receiver_kind = owner_attributes.fetch(:receiver_kind)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
63
|
+
core_identity_attributes(declaration, owner_attributes, method_name, receiver_kind)
|
|
64
|
+
.merge(visibility: visibility_for(declaration,
|
|
65
|
+
visibility_attributes(owner_attributes, method_name, receiver_kind,
|
|
66
|
+
location_attributes)))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def core_identity_attributes(declaration, owner_attributes, method_name, receiver_kind)
|
|
70
|
+
{ name: declaration.name, owner_name: owner_attributes.fetch(:owner_name),
|
|
71
|
+
method_name: method_name, signature: owner_attributes.fetch(:signature),
|
|
72
|
+
receiver_kind: receiver_kind, method_identity: method_identity_for(receiver_kind, method_name) }
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def visibility_attributes(owner_attributes, method_name, receiver_kind, location_attributes)
|
|
76
|
+
{ owner_name: owner_attributes.fetch(:owner_name), method_name: method_name,
|
|
77
|
+
receiver_kind: receiver_kind, path: location_attributes.fetch(:path) }
|
|
58
78
|
end
|
|
59
79
|
|
|
60
80
|
def location_attributes(declaration)
|
|
@@ -91,7 +111,8 @@ module MetzScan
|
|
|
91
111
|
|
|
92
112
|
def singleton_owner_name(raw_owner_name)
|
|
93
113
|
owner_name, singleton_tail = raw_owner_name.to_s.split("::<", 2)
|
|
94
|
-
|
|
114
|
+
tail_name = singleton_tail&.delete_suffix(">")
|
|
115
|
+
return unless tail_name == owner_name || tail_name == owner_name.split("::").last
|
|
95
116
|
|
|
96
117
|
owner_name
|
|
97
118
|
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
|
|
5
|
+
module MetzScan
|
|
6
|
+
class ProjectIndex
|
|
7
|
+
class RubydexBackend
|
|
8
|
+
module MethodVisibility
|
|
9
|
+
def visibility_for(declaration, attributes)
|
|
10
|
+
return module_function_visibility(attributes) if module_function_method?(attributes)
|
|
11
|
+
|
|
12
|
+
declaration.visibility.to_s
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def module_function_visibility(attributes)
|
|
16
|
+
attributes.fetch(:receiver_kind) == "singleton" ? "public" : "private"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def module_function_method?(attributes)
|
|
20
|
+
facts = module_function_facts_for(attributes.fetch(:path))
|
|
21
|
+
facts.fetch(module_function_owner_name(attributes.fetch(:owner_name)), [])
|
|
22
|
+
.include?(attributes.fetch(:method_name))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def module_function_owner_name(owner_name)
|
|
26
|
+
owner_name.to_s.split("::<", 2).first
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def module_function_facts_for(path)
|
|
30
|
+
return {} unless path && File.file?(path)
|
|
31
|
+
|
|
32
|
+
@module_function_facts_by_path ||= {}
|
|
33
|
+
@module_function_facts_by_path[path] ||= ModuleFunctionFacts.new(path).to_h
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class ModuleFunctionFacts
|
|
37
|
+
def initialize(path)
|
|
38
|
+
@path = path
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def to_h
|
|
42
|
+
add_scope_tree_facts(ast, [], {})
|
|
43
|
+
rescue Parser::SyntaxError
|
|
44
|
+
{}
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :path
|
|
50
|
+
|
|
51
|
+
def ast
|
|
52
|
+
processed_source.ast
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def processed_source
|
|
56
|
+
RuboCop::ProcessedSource.new(File.read(path), RUBY_VERSION.to_f)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def add_scope_tree_facts(node, owner_stack, facts)
|
|
60
|
+
return facts unless node
|
|
61
|
+
return add_scope_facts(node, owner_stack, facts) if scope_node?(node)
|
|
62
|
+
|
|
63
|
+
add_child_scope_facts(node, owner_stack, facts)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def add_child_scope_facts(node, owner_stack, facts)
|
|
67
|
+
node&.children&.each do |child|
|
|
68
|
+
add_child_scope_fact(child, owner_stack, facts) if child.is_a?(Parser::AST::Node)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
facts
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def add_child_scope_fact(child, owner_stack, facts)
|
|
75
|
+
return add_scope_facts(child, owner_stack, facts) if scope_node?(child)
|
|
76
|
+
|
|
77
|
+
add_child_scope_facts(child, owner_stack, facts)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def add_scope_facts(scope, owner_stack, facts)
|
|
81
|
+
owner_name = owner_name_for(scope, owner_stack)
|
|
82
|
+
nested_stack = owner_name ? [owner_name] : owner_stack
|
|
83
|
+
add_module_function_facts(facts, owner_name, scope) if owner_name
|
|
84
|
+
add_scope_tree_facts(scope.body, nested_stack, facts)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def add_module_function_facts(facts, owner_name, scope)
|
|
88
|
+
names = explicit_module_function_names(scope) + default_module_function_names(scope)
|
|
89
|
+
facts[owner_name] = Array(facts[owner_name]) | names unless names.empty?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def owner_name_for(scope, owner_stack)
|
|
93
|
+
name = scope.identifier.const_name if scope.identifier&.const_type?
|
|
94
|
+
return unless name
|
|
95
|
+
return name if name.include?("::") || owner_stack.empty?
|
|
96
|
+
|
|
97
|
+
"#{owner_stack.last}::#{name}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def explicit_module_function_names(scope)
|
|
101
|
+
module_function_sends(scope).flat_map { |node| literal_names(node.arguments) }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def default_module_function_names(scope)
|
|
105
|
+
lines = bare_module_function_lines(scope)
|
|
106
|
+
direct_defs(scope).select { |node| lines.any? { |line| line < node.loc.keyword.line } }
|
|
107
|
+
.map { |node| node.method_name.to_s }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def bare_module_function_lines(scope)
|
|
111
|
+
module_function_sends(scope).select { |node| node.arguments.empty? }
|
|
112
|
+
.map { |node| node.loc.selector.line }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def module_function_sends(scope)
|
|
116
|
+
direct_descendants(scope, :send).select do |node|
|
|
117
|
+
!node.receiver && node.method_name == :module_function
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def direct_defs(scope)
|
|
122
|
+
direct_descendants(scope, :def)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def literal_names(arguments)
|
|
126
|
+
arguments.filter_map { |argument| literal_name(argument) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def literal_name(argument)
|
|
130
|
+
argument.value.to_s if argument&.sym_type? || argument&.str_type?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def direct_descendants(scope, *types)
|
|
134
|
+
scope.each_descendant(*types).reject { |node| nested_scope_between?(node, scope) }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def nested_scope_between?(node, scope)
|
|
138
|
+
node.each_ancestor.any? do |ancestor|
|
|
139
|
+
break false if ancestor.equal?(scope)
|
|
140
|
+
|
|
141
|
+
scope_node?(ancestor)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def scope_node?(node)
|
|
146
|
+
node&.class_type? || node&.module_type?
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -28,7 +28,8 @@ module MetzScan
|
|
|
28
28
|
require "rubydex"
|
|
29
29
|
|
|
30
30
|
files = ruby_files_for(paths)
|
|
31
|
-
|
|
31
|
+
# 0.4.0: Config owns workspace_path; Graph.new no longer takes it (Shopify/rubydex#965).
|
|
32
|
+
graph = Rubydex::Graph.configure_for_workspace(workspace_path_for(paths))
|
|
32
33
|
index_errors = index_graph(graph, files, workspace)
|
|
33
34
|
|
|
34
35
|
new(graph: graph, indexed_files: files, index_errors: index_errors)
|
|
@@ -8,7 +8,7 @@ module MetzScan
|
|
|
8
8
|
class ProjectIndex
|
|
9
9
|
Declaration = Struct.new(:name, :path, :kind, keyword_init: true)
|
|
10
10
|
MethodDeclaration = Struct.new(:name, :owner_name, :method_name, :signature, :path, :line, :column,
|
|
11
|
-
:receiver_kind, :method_identity, keyword_init: true)
|
|
11
|
+
:receiver_kind, :method_identity, :visibility, keyword_init: true)
|
|
12
12
|
Reference = Struct.new(:name, :path, :line, :column, keyword_init: true)
|
|
13
13
|
|
|
14
14
|
class UnknownBackendError < StandardError; end
|
data/lib/metz_scan/version.rb
CHANGED
data/metz-scan.gemspec
CHANGED
|
@@ -18,21 +18,22 @@ Gem::Specification.new do |spec|
|
|
|
18
18
|
spec.required_ruby_version = ">= 3.3"
|
|
19
19
|
|
|
20
20
|
spec.metadata["source_code_uri"] = spec.homepage
|
|
21
|
+
spec.metadata["documentation_uri"] = "#{spec.homepage}/blob/main/README.md"
|
|
21
22
|
spec.metadata["changelog_uri"] = "#{spec.homepage}/releases"
|
|
22
23
|
spec.metadata["bug_tracker_uri"] = "#{spec.homepage}/issues"
|
|
23
24
|
spec.metadata["github_repo"] = "ssh://github.com/fuentesjr/metz-scan"
|
|
24
25
|
spec.metadata["github_package_uri"] = "https://github.com/users/fuentesjr/packages/rubygems/package/metz-scan"
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
#
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
# Resolve files from the gemspec directory so evaluation is CWD-independent.
|
|
28
|
+
# Do not raise when the entrypoint is absent: Dependabot evaluates gemspecs in
|
|
29
|
+
# a sparse tree that only materializes require_relative targets (#41). Package
|
|
30
|
+
# completeness is checked by release_metadata_test against the full checkout.
|
|
31
|
+
gem_root = __dir__
|
|
32
|
+
spec.files = Dir.glob("lib/**/*", File::FNM_DOTMATCH, base: gem_root)
|
|
33
|
+
.reject { |f| File.directory?(File.join(gem_root, f)) } +
|
|
34
|
+
Dir.glob("bin/metz-scan", base: gem_root)
|
|
35
|
+
.select { |f| File.file?(File.join(gem_root, f)) } +
|
|
36
|
+
["LICENSE", "metz-scan.gemspec"].select { |f| File.exist?(File.join(gem_root, f)) }
|
|
36
37
|
spec.bindir = "bin"
|
|
37
38
|
spec.executables = ["metz-scan"]
|
|
38
39
|
spec.require_paths = ["lib"]
|