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.

Files changed (98) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/lib/metz_scan/analyzers/contextual_node_walker.rb +82 -0
  4. data/lib/metz_scan/analyzers/cross_package_reference_metadata.rb +36 -0
  5. data/lib/metz_scan/analyzers/cross_package_reference_set.rb +33 -0
  6. data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_collector.rb +54 -0
  7. data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_reference.rb +37 -0
  8. data/lib/metz_scan/analyzers/implicit_context_pressure/current_attribute_collector.rb +73 -0
  9. data/lib/metz_scan/analyzers/implicit_context_pressure/reference_set.rb +37 -0
  10. data/lib/metz_scan/analyzers/implicit_context_pressure/thread_current_collector.rb +76 -0
  11. data/lib/metz_scan/analyzers/implicit_context_pressure/triage.rb +109 -0
  12. data/lib/metz_scan/analyzers/implicit_context_pressure.rb +139 -0
  13. data/lib/metz_scan/analyzers/inheritance_descendants/finding.rb +18 -0
  14. data/lib/metz_scan/analyzers/inheritance_descendants/metadata.rb +23 -0
  15. data/lib/metz_scan/analyzers/inheritance_descendants/root_kind.rb +58 -0
  16. data/lib/metz_scan/analyzers/inheritance_descendants/triage.rb +30 -0
  17. data/lib/metz_scan/analyzers/inheritance_descendants.rb +137 -0
  18. data/lib/metz_scan/analyzers/namespace_leak_pressure/finding.rb +20 -0
  19. data/lib/metz_scan/analyzers/namespace_leak_pressure/metadata.rb +40 -0
  20. data/lib/metz_scan/analyzers/namespace_leak_pressure/namespace.rb +41 -0
  21. data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_collector.rb +50 -0
  22. data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_set.rb +12 -0
  23. data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage/exception_declaration.rb +117 -0
  24. data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage.rb +69 -0
  25. data/lib/metz_scan/analyzers/namespace_leak_pressure.rb +139 -0
  26. data/lib/metz_scan/analyzers/occurrence.rb +28 -0
  27. data/lib/metz_scan/analyzers/package_dependency_pressure/finding.rb +20 -0
  28. data/lib/metz_scan/analyzers/package_dependency_pressure/metadata.rb +27 -0
  29. data/lib/metz_scan/analyzers/package_dependency_pressure/package_map.rb +11 -0
  30. data/lib/metz_scan/analyzers/package_dependency_pressure/reference_collector.rb +38 -0
  31. data/lib/metz_scan/analyzers/package_dependency_pressure/reference_set.rb +12 -0
  32. data/lib/metz_scan/analyzers/package_dependency_pressure/shared_dependency_triage.rb +124 -0
  33. data/lib/metz_scan/analyzers/package_dependency_pressure.rb +139 -0
  34. data/lib/metz_scan/analyzers/package_map.rb +81 -0
  35. data/lib/metz_scan/analyzers/project_analyzer_triage.rb +20 -0
  36. data/lib/metz_scan/analyzers/repeated_branching/branch_site_collector.rb +138 -0
  37. data/lib/metz_scan/analyzers/repeated_branching/branch_value.rb +27 -0
  38. data/lib/metz_scan/analyzers/repeated_branching/contextual_node_walker.rb +11 -0
  39. data/lib/metz_scan/analyzers/repeated_branching/decision_subject.rb +46 -0
  40. data/lib/metz_scan/analyzers/repeated_branching/predicate_chain.rb +58 -0
  41. data/lib/metz_scan/analyzers/repeated_branching/triage.rb +28 -0
  42. data/lib/metz_scan/analyzers/repeated_branching.rb +135 -0
  43. data/lib/metz_scan/analyzers/repeated_query_criteria/triage.rb +103 -0
  44. data/lib/metz_scan/analyzers/repeated_query_criteria.rb +285 -0
  45. data/lib/metz_scan/analyzers/ruby_file_enumerator.rb +37 -0
  46. data/lib/metz_scan/analyzers/service_soup/service_call_pattern.rb +53 -0
  47. data/lib/metz_scan/analyzers/service_soup/setup_workflow_triage.rb +48 -0
  48. data/lib/metz_scan/analyzers/service_soup/workflow_collector.rb +130 -0
  49. data/lib/metz_scan/analyzers/service_soup.rb +128 -0
  50. data/lib/metz_scan/analyzers/subclass_override_pressure/family_builder.rb +69 -0
  51. data/lib/metz_scan/analyzers/subclass_override_pressure/finding.rb +30 -0
  52. data/lib/metz_scan/analyzers/subclass_override_pressure/metadata.rb +74 -0
  53. data/lib/metz_scan/analyzers/subclass_override_pressure/method_body_facts.rb +100 -0
  54. data/lib/metz_scan/analyzers/subclass_override_pressure/triage.rb +106 -0
  55. data/lib/metz_scan/analyzers/subclass_override_pressure.rb +133 -0
  56. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/artifact_writer.rb +51 -0
  57. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/baseline_delta.rb +231 -0
  58. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/collaborators.rb +76 -0
  59. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/markdown_renderer.rb +353 -0
  60. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/readiness_catalog.rb +123 -0
  61. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/summary.rb +333 -0
  62. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/target_set.rb +139 -0
  63. data/lib/metz_scan/calibration/project_analyzer_evidence_runner.rb +64 -0
  64. data/lib/metz_scan/calibration/rubydex_drift_formatter.rb +91 -0
  65. data/lib/metz_scan/cli.rb +120 -0
  66. data/lib/metz_scan/commands/explain.rb +128 -0
  67. data/lib/metz_scan/commands/project_analyzers.rb +105 -0
  68. data/lib/metz_scan/commands/report.rb +115 -0
  69. data/lib/metz_scan/commands/rules.rb +92 -0
  70. data/lib/metz_scan/commands/scan/auto_fix.rb +138 -0
  71. data/lib/metz_scan/commands/scan/compliance_scorecard.rb +124 -0
  72. data/lib/metz_scan/commands/scan/github_annotations_renderer.rb +55 -0
  73. data/lib/metz_scan/commands/scan/offense_extractor.rb +42 -0
  74. data/lib/metz_scan/commands/scan/project_analyzer_breakdown.rb +55 -0
  75. data/lib/metz_scan/commands/scan/project_analyzer_metadata.rb +78 -0
  76. data/lib/metz_scan/commands/scan/project_analyzer_offenses.rb +85 -0
  77. data/lib/metz_scan/commands/scan/project_analyzer_runner.rb +148 -0
  78. data/lib/metz_scan/commands/scan/project_analyzer_summary_aggregate_formatter.rb +74 -0
  79. data/lib/metz_scan/commands/scan/project_analyzer_summary_breakdown_formatter.rb +46 -0
  80. data/lib/metz_scan/commands/scan/project_analyzer_triage_formatter.rb +40 -0
  81. data/lib/metz_scan/commands/scan/project_analyzer_triage_priority.rb +38 -0
  82. data/lib/metz_scan/commands/scan/project_config_scope.rb +231 -0
  83. data/lib/metz_scan/commands/scan/runner.rb +199 -0
  84. data/lib/metz_scan/commands/scan/sarif_renderer.rb +103 -0
  85. data/lib/metz_scan/commands/scan/sarif_rule_descriptors.rb +82 -0
  86. data/lib/metz_scan/commands/scan/sarif_severity.rb +27 -0
  87. data/lib/metz_scan/commands/scan/target_ruby_version.rb +56 -0
  88. data/lib/metz_scan/commands/scan/text_renderer.rb +145 -0
  89. data/lib/metz_scan/commands/scan.rb +201 -0
  90. data/lib/metz_scan/project_index/null_backend.rb +35 -0
  91. data/lib/metz_scan/project_index/rubydex_backend/file_discovery.rb +33 -0
  92. data/lib/metz_scan/project_index/rubydex_backend/location_formatting.rb +36 -0
  93. data/lib/metz_scan/project_index/rubydex_backend/method_declarations.rb +118 -0
  94. data/lib/metz_scan/project_index/rubydex_backend.rb +133 -0
  95. data/lib/metz_scan/project_index.rb +109 -0
  96. data/lib/metz_scan/version.rb +5 -0
  97. data/lib/metz_scan.rb +5 -0
  98. metadata +185 -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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../contextual_node_walker"
4
+
5
+ module MetzScan
6
+ module Analyzers
7
+ class RepeatedBranching
8
+ ContextualNodeWalker = MetzScan::Analyzers::ContextualNodeWalker
9
+ end
10
+ end
11
+ 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