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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/bin/metz-scan +10 -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
- data/metz-scan.gemspec +41 -0
- metadata +160 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../project_index"
|
|
4
|
+
require_relative "package_dependency_pressure/finding"
|
|
5
|
+
require_relative "package_dependency_pressure/metadata"
|
|
6
|
+
require_relative "package_dependency_pressure/package_map"
|
|
7
|
+
require_relative "package_dependency_pressure/reference_collector"
|
|
8
|
+
require_relative "package_dependency_pressure/reference_set"
|
|
9
|
+
require_relative "package_dependency_pressure/shared_dependency_triage"
|
|
10
|
+
require_relative "project_analyzer_triage"
|
|
11
|
+
|
|
12
|
+
module MetzScan
|
|
13
|
+
module Analyzers
|
|
14
|
+
# Reports declarations referenced from several files outside their package.
|
|
15
|
+
class PackageDependencyPressure
|
|
16
|
+
include ProjectAnalyzerTriage
|
|
17
|
+
|
|
18
|
+
RULE_ID = "MetzProject/PackageDependencyPressure"
|
|
19
|
+
PROJECT_ANALYZER_STATUS = "candidate"
|
|
20
|
+
CONFIDENCE = "medium"
|
|
21
|
+
TRIAGE_SEVERITY = "manual review"
|
|
22
|
+
TRIAGE_SUMMARY = "Candidate package-boundary signal; review cross-package reference pressure in context."
|
|
23
|
+
WHY = "Cross-package reference pressure can reveal boundaries that many parts of the system depend on."
|
|
24
|
+
SUGGESTED_NEXT_MOVES = [
|
|
25
|
+
"Review whether callers need the full declaration or a narrower interface.",
|
|
26
|
+
"Look for package-specific workflows that could own repeated dependency edges."
|
|
27
|
+
].freeze
|
|
28
|
+
IGNORED_DECLARATION_NAMES = %w[BasicObject Class Kernel Module Object].freeze
|
|
29
|
+
SYNTHETIC_DECLARATION_MARKER = "::<"
|
|
30
|
+
SUPPORTED_KINDS = [nil, :class, :module].freeze
|
|
31
|
+
private_constant :IGNORED_DECLARATION_NAMES, :SYNTHETIC_DECLARATION_MARKER, :SUPPORTED_KINDS
|
|
32
|
+
|
|
33
|
+
def initialize(paths: nil, index: nil, minimum_referring_files: 12, minimum_referring_packages: 5)
|
|
34
|
+
@paths = Array(paths)
|
|
35
|
+
@index = index || ProjectIndex.build(@paths)
|
|
36
|
+
@reference_collector = ReferenceCollector.new(@index)
|
|
37
|
+
@minimum_referring_files = minimum_referring_files
|
|
38
|
+
@minimum_referring_packages = minimum_referring_packages
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def call
|
|
42
|
+
return [] unless index.available?
|
|
43
|
+
|
|
44
|
+
declarations.filter_map { |declaration| finding_for(declaration) }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :index, :reference_collector, :minimum_referring_files, :minimum_referring_packages
|
|
50
|
+
|
|
51
|
+
def declarations
|
|
52
|
+
index.declarations.select { |declaration| declaration_candidate?(declaration) }
|
|
53
|
+
.sort_by { |declaration| declaration.name.to_s }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def declaration_candidate?(declaration)
|
|
57
|
+
declaration.name&.include?("::") && declaration.path && supported_kind?(declaration) &&
|
|
58
|
+
!ignored_declaration_name?(declaration.name) && !PackageMap.ignored_path?(declaration.path) &&
|
|
59
|
+
PackageMap.package_for(declaration.path)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def supported_kind?(declaration)
|
|
63
|
+
!declaration.respond_to?(:kind) || SUPPORTED_KINDS.include?(declaration.kind)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def finding_for(declaration)
|
|
67
|
+
reference_set = ReferenceSet.new(reference_collector.for(declaration))
|
|
68
|
+
return unless reference_set.enough?(minimum_referring_files, minimum_referring_packages)
|
|
69
|
+
|
|
70
|
+
Finding.new(finding_attributes(declaration, reference_set))
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def finding_attributes(declaration, reference_set)
|
|
74
|
+
core_finding_attributes(declaration, reference_set)
|
|
75
|
+
.merge(triage_attributes_for(declaration),
|
|
76
|
+
project_analyzer_metadata: metadata_for(declaration, reference_set))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def core_finding_attributes(declaration, reference_set)
|
|
80
|
+
declared_package = PackageMap.package_for(declaration.path)
|
|
81
|
+
core_identity_attributes(declaration, reference_set, declared_package)
|
|
82
|
+
.merge(core_context_attributes(declaration, reference_set))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def core_identity_attributes(declaration, reference_set, declared_package)
|
|
86
|
+
{ source: source_name, rule_id: RULE_ID,
|
|
87
|
+
message: message_for(declaration, reference_set, declared_package),
|
|
88
|
+
declaration_name: declaration.name, declared_package: declared_package }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def core_context_attributes(declaration, reference_set)
|
|
92
|
+
{ referring_files: reference_set.files,
|
|
93
|
+
referring_packages: reference_set.packages,
|
|
94
|
+
references: reference_set.references,
|
|
95
|
+
primary_location: Location.new(name: declaration.name, path: declaration.path),
|
|
96
|
+
why_it_matters: why_for(declaration), suggested_next_moves: suggested_next_moves_for(declaration) }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def message_for(declaration, reference_set, declared_package)
|
|
100
|
+
"#{declaration.name} is referenced from #{reference_set.files.size} files across " \
|
|
101
|
+
"#{reference_set.packages.size} packages outside #{declared_package}; " \
|
|
102
|
+
"review whether this boundary is carrying too much dependency pressure."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def metadata_for(declaration, reference_set)
|
|
106
|
+
Metadata.for(declaration, reference_set, dependency_pressure_category_for(declaration))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def ignored_declaration_name?(name)
|
|
110
|
+
IGNORED_DECLARATION_NAMES.include?(name) || name.include?(SYNTHETIC_DECLARATION_MARKER)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def triage_attributes_for(declaration)
|
|
114
|
+
return SharedDependencyTriage.attributes(PROJECT_ANALYZER_STATUS) if shared_dependency?(declaration)
|
|
115
|
+
|
|
116
|
+
project_analyzer_triage_attributes
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def why_for(declaration)
|
|
120
|
+
shared_dependency?(declaration) ? SharedDependencyTriage::WHY : WHY
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def suggested_next_moves_for(declaration)
|
|
124
|
+
shared_dependency?(declaration) ? SharedDependencyTriage::SUGGESTED_NEXT_MOVES : SUGGESTED_NEXT_MOVES
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def dependency_pressure_category_for(declaration)
|
|
128
|
+
shared_dependency?(declaration) ? SharedDependencyTriage::CATEGORY : "package_boundary"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def shared_dependency?(declaration)
|
|
132
|
+
SharedDependencyTriage.shared?(declaration)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
Reference = Struct.new(:path, :line, :column, :package, keyword_init: true)
|
|
136
|
+
private_constant :Reference
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
# Classifies coarse project packages used by index-backed project analyzers.
|
|
6
|
+
module PackageMap
|
|
7
|
+
IGNORED_REFERENCE_ROOTS = %w[spec test].freeze
|
|
8
|
+
IGNORED_LIB_PACKAGES = %w[generators seed_data seeders tasks test_data].freeze
|
|
9
|
+
IGNORED_SUPPORT_SEGMENTS = %w[seeds testing_support].freeze
|
|
10
|
+
private_constant :IGNORED_REFERENCE_ROOTS, :IGNORED_LIB_PACKAGES, :IGNORED_SUPPORT_SEGMENTS
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def package_for(path)
|
|
15
|
+
parts = path.to_s.split(File::SEPARATOR)
|
|
16
|
+
return package_after(parts, app_index(parts)) if app_index(parts)
|
|
17
|
+
return package_after(parts, lib_index(parts)) if lib_index(parts)
|
|
18
|
+
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def ignored_path?(path)
|
|
23
|
+
parts = path.to_s.split(File::SEPARATOR)
|
|
24
|
+
ignored_reference_root?(parts) || ignored_lib_package?(parts) || ignored_support_segment?(parts)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def parts_after_package(path)
|
|
28
|
+
parts = path.to_s.split(File::SEPARATOR)
|
|
29
|
+
index = app_index(parts) || lib_index(parts)
|
|
30
|
+
return [] unless index
|
|
31
|
+
|
|
32
|
+
parts[(index + 2)..] || []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def package_parts(path)
|
|
36
|
+
package_for(path).to_s.split("/")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def package_after(parts, index)
|
|
40
|
+
return unless parts[index + 1]
|
|
41
|
+
|
|
42
|
+
"#{parts[index]}/#{parts[index + 1]}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def ignored_reference_root?(parts)
|
|
46
|
+
index = project_root_index(parts)
|
|
47
|
+
index && IGNORED_REFERENCE_ROOTS.include?(parts[index])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ignored_lib_package?(parts)
|
|
51
|
+
index = project_root_index(parts)
|
|
52
|
+
return false unless index && parts[index] == "lib"
|
|
53
|
+
|
|
54
|
+
index && IGNORED_LIB_PACKAGES.include?(parts[index + 1])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def ignored_support_segment?(parts)
|
|
58
|
+
index = app_index(parts) || lib_index(parts)
|
|
59
|
+
return false unless index
|
|
60
|
+
|
|
61
|
+
parts[(index + 1)..].any? { |part| IGNORED_SUPPORT_SEGMENTS.include?(part) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def app_index(parts)
|
|
65
|
+
parts.rindex("app")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def lib_index(parts)
|
|
69
|
+
parts.rindex("lib")
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def project_root_index(parts)
|
|
73
|
+
parts.each_index.reverse_each.find { |index| project_root?(parts[index]) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def project_root?(part)
|
|
77
|
+
part == "app" || part == "lib" || IGNORED_REFERENCE_ROOTS.include?(part)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
module ProjectAnalyzerTriage
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def project_analyzer_triage_attributes
|
|
9
|
+
{ project_analyzer_status: self.class::PROJECT_ANALYZER_STATUS,
|
|
10
|
+
confidence: self.class::CONFIDENCE,
|
|
11
|
+
triage_severity: self.class::TRIAGE_SEVERITY,
|
|
12
|
+
triage_summary: self.class::TRIAGE_SUMMARY }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def source_name
|
|
16
|
+
index ? index.backend_name.to_s : "paths"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
|
|
5
|
+
require_relative "branch_value"
|
|
6
|
+
require_relative "../contextual_node_walker"
|
|
7
|
+
require_relative "predicate_chain"
|
|
8
|
+
|
|
9
|
+
module MetzScan
|
|
10
|
+
module Analyzers
|
|
11
|
+
class RepeatedBranching
|
|
12
|
+
class BranchSiteCollector
|
|
13
|
+
BranchSite = Struct.new(:signature, :kind, :decision, :branch_values, :enclosing_name, :method_name,
|
|
14
|
+
:path, :line, :expression, keyword_init: true)
|
|
15
|
+
SiteDraft = Struct.new(:kind, :decision, :branch_conditions, :branch_values, :contextual_node,
|
|
16
|
+
keyword_init: true)
|
|
17
|
+
|
|
18
|
+
def initialize(path)
|
|
19
|
+
@path = path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call
|
|
23
|
+
return [] unless File.file?(path)
|
|
24
|
+
|
|
25
|
+
contextual_nodes.filter_map { |contextual_node| branch_site_for(contextual_node) }
|
|
26
|
+
rescue Parser::SyntaxError
|
|
27
|
+
[]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :path
|
|
33
|
+
|
|
34
|
+
def processed_source
|
|
35
|
+
RuboCop::ProcessedSource.new(File.read(path), RUBY_VERSION.to_f)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def contextual_nodes
|
|
39
|
+
MetzScan::Analyzers::ContextualNodeWalker.new(processed_source.ast).nodes
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def case_branch?(node)
|
|
43
|
+
node.type == :case && node.children.first
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def root_if_branch?(node)
|
|
47
|
+
node.type == :if && node.loc.respond_to?(:keyword) && node.loc.keyword.source == "if"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def branch_site_for(contextual_node)
|
|
51
|
+
node = contextual_node.node
|
|
52
|
+
return case_site(contextual_node) if case_branch?(node)
|
|
53
|
+
return if_site(contextual_node) if root_if_branch?(node)
|
|
54
|
+
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def case_site(contextual_node)
|
|
59
|
+
node = contextual_node.node
|
|
60
|
+
decision = node.children.first.source
|
|
61
|
+
values = case_values(node)
|
|
62
|
+
build_site(:case, decision, values, contextual_node)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def case_values(node)
|
|
66
|
+
values = node.children.grep(RuboCop::AST::WhenNode).flat_map { |when_node| when_values(when_node) }
|
|
67
|
+
values.uniq(&:signature).sort_by { |value| [value.text, value.signature] }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def when_values(when_node)
|
|
71
|
+
when_node.conditions.map { |condition| condition_value(condition) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def condition_value(condition)
|
|
75
|
+
BranchValue.for(condition)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def if_site(contextual_node)
|
|
79
|
+
predicate_chain = PredicateChain.new(contextual_node.node).call
|
|
80
|
+
return unless predicate_chain
|
|
81
|
+
|
|
82
|
+
build_site(:if, predicate_chain.decision, predicate_chain.branch_values, contextual_node)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_site(kind, decision, values, contextual_node)
|
|
86
|
+
draft = site_draft(kind, decision, values, contextual_node)
|
|
87
|
+
return if draft.branch_values.empty?
|
|
88
|
+
|
|
89
|
+
BranchSite.new(site_attributes(draft))
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def site_draft(kind, decision, values, contextual_node)
|
|
93
|
+
SiteDraft.new(kind: kind, decision: decision, branch_conditions: values,
|
|
94
|
+
branch_values: branch_values_for(values), contextual_node: contextual_node)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def site_attributes(draft)
|
|
98
|
+
context_attributes(draft.contextual_node).merge(site_signature_attributes(draft))
|
|
99
|
+
.merge(site_location_attributes(draft))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def signature_for_draft(draft)
|
|
103
|
+
signature_for(draft.kind, draft.decision, signatures_for(draft.branch_conditions))
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def site_signature_attributes(draft)
|
|
107
|
+
{ signature: signature_for_draft(draft), kind: draft.kind,
|
|
108
|
+
decision: draft.decision, branch_values: draft.branch_values }
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def site_location_attributes(draft)
|
|
112
|
+
node = draft.contextual_node.node
|
|
113
|
+
{ path: path, line: node.loc.expression.line, expression: first_line(node) }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def context_attributes(contextual_node)
|
|
117
|
+
{ enclosing_name: contextual_node.enclosing_name, method_name: contextual_node.method_name }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def branch_values_for(values)
|
|
121
|
+
values.map { |value| value.respond_to?(:text) ? value.text : value }
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def signatures_for(values)
|
|
125
|
+
values.map { |value| value.respond_to?(:signature) ? value.signature : value }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def signature_for(kind, decision, values)
|
|
129
|
+
[kind, decision, values].join(":")
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def first_line(node)
|
|
133
|
+
node.loc.expression.source.lines.first.strip
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class RepeatedBranching
|
|
6
|
+
class BranchValue
|
|
7
|
+
def self.for(condition)
|
|
8
|
+
return literal(condition) if condition.respond_to?(:value)
|
|
9
|
+
|
|
10
|
+
new(condition.source, "source:#{condition.source}")
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.literal(condition)
|
|
14
|
+
text = condition.type == :sym ? ":#{condition.value}" : condition.value.to_s
|
|
15
|
+
new(text, "#{condition.type}:#{condition.value.inspect}")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(text, signature)
|
|
19
|
+
@text = text
|
|
20
|
+
@signature = signature
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :text, :signature
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class RepeatedBranching
|
|
6
|
+
class DecisionSubject
|
|
7
|
+
GENERIC_SUBJECTS = %w[action key.to_s type value value.to_s].freeze
|
|
8
|
+
SIMPLE_IDENTIFIER = /\A@?[a-z_]\w*(?:\.[a-z_]\w*)?\z/
|
|
9
|
+
private_constant :GENERIC_SUBJECTS, :SIMPLE_IDENTIFIER
|
|
10
|
+
|
|
11
|
+
def self.for(decision)
|
|
12
|
+
new(decision)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(decision)
|
|
16
|
+
@decision = decision
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attr_reader :decision
|
|
20
|
+
|
|
21
|
+
def generic?
|
|
22
|
+
kind == "generic"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def kind
|
|
26
|
+
return "generic" if GENERIC_SUBJECTS.include?(decision)
|
|
27
|
+
return "state" if SIMPLE_IDENTIFIER.match?(decision)
|
|
28
|
+
|
|
29
|
+
"expression"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def label
|
|
33
|
+
{ "generic" => "generic branch subject",
|
|
34
|
+
"state" => "state branch subject",
|
|
35
|
+
"expression" => "expression subject" }.fetch(kind)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def summary
|
|
39
|
+
{ "generic" => "Generic subject; use reported contexts before treating this as design pressure.",
|
|
40
|
+
"state" => "State-like subject; repeated branch tables may describe a shared domain decision.",
|
|
41
|
+
"expression" => "Expression subject; repeated computed conditions may deserve extraction." }.fetch(kind)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class RepeatedBranching
|
|
6
|
+
class PredicateChain
|
|
7
|
+
Result = Struct.new(:decision, :branch_values, keyword_init: true)
|
|
8
|
+
Predicate = Struct.new(:receiver, :method_name, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
def initialize(node)
|
|
11
|
+
@node = node
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
predicates = predicate_chain
|
|
16
|
+
return unless same_receiver_chain?(predicates)
|
|
17
|
+
|
|
18
|
+
Result.new(decision: predicates.first.receiver, branch_values: predicate_values(predicates))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_reader :node
|
|
24
|
+
|
|
25
|
+
def predicate_chain
|
|
26
|
+
if_chain.map { |if_node| predicate_for(if_node.children.first) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def if_chain
|
|
30
|
+
chain = []
|
|
31
|
+
chain_if_nodes(node, chain)
|
|
32
|
+
chain
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def chain_if_nodes(node, chain)
|
|
36
|
+
return unless node&.type == :if
|
|
37
|
+
|
|
38
|
+
chain << node
|
|
39
|
+
chain_if_nodes(node.children[2], chain)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def predicate_for(node)
|
|
43
|
+
return unless node&.type == :send && node.receiver && node.method_name.to_s.end_with?("?")
|
|
44
|
+
|
|
45
|
+
Predicate.new(receiver: node.receiver.source, method_name: node.method_name.to_s)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def same_receiver_chain?(predicates)
|
|
49
|
+
predicates.size > 1 && predicates.all? && predicates.map(&:receiver).uniq.one?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def predicate_values(predicates)
|
|
53
|
+
predicates.map(&:method_name).uniq.sort
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class RepeatedBranching
|
|
6
|
+
module Triage
|
|
7
|
+
GENERIC_CONFIDENCE = "low"
|
|
8
|
+
GENERIC_TRIAGE_SEVERITY = "context required"
|
|
9
|
+
GENERIC_TRIAGE_SUMMARY = "Generic repeated-decision signal; use reported contexts and branch values before " \
|
|
10
|
+
"treating this as design pressure."
|
|
11
|
+
private_constant :GENERIC_CONFIDENCE, :GENERIC_TRIAGE_SEVERITY, :GENERIC_TRIAGE_SUMMARY
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def attributes_for(subject, status:, fallback:)
|
|
16
|
+
return fallback unless subject.generic?
|
|
17
|
+
|
|
18
|
+
generic_subject_attributes(status)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def generic_subject_attributes(status)
|
|
22
|
+
{ project_analyzer_status: status, confidence: GENERIC_CONFIDENCE,
|
|
23
|
+
triage_severity: GENERIC_TRIAGE_SEVERITY, triage_summary: GENERIC_TRIAGE_SUMMARY }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
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 "repeated_branching/branch_site_collector"
|
|
8
|
+
require_relative "repeated_branching/decision_subject"
|
|
9
|
+
require_relative "repeated_branching/triage"
|
|
10
|
+
|
|
11
|
+
module MetzScan
|
|
12
|
+
module Analyzers
|
|
13
|
+
# Groups repeated case/if branching decisions across indexed Ruby files.
|
|
14
|
+
class RepeatedBranching
|
|
15
|
+
include ProjectAnalyzerTriage
|
|
16
|
+
|
|
17
|
+
RULE_ID = "MetzProject/RepeatedBranching"
|
|
18
|
+
PROJECT_ANALYZER_STATUS = "validated"
|
|
19
|
+
DEFAULT_OUTPUT_ELIGIBLE = true
|
|
20
|
+
CONFIDENCE = "medium"
|
|
21
|
+
TRIAGE_SEVERITY = "design pressure"
|
|
22
|
+
TRIAGE_SUMMARY = "Validated repeated-decision signal; review repeated decisions in context."
|
|
23
|
+
WHY = "Repeated branching spreads one domain decision across files and makes change ripple outward."
|
|
24
|
+
SUGGESTED_NEXT_MOVES = [
|
|
25
|
+
"Name the domain decision once and reuse it instead of repeating the same branch table.",
|
|
26
|
+
"Consider polymorphism, a strategy object, or a small lookup object when the branch represents type behavior."
|
|
27
|
+
].freeze
|
|
28
|
+
Finding = Struct.new(:source, :rule_id, :message, :decision, :kind, :branch_values, :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: context_name(occurrence)) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def context_name(occurrence)
|
|
37
|
+
[occurrence.enclosing_name, occurrence.method_name].compact.join.then { |name| name unless name.empty? }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(paths: nil, index: nil, minimum_occurrences: 2)
|
|
42
|
+
@paths = Array(paths)
|
|
43
|
+
@index = index
|
|
44
|
+
@minimum_occurrences = minimum_occurrences
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def call
|
|
48
|
+
grouped_sites.filter_map { |_signature, sites| finding_for(sites) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
attr_reader :paths, :index, :minimum_occurrences
|
|
54
|
+
|
|
55
|
+
def grouped_sites
|
|
56
|
+
branch_sites.group_by(&:signature)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def branch_sites
|
|
60
|
+
ruby_files.flat_map { |path| BranchSiteCollector.new(path).call }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def ruby_files
|
|
64
|
+
RubyFileEnumerator.new(paths: paths, index: index).call
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def finding_for(sites)
|
|
68
|
+
return if distinct_paths(sites).size < minimum_occurrences
|
|
69
|
+
|
|
70
|
+
first = sites.first
|
|
71
|
+
Finding.new(finding_attributes(first, sites))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def finding_attributes(first, sites)
|
|
75
|
+
subject = DecisionSubject.for(first.decision)
|
|
76
|
+
core_finding_attributes(first, sites, subject).merge(triage_attributes_for(first, sites, subject))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def triage_attributes_for(first, sites, subject)
|
|
80
|
+
subject_triage_attributes(subject).merge(
|
|
81
|
+
project_analyzer_metadata: project_analyzer_metadata_for(first, sites, subject),
|
|
82
|
+
why_it_matters: WHY, suggested_next_moves: SUGGESTED_NEXT_MOVES
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def subject_triage_attributes(subject)
|
|
87
|
+
Triage.attributes_for(subject, status: PROJECT_ANALYZER_STATUS, fallback: project_analyzer_triage_attributes)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def core_finding_attributes(first, sites, subject)
|
|
91
|
+
{ source: source_name, rule_id: RULE_ID, message: message_for(first, sites, subject),
|
|
92
|
+
decision: first.decision, kind: first.kind, branch_values: first.branch_values, occurrences: sites }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def distinct_paths(sites)
|
|
96
|
+
sites.map(&:path).uniq
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def message_for(site, sites, subject)
|
|
100
|
+
"#{site.decision} (#{subject.label}) branches in #{distinct_paths(sites).size} files" \
|
|
101
|
+
"#{context_suffix(sites)}; " \
|
|
102
|
+
"consider consolidating the decision."
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def context_suffix(sites)
|
|
106
|
+
names = context_names(sites)
|
|
107
|
+
return "" if names.empty?
|
|
108
|
+
|
|
109
|
+
": #{names.join(', ')}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def context_names(sites)
|
|
113
|
+
sites.filter_map { |site| context_name(site) }.uniq.sort
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def context_name(site)
|
|
117
|
+
[site.enclosing_name, site.method_name].compact.join.then { |name| name unless name.empty? }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def project_analyzer_metadata_for(site, sites, subject)
|
|
121
|
+
{ "decision" => site.decision, "kind" => site.kind.to_s,
|
|
122
|
+
"branch_values" => site.branch_values, "project_analyzer_category" => subject.kind,
|
|
123
|
+
"decision_subject_kind" => subject.kind,
|
|
124
|
+
"decision_subject_label" => subject.label, "decision_subject_summary" => subject.summary,
|
|
125
|
+
"occurrences" => sites.map { |occurrence| occurrence_metadata(occurrence) } }
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def occurrence_metadata(site)
|
|
129
|
+
{ "context" => context_name(site), "enclosing" => site.enclosing_name,
|
|
130
|
+
"method" => site.method_name, "path" => site.path, "line" => site.line,
|
|
131
|
+
"expression" => site.expression }.compact
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|