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,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../project_index"
4
+ require_relative "occurrence"
5
+ require_relative "project_analyzer_triage"
6
+ require_relative "ruby_file_enumerator"
7
+ require_relative "service_soup/setup_workflow_triage"
8
+ require_relative "service_soup/workflow_collector"
9
+
10
+ module MetzScan
11
+ module Analyzers
12
+ # Reports workflow methods that coordinate many service-style calls.
13
+ class ServiceSoup
14
+ include ProjectAnalyzerTriage
15
+
16
+ RULE_ID = "MetzProject/ServiceSoup"
17
+ PROJECT_ANALYZER_STATUS = "validated"
18
+ DEFAULT_OUTPUT_ELIGIBLE = true
19
+ CONFIDENCE = "medium"
20
+ TRIAGE_SEVERITY = "design pressure"
21
+ TRIAGE_SUMMARY = "Validated workflow signal; review methods that coordinate several distinct services."
22
+ WHY = "Service-object soup scatters one workflow across many procedural steps " \
23
+ "and makes orchestration harder to change."
24
+ SUGGESTED_NEXT_MOVES = [
25
+ "Introduce a workflow object that owns the multi-step process.",
26
+ "Keep the controller or job action at one high-level command when possible."
27
+ ].freeze
28
+ Finding = Struct.new(:source, :rule_id, :message, :workflow, :services, :occurrences,
29
+ :project_analyzer_status, :confidence, :triage_severity, :triage_summary,
30
+ :project_analyzer_metadata, :why_it_matters, :suggested_next_moves,
31
+ keyword_init: true) do
32
+ def report_occurrences
33
+ occurrences.map { |occurrence| Occurrence.from(occurrence, context: workflow) }
34
+ end
35
+ end
36
+
37
+ def initialize(paths: nil, index: nil, minimum_services: 3)
38
+ @paths = Array(paths)
39
+ @index = index
40
+ @minimum_services = minimum_services
41
+ end
42
+
43
+ def call
44
+ workflows.filter_map { |workflow| finding_for(workflow) }
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :paths, :index, :minimum_services
50
+
51
+ def workflows
52
+ ruby_files.flat_map { |path| WorkflowCollector.new(path).call }
53
+ end
54
+
55
+ def ruby_files
56
+ RubyFileEnumerator.new(paths: paths, index: index).call
57
+ end
58
+
59
+ def finding_for(workflow)
60
+ services = service_names(workflow)
61
+ return if services.size < minimum_services
62
+
63
+ Finding.new(finding_attributes(workflow, services))
64
+ end
65
+
66
+ def finding_attributes(workflow, services)
67
+ workflow_attributes(workflow, services).merge(
68
+ triage_attributes_for(workflow),
69
+ project_analyzer_metadata: project_analyzer_metadata_for(workflow),
70
+ why_it_matters: why_for(workflow), suggested_next_moves: suggested_next_moves_for(workflow)
71
+ )
72
+ end
73
+
74
+ def triage_attributes_for(workflow)
75
+ return setup_triage_attributes if setup_orchestration?(workflow)
76
+
77
+ project_analyzer_triage_attributes
78
+ end
79
+
80
+ def setup_triage_attributes
81
+ SetupWorkflowTriage.attributes(PROJECT_ANALYZER_STATUS)
82
+ end
83
+
84
+ def why_for(workflow)
85
+ setup_orchestration?(workflow) ? SetupWorkflowTriage::WHY : WHY
86
+ end
87
+
88
+ def suggested_next_moves_for(workflow)
89
+ setup_orchestration?(workflow) ? SetupWorkflowTriage::SUGGESTED_NEXT_MOVES : SUGGESTED_NEXT_MOVES
90
+ end
91
+
92
+ def workflow_attributes(workflow, services)
93
+ { source: source_name, rule_id: RULE_ID, message: message_for(workflow, services),
94
+ workflow: workflow.name, services: services, occurrences: workflow.service_calls }
95
+ end
96
+
97
+ def service_names(workflow)
98
+ workflow.service_calls.map(&:service_name).uniq.sort
99
+ end
100
+
101
+ def setup_orchestration?(workflow)
102
+ SetupWorkflowTriage.setup?(workflow)
103
+ end
104
+
105
+ def message_for(workflow, services)
106
+ "#{workflow.name} coordinates #{services.size} distinct services; " \
107
+ "consider a workflow object that owns the process."
108
+ end
109
+
110
+ def project_analyzer_metadata_for(workflow)
111
+ { "workflow" => workflow_metadata(workflow),
112
+ "services" => workflow.service_calls.map { |service_call| service_call_metadata(service_call) } }
113
+ end
114
+
115
+ def workflow_metadata(workflow)
116
+ { "context" => workflow.name, "enclosing" => workflow.enclosing_name,
117
+ "method" => workflow.method_name, "line" => workflow.line,
118
+ "expression" => workflow.expression }.compact
119
+ end
120
+
121
+ def service_call_metadata(service_call)
122
+ { "service" => service_call.service_name, "path" => service_call.path,
123
+ "line" => service_call.line, "expression" => service_call.expression,
124
+ "style" => service_call.style.to_s }.compact
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ class SubclassOverridePressure
6
+ module FamilyBuilder
7
+ private
8
+
9
+ def base_candidates
10
+ return configured_base_candidates unless base_names.empty?
11
+
12
+ auto_discovered_base_candidates
13
+ end
14
+
15
+ def configured_base_candidates
16
+ base_names.filter_map { |name| declarations_by_name[name] }
17
+ end
18
+
19
+ def auto_discovered_base_candidates
20
+ index.declarations.select { |declaration| auto_discovered_base_candidate?(declaration) }
21
+ end
22
+
23
+ def auto_discovered_base_candidate?(declaration)
24
+ declaration.name && declaration.path && class_candidate?(declaration) &&
25
+ !ignored_declaration_name?(declaration.name)
26
+ end
27
+
28
+ def class_candidate?(declaration)
29
+ !declaration.respond_to?(:kind) || declaration.kind.nil? || declaration.kind == :class
30
+ end
31
+
32
+ def findings_for(base)
33
+ descendants = sorted_descendants(base.name)
34
+ return [] if descendants.size < minimum_overriding_descendants
35
+
36
+ base_methods(base).filter_map { |base_method| finding_for(base, base_method, descendants) }
37
+ end
38
+
39
+ def sorted_descendants(base_name)
40
+ index.descendants_of(base_name).reject { |name| ignored_declaration_name?(name) }.sort
41
+ end
42
+
43
+ def base_methods(base)
44
+ methods = methods_by_owner.fetch(base.name, [])
45
+ methods.uniq { |method| method_identity_for(method) }
46
+ .sort_by { |method| method_identity_for(method) }
47
+ end
48
+
49
+ def finding_for(base, base_method, descendants)
50
+ overrides = overrides_for(method_identity_for(base_method), descendants)
51
+ return if overrides.size < minimum_overriding_descendants
52
+
53
+ Finding.new(finding_attributes(override_family(base, base_method, descendants, overrides)))
54
+ end
55
+
56
+ def overrides_for(method_identity, descendants)
57
+ descendants.filter_map { |descendant| method_declaration_for(descendant, method_identity) }
58
+ .sort_by { |declaration| declaration.owner_name.to_s }
59
+ end
60
+
61
+ def method_declaration_for(owner_name, method_identity)
62
+ methods_by_owner.fetch(owner_name, []).find do |declaration|
63
+ method_identity_for(declaration) == method_identity
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../occurrence"
4
+
5
+ module MetzScan
6
+ module Analyzers
7
+ class SubclassOverridePressure
8
+ Finding = Struct.new(:source, :rule_id, :message, :base_name, :method_name, :method_identity,
9
+ :receiver_kind, :descendant_count, :overriding_descendants, :occurrences,
10
+ :project_analyzer_status, :confidence, :triage_severity, :triage_summary,
11
+ :project_analyzer_metadata,
12
+ :why_it_matters, :suggested_next_moves, keyword_init: true) do
13
+ def report_occurrences
14
+ occurrence = occurrences.first
15
+ [Occurrence.from(occurrence, context: occurrence_context(occurrence))].compact
16
+ end
17
+
18
+ def occurrence_context(occurrence)
19
+ "#{occurrence&.owner_name}#{method_separator(occurrence)}#{method_name}"
20
+ end
21
+
22
+ def method_separator(occurrence)
23
+ return "#" unless occurrence.respond_to?(:receiver_kind)
24
+
25
+ occurrence.receiver_kind == "singleton" ? "." : "#"
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ class SubclassOverridePressure
6
+ module Metadata
7
+ def project_analyzer_metadata_for(family)
8
+ core_project_analyzer_metadata(family)
9
+ .merge("overriding_descendants" => family.overrides.map(&:owner_name),
10
+ "override_locations" => override_locations(family))
11
+ .compact
12
+ end
13
+
14
+ def core_project_analyzer_metadata(family)
15
+ category_metadata(subclass_override_category(family)).merge(family_metadata(family))
16
+ end
17
+
18
+ def category_metadata(category)
19
+ { "project_analyzer_category" => category, "subclass_override_category" => category }
20
+ end
21
+
22
+ def family_metadata(family)
23
+ family_identity_metadata(family)
24
+ .merge(body_fact_metadata(family))
25
+ .merge(count_metadata(family))
26
+ end
27
+
28
+ def family_identity_metadata(family)
29
+ { "base_name" => family.base.name, "method_name" => family.method_name,
30
+ "method_identity" => family.method_identity, "receiver_kind" => family.receiver_kind,
31
+ "root_kind" => family.root_kind }
32
+ end
33
+
34
+ def body_fact_metadata(family)
35
+ { "base_method_body_kind" => family.base_method_body_kind,
36
+ "overrides_calling_super_count" => family.overrides_calling_super_count }
37
+ end
38
+
39
+ def count_metadata(family)
40
+ { "descendant_count" => family.descendants.size, "override_count" => family.overrides.size }
41
+ end
42
+
43
+ def override_locations(family)
44
+ family.overrides.map { |declaration| override_location(family, declaration) }
45
+ end
46
+
47
+ def override_location(family, declaration)
48
+ override_identity_location(declaration)
49
+ .merge("calls_super" => family.override_body_facts.fetch(declaration.owner_name).calls_super)
50
+ .compact
51
+ end
52
+
53
+ def override_identity_location(declaration)
54
+ { "owner_name" => declaration.owner_name, "path" => declaration.path, "line" => declaration.line,
55
+ "receiver_kind" => receiver_kind_for(declaration),
56
+ "method_identity" => method_identity_for(declaration) }
57
+ end
58
+
59
+ def subclass_override_category(family)
60
+ return "broad_root_override" if family.root_kind
61
+ return "abstract_hook_override" if abstract_hook_body?(family.base_method_body_kind)
62
+ return "cooperative_override" if family.overrides_calling_super_count.positive?
63
+ return "replacement_override" unless family.base_method_body_kind == "unknown"
64
+
65
+ "subclass_override"
66
+ end
67
+
68
+ def abstract_hook_body?(body_kind)
69
+ %w[abstract_raise empty default_value].include?(body_kind)
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ module MetzScan
6
+ module Analyzers
7
+ class SubclassOverridePressure
8
+ class MethodBodyFacts
9
+ Facts = Struct.new(:body_kind, :calls_super, keyword_init: true)
10
+
11
+ def initialize
12
+ @processed_sources = {}
13
+ end
14
+
15
+ def for(declaration)
16
+ Facts.new(body_kind: body_kind_for(declaration), calls_super: calls_super?(declaration))
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :processed_sources
22
+
23
+ def body_kind_for(declaration)
24
+ body_kind_for_node(method_node_for(declaration))
25
+ end
26
+
27
+ def body_kind_for_node(node)
28
+ return "unknown" unless node
29
+
30
+ body = body_for(node)
31
+ return "empty" unless body
32
+
33
+ body_kind_for_body(body)
34
+ end
35
+
36
+ def body_kind_for_body(body)
37
+ return "abstract_raise" if abstract_raise?(body)
38
+ return "default_value" if default_value?(body)
39
+
40
+ "concrete"
41
+ end
42
+
43
+ def calls_super?(declaration)
44
+ body = body_for(method_node_for(declaration))
45
+ body && each_node(body).any? { |node| %i[super zsuper].include?(node.type) }
46
+ end
47
+
48
+ def method_node_for(declaration)
49
+ return unless declaration&.path && declaration.line
50
+
51
+ ast = processed_source_for(declaration.path)&.ast
52
+ each_node(ast).find { |node| method_node_at_line?(node, declaration.line) }
53
+ end
54
+
55
+ def processed_source_for(path)
56
+ return processed_sources[path] if processed_sources.key?(path)
57
+
58
+ processed_sources[path] = build_processed_source(path)
59
+ end
60
+
61
+ def build_processed_source(path)
62
+ RuboCop::ProcessedSource.new(File.read(path), RUBY_VERSION.to_f) if File.file?(path)
63
+ rescue Parser::SyntaxError
64
+ nil
65
+ end
66
+
67
+ def method_node_at_line?(node, line)
68
+ %i[def defs].include?(node.type) && node.loc.expression.line == line
69
+ end
70
+
71
+ def body_for(node)
72
+ return unless node
73
+
74
+ node.body
75
+ end
76
+
77
+ def abstract_raise?(body)
78
+ each_node(body).any? { |node| raise_not_implemented?(node) }
79
+ end
80
+
81
+ def raise_not_implemented?(node)
82
+ node.type == :send && node.method_name == :raise &&
83
+ node.source.match?(/NotImplemented|must implement|override/i)
84
+ end
85
+
86
+ def default_value?(body)
87
+ %i[nil true false str sym array hash].include?(body.type)
88
+ end
89
+
90
+ def each_node(root, &block)
91
+ return enum_for(:each_node, root) unless block
92
+ return unless root
93
+
94
+ block.call(root)
95
+ root.children.grep(RuboCop::AST::Node).each { |child| each_node(child, &block) }
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ class SubclassOverridePressure
6
+ module Triage
7
+ CATEGORY_TRIAGE = {
8
+ "abstract_hook_override" => {
9
+ triage_summary: "Candidate abstract hook override signal; review whether subclasses implement an " \
10
+ "implicit hook protocol.",
11
+ why: "Abstract hooks can make inheritance an implicit protocol whose required behavior is only " \
12
+ "visible after reading every subclass.",
13
+ suggested_next_moves: [
14
+ "Name the hook contract in the base class or an extracted collaborator.",
15
+ "Keep deliberate extension points when each subclass implements the same stable role."
16
+ ]
17
+ },
18
+ "cooperative_override" => {
19
+ triage_summary: "Candidate cooperative override signal; review whether super-based extension points " \
20
+ "are explicit.",
21
+ why: "Cooperative overrides rely on template-method ordering, so base changes can break subclasses " \
22
+ "that extend behavior with super.",
23
+ suggested_next_moves: [
24
+ "Keep cooperative hooks small and document the call order expected around super.",
25
+ "Extract the variable step when subclasses need independent behavior."
26
+ ]
27
+ },
28
+ "replacement_override" => {
29
+ triage_summary: "Candidate replacement override signal; review subclasses that replace concrete " \
30
+ "base behavior.",
31
+ why: "Replacement overrides can weaken substitution when callers expect the concrete base behavior " \
32
+ "but subclasses silently replace it.",
33
+ suggested_next_moves: [
34
+ "Prefer composition when subclasses replace the same concrete behavior for different reasons.",
35
+ "Push shared behavior into a collaborator when only the selection logic varies."
36
+ ]
37
+ }
38
+ }.freeze
39
+ MESSAGE_METHODS = {
40
+ "abstract_hook_override" => :abstract_hook_message_for,
41
+ "cooperative_override" => :cooperative_override_message_for,
42
+ "replacement_override" => :replacement_override_message_for
43
+ }.freeze
44
+ private_constant :CATEGORY_TRIAGE, :MESSAGE_METHODS
45
+
46
+ def triage_attributes_for(family)
47
+ return broad_root_triage_attributes if family.root_kind
48
+
49
+ project_analyzer_triage_attributes.merge(
50
+ triage_summary: triage_profile_for(family).fetch(:triage_summary)
51
+ )
52
+ end
53
+
54
+ def project_analyzer_context_attributes(family)
55
+ profile = triage_profile_for(family)
56
+
57
+ { project_analyzer_metadata: project_analyzer_metadata_for(family),
58
+ why_it_matters: profile.fetch(:why),
59
+ suggested_next_moves: profile.fetch(:suggested_next_moves) }
60
+ end
61
+
62
+ def message_for(family)
63
+ public_send(message_method_for(family), family)
64
+ end
65
+
66
+ def broad_root_triage_attributes
67
+ { project_analyzer_status: PROJECT_ANALYZER_STATUS, confidence: BROAD_ROOT_CONFIDENCE,
68
+ triage_severity: BROAD_ROOT_TRIAGE_SEVERITY, triage_summary: BROAD_ROOT_TRIAGE_SUMMARY }
69
+ end
70
+
71
+ def triage_profile_for(family)
72
+ CATEGORY_TRIAGE.fetch(subclass_override_category(family)) { generic_triage_profile }
73
+ end
74
+
75
+ def generic_triage_profile
76
+ { triage_summary: TRIAGE_SUMMARY, why: WHY, suggested_next_moves: SUGGESTED_NEXT_MOVES }
77
+ end
78
+
79
+ def message_method_for(family)
80
+ MESSAGE_METHODS.fetch(subclass_override_category(family), :generic_override_message_for)
81
+ end
82
+
83
+ def abstract_hook_message_for(family)
84
+ "#{family.base.name} descendants implement #{family.method_name} as an abstract hook in " \
85
+ "#{family.overrides.size} subclasses; consider naming the hook protocol."
86
+ end
87
+
88
+ def cooperative_override_message_for(family)
89
+ "#{family.base.name} descendants extend #{family.method_name} with super in " \
90
+ "#{family.overrides_calling_super_count} of #{family.overrides.size} overrides; " \
91
+ "consider making the extension contract explicit."
92
+ end
93
+
94
+ def replacement_override_message_for(family)
95
+ "#{family.base.name} descendants replace concrete #{family.method_name} behavior in " \
96
+ "#{family.overrides.size} subclasses; consider whether composition would make the variation explicit."
97
+ end
98
+
99
+ def generic_override_message_for(family)
100
+ "#{family.base.name} descendants override #{family.method_name} in #{family.overrides.size} subclasses; " \
101
+ "consider whether the hook protocol should be explicit."
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../project_index"
4
+ require_relative "inheritance_descendants/root_kind"
5
+ require_relative "project_analyzer_triage"
6
+ require_relative "subclass_override_pressure/family_builder"
7
+ require_relative "subclass_override_pressure/finding"
8
+ require_relative "subclass_override_pressure/metadata"
9
+ require_relative "subclass_override_pressure/method_body_facts"
10
+ require_relative "subclass_override_pressure/triage"
11
+
12
+ module MetzScan
13
+ module Analyzers
14
+ # Reports base classes whose descendants repeatedly override the same method.
15
+ class SubclassOverridePressure
16
+ include ProjectAnalyzerTriage
17
+ include FamilyBuilder
18
+ include Metadata
19
+ include Triage
20
+
21
+ RULE_ID = "MetzProject/SubclassOverridePressure"
22
+ PROJECT_ANALYZER_STATUS = "candidate"
23
+ CONFIDENCE = "medium"
24
+ TRIAGE_SEVERITY = "manual review"
25
+ TRIAGE_SUMMARY = "Candidate subclass override signal; review whether shared hooks hide coupling."
26
+ BROAD_ROOT_CONFIDENCE = "low"
27
+ BROAD_ROOT_TRIAGE_SEVERITY = "broad base"
28
+ BROAD_ROOT_TRIAGE_SUMMARY = "Broad inheritance base with repeated overrides; review only when hook " \
29
+ "changes frequently or subclasses diverge."
30
+ WHY = "Repeated subclass overrides can turn inheritance into an implicit protocol that is hard to change."
31
+ SUGGESTED_NEXT_MOVES = [
32
+ "Review whether the base class is defining a hook protocol that should be explicit.",
33
+ "Prefer composition when subclasses override the same behavior for unrelated reasons."
34
+ ].freeze
35
+ MINIMUM_OVERRIDING_DESCENDANTS = 6
36
+ IGNORED_DECLARATION_NAMES = %w[BasicObject Class Kernel Module Object].freeze
37
+ SYNTHETIC_DECLARATION_MARKER = "::<"
38
+ private_constant :IGNORED_DECLARATION_NAMES, :SYNTHETIC_DECLARATION_MARKER
39
+
40
+ OverrideFamily = Struct.new(:base, :base_method, :method_name, :method_identity, :receiver_kind,
41
+ :descendants, :overrides, :root_kind,
42
+ :base_method_body_kind, :override_body_facts, keyword_init: true) do
43
+ def overrides_calling_super_count
44
+ override_body_facts.values.count(&:calls_super)
45
+ end
46
+ end
47
+
48
+ def initialize(paths: nil, index: nil, base_names: nil,
49
+ minimum_overriding_descendants: MINIMUM_OVERRIDING_DESCENDANTS)
50
+ @paths = Array(paths)
51
+ @index = index || ProjectIndex.build(@paths)
52
+ @base_names = Array(base_names).compact
53
+ @minimum_overriding_descendants = minimum_overriding_descendants
54
+ end
55
+
56
+ def call
57
+ return [] unless index.available?
58
+
59
+ base_candidates.flat_map { |base| findings_for(base) }
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :index, :base_names, :minimum_overriding_descendants
65
+
66
+ def override_family(base, base_method, descendants, overrides)
67
+ OverrideFamily.new(override_family_attributes(base, base_method, descendants, overrides))
68
+ end
69
+
70
+ def override_family_attributes(base, base_method, descendants, overrides)
71
+ override_identity_attributes(base, base_method)
72
+ .merge(descendants: descendants, overrides: overrides,
73
+ base_method_body_kind: body_facts_for(base_method).body_kind,
74
+ override_body_facts: override_body_facts_for(overrides))
75
+ end
76
+
77
+ def override_identity_attributes(base, base_method)
78
+ { base: base, base_method: base_method, method_name: base_method.method_name,
79
+ method_identity: method_identity_for(base_method), receiver_kind: receiver_kind_for(base_method),
80
+ root_kind: InheritanceDescendants::RootKind.for(base.name) }
81
+ end
82
+
83
+ def finding_attributes(family)
84
+ core_finding_attributes(family)
85
+ .merge(triage_attributes_for(family))
86
+ .merge(project_analyzer_context_attributes(family))
87
+ end
88
+
89
+ def core_finding_attributes(family)
90
+ { source: source_name, rule_id: RULE_ID, message: message_for(family),
91
+ base_name: family.base.name, method_name: family.method_name, method_identity: family.method_identity,
92
+ receiver_kind: family.receiver_kind, descendant_count: family.descendants.size,
93
+ overriding_descendants: family.overrides.map(&:owner_name), occurrences: family.overrides }
94
+ end
95
+
96
+ def declarations_by_name
97
+ @declarations_by_name ||= index.declarations.to_h { |declaration| [declaration.name, declaration] }
98
+ end
99
+
100
+ def methods_by_owner
101
+ @methods_by_owner ||= index.method_declarations.group_by(&:owner_name)
102
+ end
103
+
104
+ def override_body_facts_for(overrides)
105
+ overrides.to_h { |override| [override.owner_name, body_facts_for(override)] }
106
+ end
107
+
108
+ def body_facts_for(declaration)
109
+ body_facts.for(declaration)
110
+ end
111
+
112
+ def body_facts
113
+ @body_facts ||= MethodBodyFacts.new
114
+ end
115
+
116
+ def ignored_declaration_name?(name)
117
+ IGNORED_DECLARATION_NAMES.include?(name) || name.include?(SYNTHETIC_DECLARATION_MARKER)
118
+ end
119
+
120
+ def method_identity_for(declaration)
121
+ return declaration.method_identity if declaration.respond_to?(:method_identity) && declaration.method_identity
122
+
123
+ "#{receiver_kind_for(declaration)}:#{declaration.method_name}"
124
+ end
125
+
126
+ def receiver_kind_for(declaration)
127
+ return declaration.receiver_kind if declaration.respond_to?(:receiver_kind) && declaration.receiver_kind
128
+
129
+ declaration.name.to_s.include?(".") ? "singleton" : "instance"
130
+ end
131
+ end
132
+ end
133
+ end