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 "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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cross_package_reference_set"
4
+
5
+ module MetzScan
6
+ module Analyzers
7
+ class NamespaceLeakPressure
8
+ class ReferenceSet < CrossPackageReferenceSet
9
+ end
10
+ end
11
+ end
12
+ end