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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f8ab2475c975b0058d11664aeddf01dd91b55538137e55ed65af273c4aa62874
|
|
4
|
+
data.tar.gz: d518ad9b70df91a4e74509f03883a911ea5a858cee6bb72a4f17328d7831c291
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d63399acc40747d1630dd66a0579d9ea906b71341acfc65ace51f9c39f8dd4e3f1d87b5892a2d0e6b14ddbd115aa8d52ad5eccf69865be695776b5c36ddcc538
|
|
7
|
+
data.tar.gz: 5d450a47bc76f4dab47a041b4b278e28568f5d692b14a280e95705b7df4106af4c8f9e98ae3c53d8014211a185728e4668bece96637c846608891a4665604fa8
|
|
@@ -50,7 +50,7 @@ module MetzScan
|
|
|
50
50
|
|
|
51
51
|
def auto_discovered_base_candidates
|
|
52
52
|
index.declarations.select { |declaration| auto_discovered_base_candidate?(declaration) }
|
|
53
|
-
|
|
53
|
+
.map(&:name).compact.uniq.sort
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def auto_discovered_base_candidate?(declaration)
|
|
@@ -51,7 +51,7 @@ module MetzScan
|
|
|
51
51
|
|
|
52
52
|
def declarations
|
|
53
53
|
index.declarations.select { |declaration| declaration_candidate?(declaration) }
|
|
54
|
-
|
|
54
|
+
.sort_by { |declaration| declaration.name.to_s }
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
def declaration_candidate?(declaration)
|
|
@@ -50,7 +50,7 @@ module MetzScan
|
|
|
50
50
|
|
|
51
51
|
def declarations
|
|
52
52
|
index.declarations.select { |declaration| declaration_candidate?(declaration) }
|
|
53
|
-
|
|
53
|
+
.sort_by { |declaration| declaration.name.to_s }
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def declaration_candidate?(declaration)
|
|
@@ -58,7 +58,7 @@ module MetzScan
|
|
|
58
58
|
index = app_index(parts) || lib_index(parts)
|
|
59
59
|
return false unless index
|
|
60
60
|
|
|
61
|
-
parts[(index + 1)..].
|
|
61
|
+
parts[(index + 1)..].intersect?(IGNORED_SUPPORT_SEGMENTS)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def app_index(parts)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "node_predicates"
|
|
4
|
+
require_relative "receiver_resolution"
|
|
5
|
+
require_relative "scope_facts"
|
|
6
|
+
require_relative "scope_resolution"
|
|
7
|
+
|
|
8
|
+
module MetzScan
|
|
9
|
+
module Analyzers
|
|
10
|
+
class TestCallsPrivateMethod
|
|
11
|
+
class CallSiteCollector
|
|
12
|
+
include NodePredicates
|
|
13
|
+
include ReceiverResolution
|
|
14
|
+
include ScopeFacts
|
|
15
|
+
include ScopeResolution
|
|
16
|
+
|
|
17
|
+
def initialize(path, declarations:)
|
|
18
|
+
@path = path
|
|
19
|
+
@declarations = declarations
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
walk(processed_source.ast, [])
|
|
24
|
+
rescue Parser::SyntaxError
|
|
25
|
+
[]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
attr_reader :path, :declarations
|
|
31
|
+
|
|
32
|
+
def processed_source
|
|
33
|
+
RuboCop::ProcessedSource.new(File.read(path), RUBY_VERSION.to_f)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def walk(node, scopes)
|
|
37
|
+
return [] unless node
|
|
38
|
+
return [] if shared_example_group_block?(node)
|
|
39
|
+
return walk(node.body, scopes + [scope_for_block(node, scopes)]) if example_group_block?(node)
|
|
40
|
+
return walk_scoped_node(node, scopes) if scoped_node?(node)
|
|
41
|
+
|
|
42
|
+
walk_regular_node(node, scopes)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def walk_scoped_node(node, scopes)
|
|
46
|
+
scope = node.class_type? ? scope_for_class(node) : scope_for_local(node, scopes)
|
|
47
|
+
walk(node.body, scopes + [scope])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def walk_regular_node(node, scopes)
|
|
51
|
+
[call_site_for(node, scopes), *walk_child_nodes(node, scopes)].compact
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def walk_child_nodes(node, scopes)
|
|
55
|
+
node.children.grep(RuboCop::AST::Node).flat_map { |child| walk(child, scopes) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def call_site_for(node, scopes)
|
|
59
|
+
return unless private_send_call?(node)
|
|
60
|
+
|
|
61
|
+
build_call_site(node, literal_method_name(node.arguments.first), receiver_target(node.receiver, scopes))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def private_send_call?(node)
|
|
65
|
+
send_like?(node) && SEND_METHODS.include?(node.method_name) && literal_method_name(node.arguments.first)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def build_call_site(node, method_name, receiver)
|
|
69
|
+
return unless receiver
|
|
70
|
+
|
|
71
|
+
CallSite.new(path: path, line: node.loc.selector.line, owner_name: receiver.fetch(:owner_name),
|
|
72
|
+
method_name: method_name, method_identity: method_identity(receiver.fetch(:receiver_kind),
|
|
73
|
+
method_name),
|
|
74
|
+
receiver_kind: receiver.fetch(:receiver_kind))
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../occurrence"
|
|
4
|
+
|
|
5
|
+
module MetzScan
|
|
6
|
+
module Analyzers
|
|
7
|
+
class TestCallsPrivateMethod
|
|
8
|
+
Finding = Struct.new(:source, :rule_id, :message, :path, :line, :owner_name, :method_name,
|
|
9
|
+
:method_identity, :receiver_kind, :visibility, :occurrences,
|
|
10
|
+
:project_analyzer_status, :confidence, :triage_severity, :triage_summary,
|
|
11
|
+
:project_analyzer_metadata, :why_it_matters, :suggested_next_moves,
|
|
12
|
+
keyword_init: true) do
|
|
13
|
+
def report_occurrences
|
|
14
|
+
occurrences.map { |occurrence| Occurrence.from(occurrence, context: occurrence_context) }.compact
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def occurrence_context
|
|
18
|
+
"#{owner_name}#{receiver_kind == 'singleton' ? '.' : '#'}#{method_name}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class TestCallsPrivateMethod
|
|
6
|
+
module Metadata
|
|
7
|
+
def project_analyzer_metadata_for(site, visibility)
|
|
8
|
+
category_metadata(visibility)
|
|
9
|
+
.merge(identity_metadata(site, visibility))
|
|
10
|
+
.merge("call_site" => { "path" => site.path, "line" => site.line })
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def category_metadata(visibility)
|
|
14
|
+
{ "project_analyzer_category" => "test_calls_private_method",
|
|
15
|
+
"test_private_method_category" => "index_confirmed_#{visibility.tr('/', '_')}" }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def identity_metadata(site, visibility)
|
|
19
|
+
{ "owner_name" => site.owner_name, "method_name" => site.method_name,
|
|
20
|
+
"method_identity" => site.method_identity,
|
|
21
|
+
"receiver_kind" => site.receiver_kind,
|
|
22
|
+
"visibility" => visibility }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class TestCallsPrivateMethod
|
|
6
|
+
module NodePredicates
|
|
7
|
+
def literal_method_name(node)
|
|
8
|
+
node.value.to_s if node&.sym_type? || node&.str_type?
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def described_class_call?(node)
|
|
12
|
+
send_like?(node) && bare_call?(node) && node.method_name == :described_class
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def bare_call?(node)
|
|
16
|
+
!node.receiver && node.arguments.empty?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def const_name(node)
|
|
20
|
+
node.const_name if node&.const_type?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def method_identity(receiver_kind, method_name)
|
|
24
|
+
"#{receiver_kind}:#{method_name}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def send_like?(node)
|
|
28
|
+
node&.send_type? || node&.csend_type?
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def example_group_block?(node)
|
|
32
|
+
node&.block_type? && group_method?(node, EXAMPLE_GROUP_METHODS)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def shared_example_group_block?(node)
|
|
36
|
+
node&.block_type? && group_method?(node, SHARED_EXAMPLE_GROUP_METHODS)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def local_scope_node?(node)
|
|
40
|
+
node&.def_type? || node&.defs_type? || node&.block_type?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def scoped_node?(node)
|
|
44
|
+
node&.class_type? || local_scope_node?(node)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def group_method?(node, methods)
|
|
48
|
+
methods.include?(node.send_node.method_name) && example_group_receiver?(node.send_node.receiver)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def example_group_receiver?(receiver)
|
|
52
|
+
!receiver || (receiver.const_type? && receiver.const_name == "RSpec")
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class TestCallsPrivateMethod
|
|
6
|
+
module ReceiverResolution
|
|
7
|
+
def receiver_target(receiver, scopes)
|
|
8
|
+
return unless receiver
|
|
9
|
+
return const_receiver_target(receiver) if receiver.const_type?
|
|
10
|
+
return send_receiver_target(receiver, scopes) if send_like?(receiver)
|
|
11
|
+
|
|
12
|
+
assigned_receiver_target(receiver, scopes) if VARIABLE_TYPES.include?(receiver.type)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def const_receiver_target(receiver)
|
|
16
|
+
{ owner_name: receiver.const_name, receiver_kind: "singleton" }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def send_receiver_target(receiver, scopes)
|
|
20
|
+
owner_name = new_receiver_owner(receiver, scopes) || subject_receiver_owner(receiver, scopes)
|
|
21
|
+
{ owner_name: owner_name, receiver_kind: "instance" } if owner_name
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def assigned_receiver_target(receiver, scopes)
|
|
25
|
+
owner_name = assigned_owner_for(receiver.children.first.to_s, scopes, receiver.type)
|
|
26
|
+
{ owner_name: owner_name, receiver_kind: "instance" } if owner_name
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def new_receiver_owner(node, scopes)
|
|
30
|
+
return unless send_like?(node) && node.method_name == :new
|
|
31
|
+
|
|
32
|
+
owner_for_new_receiver(node.receiver, scopes)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def owner_for_new_receiver(receiver, scopes)
|
|
36
|
+
return receiver.const_name if receiver&.const_type?
|
|
37
|
+
|
|
38
|
+
current_sut_name(scopes) if described_class_call?(receiver)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def subject_receiver_owner(node, scopes)
|
|
42
|
+
return unless bare_call?(node)
|
|
43
|
+
|
|
44
|
+
node.method_name == :subject ? bare_subject_owner(scopes) : named_subject_owner(node.method_name, scopes)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def named_subject_owner(name, scopes)
|
|
48
|
+
scope = scopes.reverse.find { |candidate| candidate.subject_definitions.key?(name) }
|
|
49
|
+
scope&.subject_definitions&.fetch(name) == :sut ? scope.sut_name : nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def bare_subject_owner(scopes)
|
|
53
|
+
return if scopes.any? { |scope| scope.subject_definitions[:subject] == :non_sut }
|
|
54
|
+
|
|
55
|
+
explicit_subject_owner(scopes) || current_sut_name(scopes)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def explicit_subject_owner(scopes)
|
|
59
|
+
scope = scopes.reverse.find { |candidate| candidate.subject_definitions[:subject] == :sut }
|
|
60
|
+
scope&.sut_name
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def assigned_owner_for(name, scopes, variable_type)
|
|
64
|
+
variable_type == :lvar ? local_owner_for(name, scopes) : instance_owner_for(name, scopes)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def local_owner_for(name, scopes)
|
|
68
|
+
scope = scopes.reverse.find { |candidate| candidate.kind == :local && candidate.assignments.key?(name) }
|
|
69
|
+
scope&.assignments&.fetch(name)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def instance_owner_for(name, scopes)
|
|
73
|
+
owner_values = scopes.select { |scope| scope.assignments.key?(name) }
|
|
74
|
+
.map { |scope| scope.assignments.fetch(name) }
|
|
75
|
+
owner_values.one? ? owner_values.first : nil
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class TestCallsPrivateMethod
|
|
6
|
+
module ScopeFacts
|
|
7
|
+
def assignments_for(scope_node)
|
|
8
|
+
grouped_assignment_owners(scope_node).transform_values { |owners| owners.one? ? owners.first : nil }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def grouped_assignment_owners(scope_node)
|
|
12
|
+
direct_descendants(scope_node, *ASSIGNMENT_TYPES).each_with_object({}) do |node, grouped|
|
|
13
|
+
record_assignment_owner(grouped, node)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def record_assignment_owner(grouped, node)
|
|
18
|
+
name = node.children.first.to_s
|
|
19
|
+
grouped[name] ||= []
|
|
20
|
+
grouped[name] << const_new_owner(node.children[1])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def const_new_owner(node)
|
|
24
|
+
return unless send_like?(node) && node.method_name == :new && node.receiver&.const_type?
|
|
25
|
+
|
|
26
|
+
node.receiver.const_name
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def subject_definitions_for(group, sut_name)
|
|
30
|
+
direct_descendants(group, :block).each_with_object({}) do |block, definitions|
|
|
31
|
+
record_subject_definition(definitions, block, sut_name)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def record_subject_definition(definitions, block, sut_name)
|
|
36
|
+
return unless SUBJECT_DEFINERS.include?(block.send_node.method_name)
|
|
37
|
+
return if block.send_node.receiver
|
|
38
|
+
|
|
39
|
+
definitions[subject_name(block.send_node)] = subject_definition_value(block, sut_name)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def subject_definition_value(block, sut_name)
|
|
43
|
+
subject_body_sut?(block, sut_name) ? :sut : :non_sut
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def subject_body_sut?(block, sut_name)
|
|
47
|
+
sut_name && subject_body_owner(block.body, sut_name) == sut_name
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def subject_body_owner(body, sut_name)
|
|
51
|
+
return unless send_like?(body) && body.method_name == :new
|
|
52
|
+
return body.receiver.const_name if body.receiver&.const_type?
|
|
53
|
+
|
|
54
|
+
sut_name if described_class_call?(body.receiver)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def subject_name(send_node)
|
|
58
|
+
argument = send_node.arguments.first
|
|
59
|
+
argument&.sym_type? ? argument.value : :subject
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def direct_descendants(scope_node, *types)
|
|
63
|
+
scope_node.each_descendant(*types).reject { |node| nested_scope_between?(node, scope_node) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def nested_scope_between?(node, scope_node)
|
|
67
|
+
node.each_ancestor.any? do |ancestor|
|
|
68
|
+
break false if ancestor.equal?(scope_node)
|
|
69
|
+
|
|
70
|
+
ancestor.class_type? || example_group_block?(ancestor)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class TestCallsPrivateMethod
|
|
6
|
+
module ScopeResolution
|
|
7
|
+
def scope_for_block(node, scopes)
|
|
8
|
+
sut_name = group_sut_name(node) || current_sut_name(scopes)
|
|
9
|
+
Scope.new(kind: :example_group, node: node, sut_name: sut_name,
|
|
10
|
+
subject_definitions: subject_definitions_for(node, sut_name),
|
|
11
|
+
assignments: assignments_for(node))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def scope_for_class(node)
|
|
15
|
+
Scope.new(kind: :class, node: node, sut_name: class_sut_name(node),
|
|
16
|
+
subject_definitions: {}, assignments: assignments_for(node))
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def scope_for_local(node, scopes)
|
|
20
|
+
Scope.new(kind: :local, node: node, sut_name: current_sut_name(scopes),
|
|
21
|
+
subject_definitions: {}, assignments: assignments_for(node))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def group_sut_name(block)
|
|
25
|
+
const_name(block.send_node.arguments.first)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def class_sut_name(node)
|
|
29
|
+
name = const_name(node.identifier)
|
|
30
|
+
demangled_name_for(name) if name
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def demangled_name_for(test_class_name)
|
|
34
|
+
candidate = demangled_candidate(test_class_name)
|
|
35
|
+
unique_declaration_match(candidate) if candidate
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def demangled_candidate(test_class_name)
|
|
39
|
+
return test_class_name.delete_suffix("Test") if test_class_name.end_with?("Test")
|
|
40
|
+
|
|
41
|
+
test_class_name.delete_prefix("Test") if test_class_name.start_with?("Test")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def unique_declaration_match(candidate)
|
|
45
|
+
matches = declarations.map(&:name).select { |name| name == candidate || name.end_with?("::#{candidate}") }
|
|
46
|
+
matches.one? ? matches.first : nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def current_sut_name(scopes)
|
|
50
|
+
scope = scopes.reverse.find { |candidate| SCOPE_KINDS.include?(candidate.kind) }
|
|
51
|
+
scope&.sut_name
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class TestCallsPrivateMethod
|
|
6
|
+
module Triage
|
|
7
|
+
def triage_attributes_for
|
|
8
|
+
project_analyzer_triage_attributes
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def project_analyzer_context_attributes(site, visibility)
|
|
12
|
+
{ project_analyzer_metadata: project_analyzer_metadata_for(site, visibility),
|
|
13
|
+
why_it_matters: WHY, suggested_next_moves: SUGGESTED_NEXT_MOVES }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def message_for(site, visibility)
|
|
17
|
+
"#{site.owner_name}#{site.receiver_kind == 'singleton' ? '.' : '#'}#{site.method_name} is " \
|
|
18
|
+
"#{visibility}; test behavior through the public interface instead of calling it with send."
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
|
|
5
|
+
require_relative "../project_index"
|
|
6
|
+
require_relative "project_analyzer_triage"
|
|
7
|
+
require_relative "ruby_file_enumerator"
|
|
8
|
+
require_relative "test_calls_private_method/call_site_collector"
|
|
9
|
+
require_relative "test_calls_private_method/finding"
|
|
10
|
+
require_relative "test_calls_private_method/metadata"
|
|
11
|
+
require_relative "test_calls_private_method/triage"
|
|
12
|
+
|
|
13
|
+
module MetzScan
|
|
14
|
+
module Analyzers
|
|
15
|
+
# Reports tests that call index-confirmed private or protected SUT methods.
|
|
16
|
+
class TestCallsPrivateMethod
|
|
17
|
+
include ProjectAnalyzerTriage
|
|
18
|
+
include Metadata
|
|
19
|
+
include Triage
|
|
20
|
+
|
|
21
|
+
RULE_ID = "MetzProject/TestCallsPrivateMethod"
|
|
22
|
+
PROJECT_ANALYZER_STATUS = "candidate"
|
|
23
|
+
CONFIDENCE = "high"
|
|
24
|
+
TRIAGE_SEVERITY = "manual review"
|
|
25
|
+
TRIAGE_SUMMARY = "Candidate private-method testing signal; review whether the test should cover public behavior."
|
|
26
|
+
WHY = "This is the index-confirmed form of Metz/TestReachesPrivate. Prefer this signal when both are " \
|
|
27
|
+
"enabled because it confirms the test reaches a private or protected method on the subject under test."
|
|
28
|
+
SUGGESTED_NEXT_MOVES = [
|
|
29
|
+
"Exercise the public behavior that uses the private method instead of calling the method directly.",
|
|
30
|
+
"Promote the method to the public interface only when callers outside the object genuinely need it.",
|
|
31
|
+
"Keep Metz/TestReachesPrivate as the lower-confidence fallback for index-less runs."
|
|
32
|
+
].freeze
|
|
33
|
+
TEST_FILE_PATTERNS = [
|
|
34
|
+
"**/*_test.rb",
|
|
35
|
+
"**/test_*.rb",
|
|
36
|
+
"**/*_spec.rb"
|
|
37
|
+
].freeze
|
|
38
|
+
PRIVATE_VISIBILITIES = %w[private protected].freeze
|
|
39
|
+
SEND_METHODS = %i[send __send__].freeze
|
|
40
|
+
EXAMPLE_GROUP_METHODS = %i[describe context feature example_group xdescribe xcontext
|
|
41
|
+
fdescribe fcontext].freeze
|
|
42
|
+
SHARED_EXAMPLE_GROUP_METHODS = %i[shared_examples shared_examples_for shared_context].freeze
|
|
43
|
+
SUBJECT_DEFINERS = %i[subject subject!].freeze
|
|
44
|
+
ASSIGNMENT_TYPES = %i[lvasgn ivasgn].freeze
|
|
45
|
+
VARIABLE_TYPES = %i[lvar ivar].freeze
|
|
46
|
+
SCOPE_KINDS = %i[class example_group].freeze
|
|
47
|
+
private_constant :TEST_FILE_PATTERNS, :PRIVATE_VISIBILITIES, :SEND_METHODS, :EXAMPLE_GROUP_METHODS,
|
|
48
|
+
:SHARED_EXAMPLE_GROUP_METHODS, :SUBJECT_DEFINERS, :ASSIGNMENT_TYPES, :VARIABLE_TYPES,
|
|
49
|
+
:SCOPE_KINDS
|
|
50
|
+
|
|
51
|
+
CallSite = Struct.new(:path, :line, :owner_name, :method_name, :method_identity, :receiver_kind,
|
|
52
|
+
keyword_init: true)
|
|
53
|
+
Scope = Struct.new(:kind, :node, :sut_name, :subject_definitions, :assignments, keyword_init: true)
|
|
54
|
+
|
|
55
|
+
def self.test_file?(path)
|
|
56
|
+
normalized = path.to_s
|
|
57
|
+
basename = File.basename(normalized)
|
|
58
|
+
normalized.end_with?("_test.rb", "_spec.rb") || (basename.start_with?("test_") && basename.end_with?(".rb"))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize(paths: nil, index: nil)
|
|
62
|
+
@paths = Array(paths)
|
|
63
|
+
@index = index || ProjectIndex.build(@paths)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def call
|
|
67
|
+
return [] unless index.available?
|
|
68
|
+
|
|
69
|
+
call_sites.filter_map { |site| finding_for(site) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
attr_reader :paths, :index
|
|
75
|
+
|
|
76
|
+
def call_sites
|
|
77
|
+
test_files.flat_map { |path| CallSiteCollector.new(path, declarations: index.declarations).call }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def test_files
|
|
81
|
+
RubyFileEnumerator.new(paths: paths, index: index).call.select { |path| self.class.test_file?(path) }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def finding_for(site)
|
|
85
|
+
visibility = confirmed_visibility_for(site)
|
|
86
|
+
return unless visibility
|
|
87
|
+
|
|
88
|
+
Finding.new(finding_attributes(site, visibility))
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def finding_attributes(site, visibility)
|
|
92
|
+
core_finding_attributes(site, visibility)
|
|
93
|
+
.merge(triage_attributes_for)
|
|
94
|
+
.merge(project_analyzer_context_attributes(site, visibility))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def core_finding_attributes(site, visibility)
|
|
98
|
+
{ source: source_name, rule_id: RULE_ID, message: message_for(site, visibility),
|
|
99
|
+
path: site.path, line: site.line, owner_name: site.owner_name, method_name: site.method_name,
|
|
100
|
+
method_identity: site.method_identity, receiver_kind: site.receiver_kind, visibility: visibility,
|
|
101
|
+
occurrences: [site] }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def confirmed_visibility_for(site)
|
|
105
|
+
declarations = confirming_declarations_for(site)
|
|
106
|
+
return if declarations.empty?
|
|
107
|
+
|
|
108
|
+
visibilities = declarations.map(&:visibility).uniq
|
|
109
|
+
return unless visibilities.all? { |visibility| PRIVATE_VISIBILITIES.include?(visibility) }
|
|
110
|
+
|
|
111
|
+
visibilities.one? ? visibilities.first : visibilities.join("/")
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def confirming_declarations_for(site)
|
|
115
|
+
methods_by_owner.fetch(site.owner_name, []).select do |declaration|
|
|
116
|
+
declaration.method_identity == site.method_identity && !self.class.test_file?(declaration.path.to_s)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def methods_by_owner
|
|
121
|
+
@methods_by_owner ||= index.method_declarations.group_by(&:owner_name)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -55,13 +55,17 @@ module MetzScan
|
|
|
55
55
|
"joins, merges, Arel, single-key finders, bang finders, exists?, or take."
|
|
56
56
|
},
|
|
57
57
|
"MetzProject/SubclassOverridePressure" => {
|
|
58
|
-
"evidence" => "Expanded active fixtures show 148 findings
|
|
59
|
-
"
|
|
60
|
-
"replacement override families.",
|
|
58
|
+
"evidence" => "Expanded active fixtures show 148 findings across broad-root, abstract hook, " \
|
|
59
|
+
"cooperative, and replacement override families.",
|
|
61
60
|
"next" => "Keep parked unless future generic work separates design-pressure hook contracts " \
|
|
62
61
|
"from deliberate extension protocols and setup conventions.",
|
|
63
62
|
"not_next" => "Do not implement classifier behavior, promote, make default-output eligible, " \
|
|
64
63
|
"suppress medium categories, or retune thresholds."
|
|
64
|
+
},
|
|
65
|
+
"MetzProject/TestCallsPrivateMethod" => {
|
|
66
|
+
"evidence" => "Index-confirmed testing analyzer; dogfood before any promotion.",
|
|
67
|
+
"next" => "Dogfood on test-heavy RSpec and Minitest codebases with Rubydex available.",
|
|
68
|
+
"not_next" => "Do not promote or add runtime double-report coordination with Metz/TestReachesPrivate."
|
|
65
69
|
}
|
|
66
70
|
}.freeze
|
|
67
71
|
|
data/lib/metz_scan/cli.rb
CHANGED
|
@@ -16,6 +16,9 @@ module MetzScan
|
|
|
16
16
|
|
|
17
17
|
SUBCOMMANDS = SUBCOMMAND_SUMMARIES.keys.freeze
|
|
18
18
|
|
|
19
|
+
# sysexits(3) EX_USAGE: keeps caller errors distinct from exit 1, "findings reported".
|
|
20
|
+
USAGE_ERROR = 64
|
|
21
|
+
|
|
19
22
|
def self.start(argv = ARGV, stdout: $stdout, stderr: $stderr)
|
|
20
23
|
new(stdout: stdout, stderr: stderr).run(argv)
|
|
21
24
|
end
|
|
@@ -55,15 +58,14 @@ module MetzScan
|
|
|
55
58
|
|
|
56
59
|
def show_help_and_fail
|
|
57
60
|
stderr.puts help_text
|
|
58
|
-
|
|
61
|
+
USAGE_ERROR
|
|
59
62
|
end
|
|
60
63
|
|
|
61
64
|
def dispatch(args)
|
|
62
65
|
name = args.shift
|
|
63
66
|
return unknown_subcommand(name) unless SUBCOMMANDS.include?(name)
|
|
64
67
|
|
|
65
|
-
|
|
66
|
-
handler ? handler.run(args, stdout: stdout, stderr: stderr) : stub_subcommand(name)
|
|
68
|
+
subcommand_handler(name).run(args, stdout: stdout, stderr: stderr)
|
|
67
69
|
end
|
|
68
70
|
|
|
69
71
|
SUBCOMMAND_HANDLERS = { "rules" => "Rules", "explain" => "Explain", "scan" => "Scan",
|
|
@@ -71,22 +73,14 @@ module MetzScan
|
|
|
71
73
|
private_constant :SUBCOMMAND_HANDLERS
|
|
72
74
|
|
|
73
75
|
def subcommand_handler(name)
|
|
74
|
-
klass_name = SUBCOMMAND_HANDLERS[name]
|
|
75
|
-
return unless klass_name
|
|
76
|
-
|
|
77
76
|
require_relative "commands/#{name.tr('-', '_')}"
|
|
78
|
-
Commands.const_get(
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def stub_subcommand(name)
|
|
82
|
-
stderr.puts "metz-scan: subcommand '#{name}' is not yet implemented."
|
|
83
|
-
1
|
|
77
|
+
Commands.const_get(SUBCOMMAND_HANDLERS.fetch(name))
|
|
84
78
|
end
|
|
85
79
|
|
|
86
80
|
def unknown_subcommand(name)
|
|
87
81
|
stderr.puts "metz-scan: unknown subcommand '#{name}'."
|
|
88
82
|
stderr.puts help_text
|
|
89
|
-
|
|
83
|
+
USAGE_ERROR
|
|
90
84
|
end
|
|
91
85
|
|
|
92
86
|
def help_text
|