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.
Files changed (100) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/bin/metz-scan +10 -0
  4. data/lib/metz_scan/analyzers/contextual_node_walker.rb +82 -0
  5. data/lib/metz_scan/analyzers/cross_package_reference_metadata.rb +36 -0
  6. data/lib/metz_scan/analyzers/cross_package_reference_set.rb +33 -0
  7. data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_collector.rb +54 -0
  8. data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_reference.rb +37 -0
  9. data/lib/metz_scan/analyzers/implicit_context_pressure/current_attribute_collector.rb +73 -0
  10. data/lib/metz_scan/analyzers/implicit_context_pressure/reference_set.rb +37 -0
  11. data/lib/metz_scan/analyzers/implicit_context_pressure/thread_current_collector.rb +76 -0
  12. data/lib/metz_scan/analyzers/implicit_context_pressure/triage.rb +109 -0
  13. data/lib/metz_scan/analyzers/implicit_context_pressure.rb +139 -0
  14. data/lib/metz_scan/analyzers/inheritance_descendants/finding.rb +18 -0
  15. data/lib/metz_scan/analyzers/inheritance_descendants/metadata.rb +23 -0
  16. data/lib/metz_scan/analyzers/inheritance_descendants/root_kind.rb +58 -0
  17. data/lib/metz_scan/analyzers/inheritance_descendants/triage.rb +30 -0
  18. data/lib/metz_scan/analyzers/inheritance_descendants.rb +137 -0
  19. data/lib/metz_scan/analyzers/namespace_leak_pressure/finding.rb +20 -0
  20. data/lib/metz_scan/analyzers/namespace_leak_pressure/metadata.rb +40 -0
  21. data/lib/metz_scan/analyzers/namespace_leak_pressure/namespace.rb +41 -0
  22. data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_collector.rb +50 -0
  23. data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_set.rb +12 -0
  24. data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage/exception_declaration.rb +117 -0
  25. data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage.rb +69 -0
  26. data/lib/metz_scan/analyzers/namespace_leak_pressure.rb +139 -0
  27. data/lib/metz_scan/analyzers/occurrence.rb +28 -0
  28. data/lib/metz_scan/analyzers/package_dependency_pressure/finding.rb +20 -0
  29. data/lib/metz_scan/analyzers/package_dependency_pressure/metadata.rb +27 -0
  30. data/lib/metz_scan/analyzers/package_dependency_pressure/package_map.rb +11 -0
  31. data/lib/metz_scan/analyzers/package_dependency_pressure/reference_collector.rb +38 -0
  32. data/lib/metz_scan/analyzers/package_dependency_pressure/reference_set.rb +12 -0
  33. data/lib/metz_scan/analyzers/package_dependency_pressure/shared_dependency_triage.rb +124 -0
  34. data/lib/metz_scan/analyzers/package_dependency_pressure.rb +139 -0
  35. data/lib/metz_scan/analyzers/package_map.rb +81 -0
  36. data/lib/metz_scan/analyzers/project_analyzer_triage.rb +20 -0
  37. data/lib/metz_scan/analyzers/repeated_branching/branch_site_collector.rb +138 -0
  38. data/lib/metz_scan/analyzers/repeated_branching/branch_value.rb +27 -0
  39. data/lib/metz_scan/analyzers/repeated_branching/contextual_node_walker.rb +11 -0
  40. data/lib/metz_scan/analyzers/repeated_branching/decision_subject.rb +46 -0
  41. data/lib/metz_scan/analyzers/repeated_branching/predicate_chain.rb +58 -0
  42. data/lib/metz_scan/analyzers/repeated_branching/triage.rb +28 -0
  43. data/lib/metz_scan/analyzers/repeated_branching.rb +135 -0
  44. data/lib/metz_scan/analyzers/repeated_query_criteria/triage.rb +103 -0
  45. data/lib/metz_scan/analyzers/repeated_query_criteria.rb +285 -0
  46. data/lib/metz_scan/analyzers/ruby_file_enumerator.rb +37 -0
  47. data/lib/metz_scan/analyzers/service_soup/service_call_pattern.rb +53 -0
  48. data/lib/metz_scan/analyzers/service_soup/setup_workflow_triage.rb +48 -0
  49. data/lib/metz_scan/analyzers/service_soup/workflow_collector.rb +130 -0
  50. data/lib/metz_scan/analyzers/service_soup.rb +128 -0
  51. data/lib/metz_scan/analyzers/subclass_override_pressure/family_builder.rb +69 -0
  52. data/lib/metz_scan/analyzers/subclass_override_pressure/finding.rb +30 -0
  53. data/lib/metz_scan/analyzers/subclass_override_pressure/metadata.rb +74 -0
  54. data/lib/metz_scan/analyzers/subclass_override_pressure/method_body_facts.rb +100 -0
  55. data/lib/metz_scan/analyzers/subclass_override_pressure/triage.rb +106 -0
  56. data/lib/metz_scan/analyzers/subclass_override_pressure.rb +133 -0
  57. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/artifact_writer.rb +51 -0
  58. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/baseline_delta.rb +231 -0
  59. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/collaborators.rb +76 -0
  60. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/markdown_renderer.rb +353 -0
  61. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/readiness_catalog.rb +123 -0
  62. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/summary.rb +333 -0
  63. data/lib/metz_scan/calibration/project_analyzer_evidence_runner/target_set.rb +139 -0
  64. data/lib/metz_scan/calibration/project_analyzer_evidence_runner.rb +64 -0
  65. data/lib/metz_scan/calibration/rubydex_drift_formatter.rb +91 -0
  66. data/lib/metz_scan/cli.rb +120 -0
  67. data/lib/metz_scan/commands/explain.rb +128 -0
  68. data/lib/metz_scan/commands/project_analyzers.rb +105 -0
  69. data/lib/metz_scan/commands/report.rb +115 -0
  70. data/lib/metz_scan/commands/rules.rb +92 -0
  71. data/lib/metz_scan/commands/scan/auto_fix.rb +138 -0
  72. data/lib/metz_scan/commands/scan/compliance_scorecard.rb +124 -0
  73. data/lib/metz_scan/commands/scan/github_annotations_renderer.rb +55 -0
  74. data/lib/metz_scan/commands/scan/offense_extractor.rb +42 -0
  75. data/lib/metz_scan/commands/scan/project_analyzer_breakdown.rb +55 -0
  76. data/lib/metz_scan/commands/scan/project_analyzer_metadata.rb +78 -0
  77. data/lib/metz_scan/commands/scan/project_analyzer_offenses.rb +85 -0
  78. data/lib/metz_scan/commands/scan/project_analyzer_runner.rb +148 -0
  79. data/lib/metz_scan/commands/scan/project_analyzer_summary_aggregate_formatter.rb +74 -0
  80. data/lib/metz_scan/commands/scan/project_analyzer_summary_breakdown_formatter.rb +46 -0
  81. data/lib/metz_scan/commands/scan/project_analyzer_triage_formatter.rb +40 -0
  82. data/lib/metz_scan/commands/scan/project_analyzer_triage_priority.rb +38 -0
  83. data/lib/metz_scan/commands/scan/project_config_scope.rb +231 -0
  84. data/lib/metz_scan/commands/scan/runner.rb +199 -0
  85. data/lib/metz_scan/commands/scan/sarif_renderer.rb +103 -0
  86. data/lib/metz_scan/commands/scan/sarif_rule_descriptors.rb +82 -0
  87. data/lib/metz_scan/commands/scan/sarif_severity.rb +27 -0
  88. data/lib/metz_scan/commands/scan/target_ruby_version.rb +56 -0
  89. data/lib/metz_scan/commands/scan/text_renderer.rb +145 -0
  90. data/lib/metz_scan/commands/scan.rb +201 -0
  91. data/lib/metz_scan/project_index/null_backend.rb +35 -0
  92. data/lib/metz_scan/project_index/rubydex_backend/file_discovery.rb +33 -0
  93. data/lib/metz_scan/project_index/rubydex_backend/location_formatting.rb +36 -0
  94. data/lib/metz_scan/project_index/rubydex_backend/method_declarations.rb +118 -0
  95. data/lib/metz_scan/project_index/rubydex_backend.rb +133 -0
  96. data/lib/metz_scan/project_index.rb +109 -0
  97. data/lib/metz_scan/version.rb +5 -0
  98. data/lib/metz_scan.rb +5 -0
  99. data/metz-scan.gemspec +41 -0
  100. metadata +160 -0
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ module MetzScan
6
+ module Analyzers
7
+ class NamespaceLeakPressure
8
+ module SharedNamespaceTriage
9
+ class ExceptionDeclaration
10
+ ERROR_SUFFIX_PATTERN = /(?:Error|Exception)\z/
11
+ EXCEPTION_FAMILY_SEGMENT_PATTERN = /(?:Errors?|Exceptions?)\z/i
12
+ STANDARD_EXCEPTION_ROOTS = %w[Exception RuntimeError StandardError].freeze
13
+ private_constant :ERROR_SUFFIX_PATTERN, :EXCEPTION_FAMILY_SEGMENT_PATTERN, :STANDARD_EXCEPTION_ROOTS
14
+
15
+ def self.exception?(declaration)
16
+ new(declaration).exception?
17
+ end
18
+
19
+ def initialize(declaration)
20
+ @declaration = declaration
21
+ end
22
+
23
+ def exception?
24
+ return false unless declaration.path && File.file?(declaration.path)
25
+
26
+ exception_node?(processed_source.ast, declaration.name.to_s)
27
+ rescue Parser::SyntaxError, SystemCallError
28
+ false
29
+ end
30
+
31
+ private
32
+
33
+ attr_reader :declaration
34
+
35
+ def processed_source
36
+ RuboCop::ProcessedSource.new(File.read(declaration.path), RUBY_VERSION.to_f)
37
+ end
38
+
39
+ def exception_node?(node, name, namespace = [])
40
+ return false unless node
41
+ return module_exception?(node, name, namespace) if node.type == :module
42
+ return class_exception?(node, name, namespace) if node.type == :class
43
+ return assignment_exception?(node, name, namespace) if node.type == :casgn
44
+
45
+ child_exception?(node, name, namespace)
46
+ end
47
+
48
+ def module_exception?(node, name, namespace)
49
+ exception_node?(node.children.last, name, namespace_for(node.children.first, namespace))
50
+ end
51
+
52
+ def class_exception?(node, name, namespace)
53
+ name_node, superclass_node, body = node.children
54
+ return true if constant_name(name_node, namespace) == name && exception_name?(superclass_node&.source)
55
+
56
+ exception_node?(body, name, namespace_for(name_node, namespace))
57
+ end
58
+
59
+ def assignment_exception?(node, name, namespace)
60
+ assignment_name(node, namespace) == name && class_new_exception?(node.children[2])
61
+ end
62
+
63
+ def child_exception?(node, name, namespace)
64
+ node.children.grep(RuboCop::AST::Node).any? { |child| exception_node?(child, name, namespace) }
65
+ end
66
+
67
+ def namespace_for(name_node, namespace)
68
+ namespace + constant_segments(name_node)
69
+ end
70
+
71
+ def constant_name(name_node, namespace)
72
+ segments = constant_segments(name_node)
73
+ return segments.join("::") if qualified_constant?(name_node)
74
+
75
+ (namespace + segments).join("::")
76
+ end
77
+
78
+ def assignment_name(node, namespace)
79
+ scope_node, constant_name = node.children
80
+ return (namespace + [constant_name.to_s]).join("::") unless scope_node
81
+
82
+ (constant_segments(scope_node) + [constant_name.to_s]).join("::")
83
+ end
84
+
85
+ def class_new_exception?(node)
86
+ return false unless node&.type == :send
87
+ return false unless node.receiver&.source == "Class" && node.method_name == :new
88
+
89
+ exception_name?(node.arguments.first&.source)
90
+ end
91
+
92
+ def exception_name?(name)
93
+ segments = name.to_s.split("::")
94
+ STANDARD_EXCEPTION_ROOTS.include?(segments.last) || error_tail?(segments.last) ||
95
+ segments.any? { |segment| exception_family_segment?(segment) }
96
+ end
97
+
98
+ def constant_segments(node)
99
+ node&.source.to_s.sub(/\A::/, "").split("::").reject(&:empty?)
100
+ end
101
+
102
+ def qualified_constant?(node)
103
+ node&.source.to_s.include?("::")
104
+ end
105
+
106
+ def error_tail?(segment)
107
+ segment.to_s.match?(ERROR_SUFFIX_PATTERN)
108
+ end
109
+
110
+ def exception_family_segment?(segment)
111
+ segment.to_s.match?(EXCEPTION_FAMILY_SEGMENT_PATTERN)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "shared_namespace_triage/exception_declaration"
4
+
5
+ module MetzScan
6
+ module Analyzers
7
+ class NamespaceLeakPressure
8
+ module SharedNamespaceTriage
9
+ CATEGORY = "shared_namespace"
10
+ CONFIDENCE = "low"
11
+ TRIAGE_SEVERITY = "shared namespace"
12
+ TRIAGE_SUMMARY = "Shared namespace signal; review public constants and extension points only when " \
13
+ "they make app code depend on namespace internals."
14
+ WHY = "Public constants, error families, and framework extension namespaces are often intentional APIs."
15
+ SUGGESTED_NEXT_MOVES = [
16
+ "Leave stable public constants alone when the namespace is the intended API.",
17
+ "Introduce a narrower public object only when callers repeat namespace-specific knowledge."
18
+ ].freeze
19
+ SHARED_SEGMENT_PATTERN =
20
+ /\A(?:Config|Settings?|Core|Events?|Types?|VERSION|DefaultsProvider|RedisKeys|Registr(?:y|ies)|Errors?|
21
+ Exceptions?)\z/ix
22
+ EXCEPTION_FAMILY_SEGMENT_PATTERN = /(?:Errors?|Exceptions?)\z/i
23
+ ERROR_SUFFIX_PATTERN = /(?:Error|Exception)\z/
24
+ FRAMEWORK_NAMESPACE_PATTERN = /\A(?:I18n::Backend|Spree::Core)\b/
25
+ private_constant :SHARED_SEGMENT_PATTERN, :EXCEPTION_FAMILY_SEGMENT_PATTERN, :ERROR_SUFFIX_PATTERN,
26
+ :FRAMEWORK_NAMESPACE_PATTERN
27
+
28
+ module_function
29
+
30
+ def shared?(declaration)
31
+ shared_name?(declaration.name) ||
32
+ ExceptionDeclaration.exception?(declaration) ||
33
+ framework_namespace?(declaration.name)
34
+ end
35
+
36
+ def attributes(status)
37
+ { project_analyzer_status: status, confidence: CONFIDENCE,
38
+ triage_severity: TRIAGE_SEVERITY, triage_summary: TRIAGE_SUMMARY }
39
+ end
40
+
41
+ def shared_name?(name)
42
+ segments = name.to_s.split("::")
43
+ segments.any? { |segment| shared_segment?(segment) } ||
44
+ constant_like_tail?(segments.last) || error_like_tail?(segments.last)
45
+ end
46
+
47
+ def framework_namespace?(name)
48
+ name.to_s.match?(FRAMEWORK_NAMESPACE_PATTERN)
49
+ end
50
+
51
+ def shared_segment?(segment)
52
+ segment.to_s.match?(SHARED_SEGMENT_PATTERN) || exception_family_segment?(segment)
53
+ end
54
+
55
+ def constant_like_tail?(segment)
56
+ segment.to_s.match?(/\A[A-Z][A-Z0-9_]*\z/)
57
+ end
58
+
59
+ def error_like_tail?(segment)
60
+ segment.to_s.match?(ERROR_SUFFIX_PATTERN)
61
+ end
62
+
63
+ def exception_family_segment?(segment)
64
+ segment.to_s.match?(EXCEPTION_FAMILY_SEGMENT_PATTERN)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../project_index"
4
+ require_relative "namespace_leak_pressure/finding"
5
+ require_relative "namespace_leak_pressure/metadata"
6
+ require_relative "namespace_leak_pressure/namespace"
7
+ require_relative "namespace_leak_pressure/reference_collector"
8
+ require_relative "namespace_leak_pressure/reference_set"
9
+ require_relative "namespace_leak_pressure/shared_namespace_triage"
10
+ require_relative "package_map"
11
+ require_relative "project_analyzer_triage"
12
+
13
+ module MetzScan
14
+ module Analyzers
15
+ # Reports internal namespace constants referenced outside their namespace.
16
+ class NamespaceLeakPressure
17
+ include ProjectAnalyzerTriage
18
+
19
+ RULE_ID = "MetzProject/NamespaceLeakPressure"
20
+ PROJECT_ANALYZER_STATUS = "candidate"
21
+ CONFIDENCE = "medium"
22
+ TRIAGE_SEVERITY = "manual review"
23
+ TRIAGE_SUMMARY = "Candidate namespace-boundary signal; review internal constants used across layers."
24
+ WHY = "Internal namespace constants can become implicit public APIs when distant callers reference them directly."
25
+ SUGGESTED_NEXT_MOVES = [
26
+ "Review whether callers should depend on a narrower public namespace interface.",
27
+ "Look for behavior that belongs behind the namespace boundary instead of in external callers."
28
+ ].freeze
29
+ IGNORED_DECLARATION_NAMES = %w[BasicObject Class Kernel Module Object].freeze
30
+ SYNTHETIC_DECLARATION_MARKER = "::<"
31
+ SUPPORTED_KINDS = [nil, :class, :module].freeze
32
+ private_constant :IGNORED_DECLARATION_NAMES, :SYNTHETIC_DECLARATION_MARKER, :SUPPORTED_KINDS
33
+
34
+ def initialize(paths: nil, index: nil, minimum_referring_files: 3, minimum_referring_packages: 3)
35
+ @paths = Array(paths)
36
+ @index = index || ProjectIndex.build(@paths)
37
+ @reference_collector = ReferenceCollector.new(@index)
38
+ @minimum_referring_files = minimum_referring_files
39
+ @minimum_referring_packages = minimum_referring_packages
40
+ end
41
+
42
+ def call
43
+ return [] unless index.available?
44
+
45
+ declarations.filter_map { |declaration| finding_for(declaration) }
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :index, :reference_collector, :minimum_referring_files, :minimum_referring_packages
51
+
52
+ def declarations
53
+ index.declarations.select { |declaration| declaration_candidate?(declaration) }
54
+ .sort_by { |declaration| declaration.name.to_s }
55
+ end
56
+
57
+ def declaration_candidate?(declaration)
58
+ declaration.path && Namespace.new(declaration.name).deep? && supported_kind?(declaration) &&
59
+ !ignored_declaration_name?(declaration.name) && !PackageMap.ignored_path?(declaration.path) &&
60
+ PackageMap.package_for(declaration.path)
61
+ end
62
+
63
+ def supported_kind?(declaration)
64
+ !declaration.respond_to?(:kind) || SUPPORTED_KINDS.include?(declaration.kind)
65
+ end
66
+
67
+ def finding_for(declaration)
68
+ reference_set = ReferenceSet.new(reference_collector.for(declaration))
69
+ return unless reference_set.enough?(minimum_referring_files, minimum_referring_packages)
70
+
71
+ Finding.new(finding_attributes(declaration, reference_set))
72
+ end
73
+
74
+ def finding_attributes(declaration, reference_set)
75
+ core_finding_attributes(declaration, reference_set)
76
+ .merge(triage_attributes_for(declaration),
77
+ project_analyzer_metadata: metadata_for(declaration, reference_set))
78
+ end
79
+
80
+ def core_finding_attributes(declaration, reference_set)
81
+ identity_attributes(declaration, reference_set)
82
+ .merge(context_attributes(declaration, reference_set))
83
+ end
84
+
85
+ def identity_attributes(declaration, reference_set)
86
+ { source: source_name, rule_id: RULE_ID, message: message_for(declaration, reference_set),
87
+ declaration_name: declaration.name, home_namespace: home_namespace_for(declaration.name),
88
+ declared_package: PackageMap.package_for(declaration.path) }
89
+ end
90
+
91
+ def context_attributes(declaration, reference_set)
92
+ { referring_files: reference_set.files, referring_packages: reference_set.packages,
93
+ references: reference_set.references,
94
+ primary_location: Location.new(name: declaration.name, path: declaration.path),
95
+ why_it_matters: why_for(declaration), suggested_next_moves: suggested_next_moves_for(declaration) }
96
+ end
97
+
98
+ def message_for(declaration, reference_set)
99
+ "#{declaration.name} is referenced from #{reference_set.files.size} files across " \
100
+ "#{reference_set.packages.size} packages outside #{home_namespace_for(declaration.name)}; " \
101
+ "review whether callers know too much about this namespace."
102
+ end
103
+
104
+ def metadata_for(declaration, reference_set)
105
+ Metadata.for(declaration, reference_set, namespace_leak_category_for(declaration))
106
+ end
107
+
108
+ def home_namespace_for(name)
109
+ Namespace.new(name).home_name
110
+ end
111
+
112
+ def triage_attributes_for(declaration)
113
+ return SharedNamespaceTriage.attributes(PROJECT_ANALYZER_STATUS) if shared_namespace?(declaration)
114
+
115
+ project_analyzer_triage_attributes
116
+ end
117
+
118
+ def namespace_leak_category_for(declaration)
119
+ shared_namespace?(declaration) ? SharedNamespaceTriage::CATEGORY : "namespace_boundary"
120
+ end
121
+
122
+ def why_for(declaration)
123
+ shared_namespace?(declaration) ? SharedNamespaceTriage::WHY : WHY
124
+ end
125
+
126
+ def suggested_next_moves_for(declaration)
127
+ shared_namespace?(declaration) ? SharedNamespaceTriage::SUGGESTED_NEXT_MOVES : SUGGESTED_NEXT_MOVES
128
+ end
129
+
130
+ def shared_namespace?(declaration)
131
+ SharedNamespaceTriage.shared?(declaration)
132
+ end
133
+
134
+ def ignored_declaration_name?(name)
135
+ IGNORED_DECLARATION_NAMES.include?(name) || name.include?(SYNTHETIC_DECLARATION_MARKER)
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ FALLBACK_LINE = 1
6
+
7
+ Occurrence = Struct.new(:path, :line, :context, keyword_init: true) do
8
+ def self.from(value, context: nil)
9
+ return value if value.is_a?(self)
10
+ return nil unless value.respond_to?(:path) && value.path
11
+
12
+ new(path: value.path, line: line_for(value), context: context)
13
+ end
14
+
15
+ def self.line_for(value)
16
+ value.line if value.respond_to?(:line)
17
+ end
18
+
19
+ def report_line
20
+ line || MetzScan::Analyzers::FALLBACK_LINE
21
+ end
22
+
23
+ def line_source
24
+ line ? "source" : "fallback"
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../occurrence"
4
+
5
+ module MetzScan
6
+ module Analyzers
7
+ class PackageDependencyPressure
8
+ Finding = Struct.new(:source, :rule_id, :message, :declaration_name, :declared_package,
9
+ :referring_files, :referring_packages, :references, :primary_location,
10
+ :why_it_matters, :project_analyzer_status, :confidence, :triage_severity,
11
+ :triage_summary, :project_analyzer_metadata, :suggested_next_moves,
12
+ 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,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../cross_package_reference_metadata"
4
+
5
+ module MetzScan
6
+ module Analyzers
7
+ class PackageDependencyPressure
8
+ module Metadata
9
+ module_function
10
+
11
+ def for(declaration, reference_set, dependency_pressure_category)
12
+ CrossPackageReferenceMetadata.counts(reference_set).merge(
13
+ dependency_metadata(declaration, reference_set, dependency_pressure_category)
14
+ )
15
+ end
16
+
17
+ def dependency_metadata(declaration, reference_set, dependency_pressure_category)
18
+ { "declaration" => CrossPackageReferenceMetadata.declaration_metadata(declaration),
19
+ "declared_package" => PackageMap.package_for(declaration.path),
20
+ "project_analyzer_category" => dependency_pressure_category,
21
+ "dependency_pressure_category" => dependency_pressure_category,
22
+ "references" => CrossPackageReferenceMetadata.references_metadata(reference_set) }
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../package_map"
4
+
5
+ module MetzScan
6
+ module Analyzers
7
+ class PackageDependencyPressure
8
+ PackageMap = MetzScan::Analyzers::PackageMap
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ class PackageDependencyPressure
6
+ class ReferenceCollector
7
+ def initialize(index)
8
+ @index = index
9
+ end
10
+
11
+ def for(declaration)
12
+ declared_package = PackageMap.package_for(declaration.path)
13
+ index.constant_references_to(declaration.name).filter_map do |reference|
14
+ counted_reference_for(reference, declaration.path, declared_package)
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :index
21
+
22
+ def counted_reference_for(reference, declaration_path, declared_package)
23
+ return if same_path?(reference.path, declaration_path)
24
+ return if PackageMap.ignored_path?(reference.path)
25
+
26
+ package = PackageMap.package_for(reference.path)
27
+ return if !package || package == declared_package
28
+
29
+ Reference.new(path: reference.path, line: reference.line, column: reference.column, package: package)
30
+ end
31
+
32
+ def same_path?(left, right)
33
+ File.expand_path(left) == File.expand_path(right)
34
+ end
35
+ end
36
+ end
37
+ end
38
+ 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 PackageDependencyPressure
8
+ class ReferenceSet < CrossPackageReferenceSet
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ class PackageDependencyPressure
6
+ module SharedDependencyTriage
7
+ CATEGORY = "shared_dependency"
8
+ CONFIDENCE = "low"
9
+ TRIAGE_SEVERITY = "shared dependency"
10
+ TRIAGE_SUMMARY = "Shared dependency signal; review broad APIs or infrastructure hubs only when " \
11
+ "they change often or force package-specific callers to know too much."
12
+ WHY = "Broad shared dependencies are often intentional, but they can still reveal global APIs " \
13
+ "that many packages must understand."
14
+ SUGGESTED_NEXT_MOVES = [
15
+ "Leave stable shared APIs alone when callers use a narrow, intentional interface.",
16
+ "Extract package-owned adapters when broad APIs force repeated package-specific knowledge."
17
+ ].freeze
18
+ SHARED_NAME_SEGMENTS = "Config(?:uration)?|Settings?|Current|Core|Events?|Types?|" \
19
+ "Feature(?:Flag|Toggle)s?|Permissions?|Utils?|Utilities?"
20
+ NAME_SEGMENT_PATTERN =
21
+ /\A(?:#{SHARED_NAME_SEGMENTS})\z/i
22
+ ERROR_SUFFIX_PATTERN = /(?:Error|Exception)\z/
23
+ INFRASTRUCTURE_LIB_SEGMENT_PATTERN =
24
+ /\A(?:cache|caches|errors?|exceptions?|freedom_patches|rate_limiter|redis|scheduler)\z/i
25
+ VALUE_OBJECT_SEGMENT_PATTERN = /\A(?:Amount|Currency|Money|Price)\z/
26
+ PROTOCOL_MANAGER_SUFFIX_PATTERN = /(?:Manager|Registry|Resolver|Router)\z/
27
+ private_constant :SHARED_NAME_SEGMENTS, :NAME_SEGMENT_PATTERN, :ERROR_SUFFIX_PATTERN,
28
+ :INFRASTRUCTURE_LIB_SEGMENT_PATTERN, :VALUE_OBJECT_SEGMENT_PATTERN,
29
+ :PROTOCOL_MANAGER_SUFFIX_PATTERN
30
+
31
+ module_function
32
+
33
+ def shared?(declaration)
34
+ shared_name?(declaration.name) || shared_path?(declaration.path) || shared_surface?(declaration)
35
+ end
36
+
37
+ def attributes(status)
38
+ { project_analyzer_status: status, confidence: CONFIDENCE,
39
+ triage_severity: TRIAGE_SEVERITY, triage_summary: TRIAGE_SUMMARY }
40
+ end
41
+
42
+ def shared_name?(name)
43
+ segments = name_segments(name)
44
+ segments.any? { |segment| segment.match?(NAME_SEGMENT_PATTERN) } ||
45
+ constant_like_tail?(segments.last) || error_like_tail?(segments.last)
46
+ end
47
+
48
+ def shared_path?(path)
49
+ package_parts = PackageMap.package_parts(path)
50
+ return false unless package_parts.first == "lib"
51
+
52
+ root_lib_file?(package_parts) || infrastructure_lib_segment?(package_parts)
53
+ end
54
+
55
+ def shared_surface?(declaration)
56
+ conventional_domain_model?(declaration) ||
57
+ conventional_value_object?(declaration) ||
58
+ protocol_manager_surface?(declaration)
59
+ end
60
+
61
+ def name_segments(name)
62
+ name.to_s.split("::")
63
+ end
64
+
65
+ def constant_like_tail?(segment)
66
+ segment.to_s.match?(/\A[A-Z][A-Z0-9_]*\z/) && segment.to_s.include?("_")
67
+ end
68
+
69
+ def error_like_tail?(segment)
70
+ segment.to_s.match?(ERROR_SUFFIX_PATTERN)
71
+ end
72
+
73
+ def root_lib_file?(package_parts)
74
+ package_parts[1].to_s.end_with?(".rb")
75
+ end
76
+
77
+ def infrastructure_lib_segment?(package_parts)
78
+ File.basename(package_parts[1].to_s, ".rb").match?(INFRASTRUCTURE_LIB_SEGMENT_PATTERN)
79
+ end
80
+
81
+ def conventional_domain_model?(declaration)
82
+ PackageMap.package_for(declaration.path) == "app/models" &&
83
+ conventional_declaration_path?(declaration)
84
+ end
85
+
86
+ def conventional_value_object?(declaration)
87
+ PackageMap.package_for(declaration.path).to_s.start_with?("lib/") &&
88
+ name_segments(declaration.name).last.to_s.match?(VALUE_OBJECT_SEGMENT_PATTERN) &&
89
+ conventional_declaration_path?(declaration)
90
+ end
91
+
92
+ def protocol_manager_surface?(declaration)
93
+ PackageMap.package_for(declaration.path) == "app/lib" &&
94
+ name_segments(declaration.name).last.to_s.match?(PROTOCOL_MANAGER_SUFFIX_PATTERN) &&
95
+ declaration_path_matches_tail?(declaration)
96
+ end
97
+
98
+ def conventional_declaration_path?(declaration)
99
+ normalized_path(declaration.path).end_with?(conventional_path_suffix(declaration.name))
100
+ end
101
+
102
+ def declaration_path_matches_tail?(declaration)
103
+ normalized_path(declaration.path).end_with?("#{underscore(name_segments(declaration.name).last)}.rb")
104
+ end
105
+
106
+ def conventional_path_suffix(name)
107
+ "#{name_segments(name).map { |segment| underscore(segment) }.join('/')}.rb"
108
+ end
109
+
110
+ def underscore(segment)
111
+ segment.to_s
112
+ .gsub(/([A-Z\d]+)([A-Z][a-z])/, '\\1_\\2')
113
+ .gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
114
+ .tr("-", "_")
115
+ .downcase
116
+ end
117
+
118
+ def normalized_path(path)
119
+ path.to_s.split(File::SEPARATOR).join("/")
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end