rubocop-metz 0.5.2
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.
Potentially problematic release.
This version of rubocop-metz might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/lib/metz_scan/analyzers/contextual_node_walker.rb +82 -0
- data/lib/metz_scan/analyzers/cross_package_reference_metadata.rb +36 -0
- data/lib/metz_scan/analyzers/cross_package_reference_set.rb +33 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_collector.rb +54 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_reference.rb +37 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/current_attribute_collector.rb +73 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/reference_set.rb +37 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/thread_current_collector.rb +76 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/triage.rb +109 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure.rb +139 -0
- data/lib/metz_scan/analyzers/inheritance_descendants/finding.rb +18 -0
- data/lib/metz_scan/analyzers/inheritance_descendants/metadata.rb +23 -0
- data/lib/metz_scan/analyzers/inheritance_descendants/root_kind.rb +58 -0
- data/lib/metz_scan/analyzers/inheritance_descendants/triage.rb +30 -0
- data/lib/metz_scan/analyzers/inheritance_descendants.rb +137 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/finding.rb +20 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/metadata.rb +40 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/namespace.rb +41 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_collector.rb +50 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_set.rb +12 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage/exception_declaration.rb +117 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage.rb +69 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure.rb +139 -0
- data/lib/metz_scan/analyzers/occurrence.rb +28 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/finding.rb +20 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/metadata.rb +27 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/package_map.rb +11 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/reference_collector.rb +38 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/reference_set.rb +12 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/shared_dependency_triage.rb +124 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure.rb +139 -0
- data/lib/metz_scan/analyzers/package_map.rb +81 -0
- data/lib/metz_scan/analyzers/project_analyzer_triage.rb +20 -0
- data/lib/metz_scan/analyzers/repeated_branching/branch_site_collector.rb +138 -0
- data/lib/metz_scan/analyzers/repeated_branching/branch_value.rb +27 -0
- data/lib/metz_scan/analyzers/repeated_branching/contextual_node_walker.rb +11 -0
- data/lib/metz_scan/analyzers/repeated_branching/decision_subject.rb +46 -0
- data/lib/metz_scan/analyzers/repeated_branching/predicate_chain.rb +58 -0
- data/lib/metz_scan/analyzers/repeated_branching/triage.rb +28 -0
- data/lib/metz_scan/analyzers/repeated_branching.rb +135 -0
- data/lib/metz_scan/analyzers/repeated_query_criteria/triage.rb +103 -0
- data/lib/metz_scan/analyzers/repeated_query_criteria.rb +285 -0
- data/lib/metz_scan/analyzers/ruby_file_enumerator.rb +37 -0
- data/lib/metz_scan/analyzers/service_soup/service_call_pattern.rb +53 -0
- data/lib/metz_scan/analyzers/service_soup/setup_workflow_triage.rb +48 -0
- data/lib/metz_scan/analyzers/service_soup/workflow_collector.rb +130 -0
- data/lib/metz_scan/analyzers/service_soup.rb +128 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure/family_builder.rb +69 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure/finding.rb +30 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure/metadata.rb +74 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure/method_body_facts.rb +100 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure/triage.rb +106 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure.rb +133 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/artifact_writer.rb +51 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/baseline_delta.rb +231 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/collaborators.rb +76 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/markdown_renderer.rb +353 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/readiness_catalog.rb +123 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/summary.rb +333 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/target_set.rb +139 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner.rb +64 -0
- data/lib/metz_scan/calibration/rubydex_drift_formatter.rb +91 -0
- data/lib/metz_scan/cli.rb +120 -0
- data/lib/metz_scan/commands/explain.rb +128 -0
- data/lib/metz_scan/commands/project_analyzers.rb +105 -0
- data/lib/metz_scan/commands/report.rb +115 -0
- data/lib/metz_scan/commands/rules.rb +92 -0
- data/lib/metz_scan/commands/scan/auto_fix.rb +138 -0
- data/lib/metz_scan/commands/scan/compliance_scorecard.rb +124 -0
- data/lib/metz_scan/commands/scan/github_annotations_renderer.rb +55 -0
- data/lib/metz_scan/commands/scan/offense_extractor.rb +42 -0
- data/lib/metz_scan/commands/scan/project_analyzer_breakdown.rb +55 -0
- data/lib/metz_scan/commands/scan/project_analyzer_metadata.rb +78 -0
- data/lib/metz_scan/commands/scan/project_analyzer_offenses.rb +85 -0
- data/lib/metz_scan/commands/scan/project_analyzer_runner.rb +148 -0
- data/lib/metz_scan/commands/scan/project_analyzer_summary_aggregate_formatter.rb +74 -0
- data/lib/metz_scan/commands/scan/project_analyzer_summary_breakdown_formatter.rb +46 -0
- data/lib/metz_scan/commands/scan/project_analyzer_triage_formatter.rb +40 -0
- data/lib/metz_scan/commands/scan/project_analyzer_triage_priority.rb +38 -0
- data/lib/metz_scan/commands/scan/project_config_scope.rb +231 -0
- data/lib/metz_scan/commands/scan/runner.rb +199 -0
- data/lib/metz_scan/commands/scan/sarif_renderer.rb +103 -0
- data/lib/metz_scan/commands/scan/sarif_rule_descriptors.rb +82 -0
- data/lib/metz_scan/commands/scan/sarif_severity.rb +27 -0
- data/lib/metz_scan/commands/scan/target_ruby_version.rb +56 -0
- data/lib/metz_scan/commands/scan/text_renderer.rb +145 -0
- data/lib/metz_scan/commands/scan.rb +201 -0
- data/lib/metz_scan/project_index/null_backend.rb +35 -0
- data/lib/metz_scan/project_index/rubydex_backend/file_discovery.rb +33 -0
- data/lib/metz_scan/project_index/rubydex_backend/location_formatting.rb +36 -0
- data/lib/metz_scan/project_index/rubydex_backend/method_declarations.rb +118 -0
- data/lib/metz_scan/project_index/rubydex_backend.rb +133 -0
- data/lib/metz_scan/project_index.rb +109 -0
- data/lib/metz_scan/version.rb +5 -0
- data/lib/metz_scan.rb +5 -0
- metadata +185 -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
|