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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/metz_scan/analyzers/inheritance_descendants.rb +1 -1
  3. data/lib/metz_scan/analyzers/namespace_leak_pressure.rb +1 -1
  4. data/lib/metz_scan/analyzers/package_dependency_pressure.rb +1 -1
  5. data/lib/metz_scan/analyzers/package_map.rb +1 -1
  6. data/lib/metz_scan/analyzers/test_calls_private_method/call_site_collector.rb +79 -0
  7. data/lib/metz_scan/analyzers/test_calls_private_method/finding.rb +23 -0
  8. data/lib/metz_scan/analyzers/test_calls_private_method/metadata.rb +27 -0
  9. data/lib/metz_scan/analyzers/test_calls_private_method/node_predicates.rb +57 -0
  10. data/lib/metz_scan/analyzers/test_calls_private_method/receiver_resolution.rb +80 -0
  11. data/lib/metz_scan/analyzers/test_calls_private_method/scope_facts.rb +76 -0
  12. data/lib/metz_scan/analyzers/test_calls_private_method/scope_resolution.rb +56 -0
  13. data/lib/metz_scan/analyzers/test_calls_private_method/triage.rb +23 -0
  14. data/lib/metz_scan/analyzers/test_calls_private_method.rb +125 -0
  15. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/readiness_catalog.rb +7 -3
  16. data/lib/metz_scan/cli.rb +7 -13
  17. data/lib/metz_scan/commands/explain.rb +11 -2
  18. data/lib/metz_scan/commands/project_analyzers.rb +3 -2
  19. data/lib/metz_scan/commands/report.rb +6 -5
  20. data/lib/metz_scan/commands/rules.rb +16 -4
  21. data/lib/metz_scan/commands/scan/auto_fix.rb +9 -7
  22. data/lib/metz_scan/commands/scan/config_exclusions.rb +76 -0
  23. data/lib/metz_scan/commands/scan/exclude_entries.rb +59 -0
  24. data/lib/metz_scan/commands/scan/exclude_entry_locator.rb +74 -0
  25. data/lib/metz_scan/commands/scan/exclude_provenance.rb +48 -0
  26. data/lib/metz_scan/commands/scan/inline_directive_locator.rb +53 -0
  27. data/lib/metz_scan/commands/scan/project_analyzer_runner.rb +6 -3
  28. data/lib/metz_scan/commands/scan/project_config_scope.rb +26 -4
  29. data/lib/metz_scan/commands/scan/runner.rb +9 -44
  30. data/lib/metz_scan/commands/scan/suppression_ledger.rb +76 -0
  31. data/lib/metz_scan/commands/scan/suppression_ledger_formatter.rb +48 -0
  32. data/lib/metz_scan/commands/scan/text_renderer.rb +5 -4
  33. data/lib/metz_scan/commands/scan.rb +5 -5
  34. data/lib/metz_scan/project_index/rubydex_backend/method_declarations.rb +33 -12
  35. data/lib/metz_scan/project_index/rubydex_backend/method_visibility.rb +152 -0
  36. data/lib/metz_scan/project_index/rubydex_backend.rb +2 -1
  37. data/lib/metz_scan/project_index.rb +1 -1
  38. data/lib/metz_scan/version.rb +1 -1
  39. data/metz-scan.gemspec +11 -10
  40. metadata +23 -5
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5445ac335e32a5730e08a32f14a5ed924db3c3c8aad1ca7d014082fd86e2b95e
4
- data.tar.gz: 19fc36ed7b22163a5d5c1f19f26021da8de907adb3ff8cecbeb04a2ae5a5f768
3
+ metadata.gz: f8ab2475c975b0058d11664aeddf01dd91b55538137e55ed65af273c4aa62874
4
+ data.tar.gz: d518ad9b70df91a4e74509f03883a911ea5a858cee6bb72a4f17328d7831c291
5
5
  SHA512:
6
- metadata.gz: 5b7eb5cde94f2a0136d278d635827779fa1288890bc5b694e6cbc39643daf9b2d8ba1873e308bb8f048bc60392cdf8adbc7d252f2c8feee5d56f6161143c7dc5
7
- data.tar.gz: 752000a0f48d9dfda4806844613ca562c405e6d0ed5e03032e98deb9b688719a2dc856d3f2c1530461ba16af6032b9add2b6e89c49b196c25df33613afe0db2d
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
- .map(&:name).compact.uniq.sort
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
- .sort_by { |declaration| declaration.name.to_s }
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
- .sort_by { |declaration| declaration.name.to_s }
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)..].any? { |part| IGNORED_SUPPORT_SEGMENTS.include?(part) }
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: 93 low broad-root and " \
59
- "55 medium manual-review findings across abstract hook, cooperative, and " \
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
- 1
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
- handler = subcommand_handler(name)
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(klass_name)
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
- 1
83
+ USAGE_ERROR
90
84
  end
91
85
 
92
86
  def help_text