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,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "implicit_context_pressure/ambient_context_collector"
|
|
4
|
+
require_relative "implicit_context_pressure/reference_set"
|
|
5
|
+
require_relative "implicit_context_pressure/triage"
|
|
6
|
+
require_relative "occurrence"
|
|
7
|
+
require_relative "package_map"
|
|
8
|
+
require_relative "project_analyzer_triage"
|
|
9
|
+
require_relative "ruby_file_enumerator"
|
|
10
|
+
|
|
11
|
+
module MetzScan
|
|
12
|
+
module Analyzers
|
|
13
|
+
# Reports repeated ambient CurrentAttributes-style context access.
|
|
14
|
+
class ImplicitContextPressure
|
|
15
|
+
include ProjectAnalyzerTriage
|
|
16
|
+
include Triage
|
|
17
|
+
|
|
18
|
+
RULE_ID = "MetzProject/ImplicitContextPressure"
|
|
19
|
+
PROJECT_ANALYZER_STATUS = "candidate"
|
|
20
|
+
CONFIDENCE = "medium"
|
|
21
|
+
TRIAGE_SEVERITY = "manual review"
|
|
22
|
+
TRIAGE_SUMMARY = "Candidate ambient context signal; review whether dependencies should be explicit."
|
|
23
|
+
WHY = "Ambient context hides dependencies from method signatures and makes workflows harder to reason about."
|
|
24
|
+
SUGGESTED_NEXT_MOVES = [
|
|
25
|
+
"Pass the context value explicitly into collaborators that use it.",
|
|
26
|
+
"Keep request-scoped globals at application boundaries when possible."
|
|
27
|
+
].freeze
|
|
28
|
+
MINIMUM_FILES = 3
|
|
29
|
+
MINIMUM_PACKAGES = 2
|
|
30
|
+
Finding = Struct.new(:source, :rule_id, :message, :ambient_context, :referring_files,
|
|
31
|
+
:referring_packages, :occurrences, :project_analyzer_status, :confidence,
|
|
32
|
+
:triage_severity, :triage_summary, :project_analyzer_metadata,
|
|
33
|
+
:why_it_matters, :suggested_next_moves, keyword_init: true) do
|
|
34
|
+
def report_occurrences
|
|
35
|
+
occurrence = occurrences.first
|
|
36
|
+
[Occurrence.from(occurrence, context: occurrence&.context)].compact
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize(paths: nil, index: nil, minimum_files: MINIMUM_FILES, minimum_packages: MINIMUM_PACKAGES)
|
|
41
|
+
@paths = Array(paths)
|
|
42
|
+
@index = index
|
|
43
|
+
@minimum_files = minimum_files
|
|
44
|
+
@minimum_packages = minimum_packages
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def call
|
|
48
|
+
ambient_references.group_by(&:ambient_context).filter_map do |ambient_context, references|
|
|
49
|
+
finding_for(ambient_context, references)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
attr_reader :paths, :index, :minimum_files, :minimum_packages
|
|
56
|
+
|
|
57
|
+
def ambient_references
|
|
58
|
+
ruby_files.flat_map { |path| AmbientContextCollector.new(path).call }
|
|
59
|
+
.reject { |reference| PackageMap.ignored_path?(reference.path) }
|
|
60
|
+
.select { |reference| PackageMap.package_for(reference.path) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def ruby_files = RubyFileEnumerator.new(paths: paths, index: index).call
|
|
64
|
+
|
|
65
|
+
def finding_for(ambient_context, references)
|
|
66
|
+
grouped = ReferenceSet.new(references)
|
|
67
|
+
return unless grouped.referring_files.size >= minimum_files
|
|
68
|
+
return unless grouped.referring_packages.size >= minimum_packages
|
|
69
|
+
|
|
70
|
+
Finding.new(finding_attributes(ambient_context, grouped))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def finding_attributes(ambient_context, grouped)
|
|
74
|
+
category = implicit_context_category_for(ambient_context, grouped)
|
|
75
|
+
|
|
76
|
+
finding_core_attributes(ambient_context, grouped, category)
|
|
77
|
+
.merge(project_analyzer_triage_attributes)
|
|
78
|
+
.merge(category_triage_attributes(category))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def finding_core_attributes(ambient_context, grouped, category)
|
|
82
|
+
finding_identity_attributes(ambient_context, grouped)
|
|
83
|
+
.merge(finding_reference_attributes(ambient_context, grouped, category))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def finding_identity_attributes(ambient_context, grouped)
|
|
87
|
+
{ source: source_name, rule_id: RULE_ID, message: message_for(ambient_context, grouped),
|
|
88
|
+
ambient_context: ambient_context }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def finding_reference_attributes(ambient_context, grouped, category)
|
|
92
|
+
{ referring_files: grouped.referring_files, referring_packages: grouped.referring_packages,
|
|
93
|
+
occurrences: grouped.references,
|
|
94
|
+
project_analyzer_metadata: project_analyzer_metadata_for(ambient_context, grouped, category) }
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def message_for(ambient_context, grouped)
|
|
98
|
+
"#{ambient_context} is #{access_phrase(grouped)} from #{grouped.referring_files.size} files across " \
|
|
99
|
+
"#{grouped.referring_packages.size} packages; consider passing context explicitly."
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def project_analyzer_metadata_for(ambient_context, grouped, category)
|
|
103
|
+
implicit_context_metadata(ambient_context, grouped, category).merge(grouped_metadata(grouped))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def implicit_context_metadata(ambient_context, grouped, category)
|
|
107
|
+
{ "project_analyzer_category" => category,
|
|
108
|
+
"implicit_context_category" => category, "ambient_context" => ambient_context,
|
|
109
|
+
"ambient_context_kind" => grouped.context_kind }
|
|
110
|
+
.merge(context_specific_metadata(ambient_context, grouped))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def context_specific_metadata(ambient_context, grouped)
|
|
114
|
+
return thread_current_metadata(grouped) if grouped.context_kind == "thread_current"
|
|
115
|
+
|
|
116
|
+
{ "current_receiver_scope" => current_receiver_scope_for(ambient_context),
|
|
117
|
+
"current_attribute" => current_attribute_for(ambient_context) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def thread_current_metadata(grouped)
|
|
121
|
+
{ "thread_current_key" => grouped.context_key }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def grouped_metadata(grouped)
|
|
125
|
+
{ "access_modes" => grouped.access_modes, "referring_files" => grouped.referring_files,
|
|
126
|
+
"referring_packages" => grouped.referring_packages, "occurrences" => occurrences_metadata(grouped) }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def occurrences_metadata(grouped)
|
|
130
|
+
grouped.references.map { |reference| reference_metadata(reference) }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def reference_metadata(reference)
|
|
134
|
+
{ "path" => reference.path, "line" => reference.line, "context" => reference.context,
|
|
135
|
+
"expression" => reference.expression, "access_mode" => reference.access_mode }.compact
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class InheritanceDescendants
|
|
6
|
+
Finding = Struct.new(:source, :rule_id, :message, :base_name, :descendants, :locations, :primary_location,
|
|
7
|
+
:why_it_matters, :project_analyzer_status, :confidence, :triage_severity, :triage_summary,
|
|
8
|
+
:project_analyzer_metadata, :suggested_next_moves, keyword_init: true) do
|
|
9
|
+
def occurrences = [primary_location].compact
|
|
10
|
+
|
|
11
|
+
def report_occurrences
|
|
12
|
+
occurrences.map { |occurrence| Occurrence.from(occurrence, context: base_name) }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
Location = Struct.new(:name, :path, keyword_init: true)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class InheritanceDescendants
|
|
6
|
+
module Metadata
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def for(base_name, descendants, descendant_locations, context)
|
|
10
|
+
{ "base_name" => base_name, "descendants" => descendants,
|
|
11
|
+
"descendant_count" => descendants.size,
|
|
12
|
+
"descendant_locations" => location_metadata(descendant_locations),
|
|
13
|
+
"project_analyzer_category" => context.fetch(:root_kind),
|
|
14
|
+
"root_kind" => context.fetch(:root_kind), "source" => context.fetch(:source) }.compact
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def location_metadata(locations)
|
|
18
|
+
locations.map { |location| { "name" => location.name, "path" => location.path } }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class InheritanceDescendants
|
|
6
|
+
module RootKind
|
|
7
|
+
FRAMEWORK_ROOT_NAMESPACES = %w[
|
|
8
|
+
ActionCable
|
|
9
|
+
ActionController
|
|
10
|
+
ActionMailer
|
|
11
|
+
ActionView
|
|
12
|
+
ActiveJob
|
|
13
|
+
ActiveModel
|
|
14
|
+
ActiveRecord
|
|
15
|
+
Rails
|
|
16
|
+
ViteRails
|
|
17
|
+
].freeze
|
|
18
|
+
RAILS_APPLICATION_BASES = %w[ApplicationController ApplicationJob ApplicationMailer ApplicationRecord].freeze
|
|
19
|
+
RULES = [
|
|
20
|
+
["framework root", lambda { |name|
|
|
21
|
+
FRAMEWORK_ROOT_NAMESPACES.any? { |namespace| name.start_with?("#{namespace}::") }
|
|
22
|
+
}],
|
|
23
|
+
["rails application base", ->(name) { RAILS_APPLICATION_BASES.include?(name) }],
|
|
24
|
+
["application job base", lambda { |name|
|
|
25
|
+
name == "Jobs::Base" || name == "Jobs::Scheduled" || name.start_with?("Jobs::") ||
|
|
26
|
+
name.end_with?("Job") || name.end_with?("::BaseJob")
|
|
27
|
+
}],
|
|
28
|
+
["application service base", lambda { |name|
|
|
29
|
+
name == "BaseService" || name.start_with?("Service::") || name.end_with?("::BaseService") ||
|
|
30
|
+
name.match?(/Base.*Service/) || name.match?(/Service.*Base/)
|
|
31
|
+
}],
|
|
32
|
+
["controller base", ->(name) { name.end_with?("Controller") }],
|
|
33
|
+
["serializer base", ->(name) { name.end_with?("Serializer") }],
|
|
34
|
+
["policy base", ->(name) { name.end_with?("Policy") }],
|
|
35
|
+
["worker base", ->(name) { name.end_with?("Worker") }],
|
|
36
|
+
["exception base", lambda { |name|
|
|
37
|
+
name.end_with?("Error") || name.end_with?("Exception") || name.include?("Exception")
|
|
38
|
+
}],
|
|
39
|
+
["cli base", ->(name) { name.end_with?("CLI::Base") }],
|
|
40
|
+
["abstract base", lambda { |name|
|
|
41
|
+
name.include?("::Base::") || base_token?(name.split("::").last)
|
|
42
|
+
}]
|
|
43
|
+
].freeze
|
|
44
|
+
private_constant :FRAMEWORK_ROOT_NAMESPACES, :RAILS_APPLICATION_BASES, :RULES
|
|
45
|
+
|
|
46
|
+
module_function
|
|
47
|
+
|
|
48
|
+
def for(base_name)
|
|
49
|
+
RULES.find { |_label, matcher| matcher.call(base_name) }&.first
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def base_token?(name)
|
|
53
|
+
name == "Base" || name.start_with?("Base") || name.end_with?("Base")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class InheritanceDescendants
|
|
6
|
+
module Triage
|
|
7
|
+
BROAD_ROOT_CONFIDENCE = "low"
|
|
8
|
+
BROAD_ROOT_TRIAGE_SEVERITY = "broad base"
|
|
9
|
+
BROAD_ROOT_TRIAGE_SUMMARY = "Broad inheritance base; review only when shared behavior changes often " \
|
|
10
|
+
"or descendants diverge."
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def attributes_for(root_kind)
|
|
15
|
+
root_kind ? broad_root_attributes : candidate_attributes
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def candidate_attributes
|
|
19
|
+
{ project_analyzer_status: PROJECT_ANALYZER_STATUS, confidence: CONFIDENCE,
|
|
20
|
+
triage_severity: TRIAGE_SEVERITY, triage_summary: TRIAGE_SUMMARY }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def broad_root_attributes
|
|
24
|
+
{ project_analyzer_status: PROJECT_ANALYZER_STATUS, confidence: BROAD_ROOT_CONFIDENCE,
|
|
25
|
+
triage_severity: BROAD_ROOT_TRIAGE_SEVERITY, triage_summary: BROAD_ROOT_TRIAGE_SUMMARY }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../project_index"
|
|
4
|
+
require_relative "inheritance_descendants/finding"
|
|
5
|
+
require_relative "inheritance_descendants/metadata"
|
|
6
|
+
require_relative "inheritance_descendants/root_kind"
|
|
7
|
+
require_relative "inheritance_descendants/triage"
|
|
8
|
+
require_relative "occurrence"
|
|
9
|
+
|
|
10
|
+
module MetzScan
|
|
11
|
+
module Analyzers
|
|
12
|
+
# Reports configured inheritance roots with enough known descendants.
|
|
13
|
+
class InheritanceDescendants
|
|
14
|
+
RULE_ID = "MetzProject/DeepInheritanceTree"
|
|
15
|
+
PROJECT_ANALYZER_STATUS = "validated"
|
|
16
|
+
CONFIDENCE = "medium"
|
|
17
|
+
TRIAGE_SEVERITY = "manual review"
|
|
18
|
+
TRIAGE_SUMMARY = "Validated inheritance signal; review broad base classes and descendant spread in context."
|
|
19
|
+
WHY = "Large inheritance trees hide coupling and make changes expensive."
|
|
20
|
+
SUGGESTED_NEXT_MOVES = [
|
|
21
|
+
"Review whether the base class is carrying multiple responsibilities.",
|
|
22
|
+
"Prefer composition or narrower shared modules when descendants only need part of the base behavior."
|
|
23
|
+
].freeze
|
|
24
|
+
IGNORED_DECLARATION_NAMES = %w[BasicObject Class Kernel Module Object].freeze
|
|
25
|
+
SYNTHETIC_DECLARATION_MARKER = "::<"
|
|
26
|
+
private_constant :IGNORED_DECLARATION_NAMES, :SYNTHETIC_DECLARATION_MARKER
|
|
27
|
+
|
|
28
|
+
def initialize(paths: nil, index: nil, base_names: nil, minimum_descendants: 3)
|
|
29
|
+
@paths = Array(paths)
|
|
30
|
+
@index = index || ProjectIndex.build(@paths)
|
|
31
|
+
@base_names = Array(base_names).compact
|
|
32
|
+
@minimum_descendants = minimum_descendants
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call
|
|
36
|
+
return [] unless index.available?
|
|
37
|
+
|
|
38
|
+
base_candidates.filter_map { |base_name| finding_for(base_name) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
attr_reader :index, :base_names, :minimum_descendants
|
|
44
|
+
|
|
45
|
+
def base_candidates
|
|
46
|
+
return base_names unless base_names.empty?
|
|
47
|
+
|
|
48
|
+
auto_discovered_base_candidates
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def auto_discovered_base_candidates
|
|
52
|
+
index.declarations.select { |declaration| auto_discovered_base_candidate?(declaration) }
|
|
53
|
+
.map(&:name).compact.uniq.sort
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def auto_discovered_base_candidate?(declaration)
|
|
57
|
+
declaration.name && declaration.path && class_candidate?(declaration) &&
|
|
58
|
+
!ignored_declaration_name?(declaration.name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def class_candidate?(declaration)
|
|
62
|
+
!declaration.respond_to?(:kind) || declaration.kind.nil? || declaration.kind == :class
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def finding_for(base_name)
|
|
66
|
+
descendants = sorted_descendants(base_name)
|
|
67
|
+
return if descendants.size < minimum_descendants
|
|
68
|
+
|
|
69
|
+
build_finding(base_name, descendants)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def sorted_descendants(base_name)
|
|
73
|
+
index.descendants_of(base_name).reject { |name| ignored_declaration_name?(name) }.sort
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def build_finding(base_name, descendants)
|
|
77
|
+
descendant_locations = locations_for(descendants)
|
|
78
|
+
Finding.new(finding_attributes(base_name, descendants, descendant_locations))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def finding_attributes(base_name, descendants, descendant_locations)
|
|
82
|
+
root_kind = RootKind.for(base_name)
|
|
83
|
+
core_finding_attributes(base_name, descendants, descendant_locations, root_kind)
|
|
84
|
+
.merge(triage_for(base_name, descendants, descendant_locations, root_kind))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def core_finding_attributes(base_name, descendants, descendant_locations, root_kind)
|
|
88
|
+
{ source: index.backend_name.to_s, rule_id: RULE_ID, message: message_for(base_name, descendants, root_kind),
|
|
89
|
+
base_name: base_name, descendants: descendants, locations: descendant_locations,
|
|
90
|
+
primary_location: primary_location_for(base_name, descendant_locations), why_it_matters: WHY,
|
|
91
|
+
suggested_next_moves: SUGGESTED_NEXT_MOVES }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def triage_for(base_name, descendants, descendant_locations, root_kind)
|
|
95
|
+
Triage.attributes_for(root_kind).merge(
|
|
96
|
+
project_analyzer_metadata: project_analyzer_metadata_for(base_name, descendants, descendant_locations,
|
|
97
|
+
root_kind)
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def message_for(base_name, descendants, root_kind)
|
|
102
|
+
label = root_kind ? " (#{root_kind})" : ""
|
|
103
|
+
"#{base_name}#{label} has #{descendants.size} descendants; " \
|
|
104
|
+
"consider whether shared behavior is becoming too broad."
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def locations_for(descendants)
|
|
108
|
+
descendants.filter_map { |name| location_for(name) }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def primary_location_for(base_name, descendant_locations)
|
|
112
|
+
location_for(base_name) || descendant_locations.first
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def project_analyzer_metadata_for(base_name, descendants, descendant_locations, root_kind)
|
|
116
|
+
Metadata.for(base_name, descendants, descendant_locations, metadata_context(root_kind))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def metadata_context(root_kind)
|
|
120
|
+
{ root_kind: root_kind, source: index.backend_name.to_s }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def location_for(name)
|
|
124
|
+
declaration = declarations_by_name[name]
|
|
125
|
+
Location.new(name: name, path: declaration.path) if declaration&.path
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def declarations_by_name
|
|
129
|
+
@declarations_by_name ||= index.declarations.to_h { |declaration| [declaration.name, declaration] }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def ignored_declaration_name?(name)
|
|
133
|
+
IGNORED_DECLARATION_NAMES.include?(name) || name.include?(SYNTHETIC_DECLARATION_MARKER)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../occurrence"
|
|
4
|
+
|
|
5
|
+
module MetzScan
|
|
6
|
+
module Analyzers
|
|
7
|
+
class NamespaceLeakPressure
|
|
8
|
+
Finding = Struct.new(:source, :rule_id, :message, :declaration_name, :home_namespace,
|
|
9
|
+
:declared_package, :referring_files, :referring_packages, :references,
|
|
10
|
+
:primary_location, :why_it_matters, :project_analyzer_status, :confidence,
|
|
11
|
+
:triage_severity, :triage_summary, :project_analyzer_metadata,
|
|
12
|
+
:suggested_next_moves, keyword_init: true) do
|
|
13
|
+
def report_occurrences
|
|
14
|
+
[Occurrence.from(primary_location, context: declaration_name)].compact
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
Location = Struct.new(:name, :path, keyword_init: true)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../cross_package_reference_metadata"
|
|
4
|
+
|
|
5
|
+
module MetzScan
|
|
6
|
+
module Analyzers
|
|
7
|
+
class NamespaceLeakPressure
|
|
8
|
+
module Metadata
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def for(declaration, reference_set, namespace_leak_category)
|
|
12
|
+
CrossPackageReferenceMetadata.counts(reference_set).merge(
|
|
13
|
+
namespace_metadata(declaration, reference_set, namespace_leak_category)
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def namespace_metadata(declaration, reference_set, namespace_leak_category)
|
|
18
|
+
base_namespace_metadata(declaration)
|
|
19
|
+
.merge(category_metadata(namespace_leak_category))
|
|
20
|
+
.merge(references_metadata(reference_set))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def base_namespace_metadata(declaration)
|
|
24
|
+
{ "declaration" => CrossPackageReferenceMetadata.declaration_metadata(declaration),
|
|
25
|
+
"home_namespace" => Namespace.new(declaration.name).home_name,
|
|
26
|
+
"declared_package" => PackageMap.package_for(declaration.path) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def category_metadata(namespace_leak_category)
|
|
30
|
+
{ "project_analyzer_category" => namespace_leak_category,
|
|
31
|
+
"namespace_leak_category" => namespace_leak_category }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def references_metadata(reference_set)
|
|
35
|
+
{ "references" => CrossPackageReferenceMetadata.references_metadata(reference_set) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class NamespaceLeakPressure
|
|
6
|
+
class Namespace
|
|
7
|
+
def initialize(name)
|
|
8
|
+
@segments = name.to_s.split("::")
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
attr_reader :segments
|
|
12
|
+
|
|
13
|
+
def deep?
|
|
14
|
+
segments.size >= 3
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def home_name
|
|
18
|
+
home_segments.join("::")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def home_path_parts
|
|
22
|
+
home_segments.map { |part| underscore(part) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def home_segments
|
|
28
|
+
segments[0...-1]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def underscore(value)
|
|
32
|
+
value.to_s
|
|
33
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
|
|
34
|
+
.gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
|
|
35
|
+
.tr("-", "_")
|
|
36
|
+
.downcase
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class NamespaceLeakPressure
|
|
6
|
+
class ReferenceCollector
|
|
7
|
+
def initialize(index)
|
|
8
|
+
@index = index
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def for(declaration)
|
|
12
|
+
home_namespace_parts = Namespace.new(declaration.name).home_path_parts
|
|
13
|
+
index.constant_references_to(declaration.name).filter_map do |reference|
|
|
14
|
+
counted_reference_for(reference, declaration.path, home_namespace_parts)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :index
|
|
21
|
+
|
|
22
|
+
def counted_reference_for(reference, declaration_path, home_namespace_parts)
|
|
23
|
+
return unless counted_reference?(reference, declaration_path, home_namespace_parts)
|
|
24
|
+
|
|
25
|
+
package = PackageMap.package_for(reference.path)
|
|
26
|
+
return unless package
|
|
27
|
+
|
|
28
|
+
Reference.new(path: reference.path, line: reference.line, column: reference.column, package: package)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def counted_reference?(reference, declaration_path, home_namespace_parts)
|
|
32
|
+
!same_path?(reference.path, declaration_path) &&
|
|
33
|
+
!PackageMap.ignored_path?(reference.path) &&
|
|
34
|
+
!inside_home_namespace?(reference.path, home_namespace_parts)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def inside_home_namespace?(path, home_namespace_parts)
|
|
38
|
+
PackageMap.parts_after_package(path).first(home_namespace_parts.size) == home_namespace_parts
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def same_path?(left, right)
|
|
42
|
+
File.expand_path(left) == File.expand_path(right)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Reference = Struct.new(:path, :line, :column, :package, keyword_init: true)
|
|
46
|
+
private_constant :Reference
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|