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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 52d0e1b2249535049c9a049fe4c8955bd0e6478e0c1b852f52a1cece489ac6d8
4
+ data.tar.gz: 6577bb1134e0e78115148bfb130fcd0d236410080917cc5203f3bd199a088970
5
+ SHA512:
6
+ metadata.gz: 9290ef1235939702c2a31c7588bc5a8302f2ca994b75e2b39998fe669941094be0cf0eb58bb50cd4e504555167e1e34767ec96524644c5c6aa9859e1efb0a2ba
7
+ data.tar.gz: a7f3bc181cd224ed873ff5f991b7bff30a320c59b741dd82dd8ef87f99b32cd845e5b6d128ab46593235e06e53963488f02e025b32af3f6f7e4f180bba9377f0
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Salvador Fuentes Jr
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ module MetzScan
6
+ module Analyzers
7
+ class ContextualNodeWalker
8
+ ContextualNode = Struct.new(:node, :enclosing_name, :method_name, keyword_init: true)
9
+
10
+ Context = Struct.new(:namespace, :method_name, keyword_init: true) do
11
+ def self.root = new(namespace: [], method_name: nil)
12
+
13
+ def enclosing_name = namespace.join("::").then { |name| name unless name.empty? }
14
+
15
+ def with_namespace(name) = self.class.new(namespace: namespace + [name].compact, method_name: nil)
16
+
17
+ def with_method(name) = self.class.new(namespace: namespace, method_name: name)
18
+ end
19
+
20
+ def initialize(root)
21
+ @root = root
22
+ end
23
+
24
+ def nodes
25
+ collect(root, Context.root, [])
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :root
31
+
32
+ def collect(node, context, nodes)
33
+ return nodes unless node
34
+ return collect_scope(node, context, nodes) if scope_node?(node)
35
+
36
+ collect_current(node, context, nodes)
37
+ collect_children(node, context, nodes)
38
+ end
39
+
40
+ def collect_scope(node, context, nodes)
41
+ return collect_namespace(node, context, nodes) if namespace_node?(node)
42
+ return collect_method(node, context, nodes) if node.type == :def
43
+
44
+ collect_singleton_method(node, context, nodes)
45
+ end
46
+
47
+ def collect_namespace(node, context, nodes)
48
+ collect(node.children.last, context.with_namespace(constant_name(node.children.first)), nodes)
49
+ end
50
+
51
+ def collect_method(node, context, nodes)
52
+ collect(node.children[2], context.with_method("##{node.method_name}"), nodes)
53
+ end
54
+
55
+ def collect_singleton_method(node, context, nodes)
56
+ collect(node.children[3], context.with_method(".#{node.method_name}"), nodes)
57
+ end
58
+
59
+ def collect_current(node, context, nodes)
60
+ nodes << ContextualNode.new(node: node, enclosing_name: context.enclosing_name,
61
+ method_name: context.method_name)
62
+ end
63
+
64
+ def collect_children(node, context, nodes)
65
+ node.children.grep(RuboCop::AST::Node).each { |child| collect(child, context, nodes) }
66
+ nodes
67
+ end
68
+
69
+ def scope_node?(node)
70
+ namespace_node?(node) || %i[def defs].include?(node.type)
71
+ end
72
+
73
+ def namespace_node?(node)
74
+ %i[class module].include?(node.type)
75
+ end
76
+
77
+ def constant_name(node)
78
+ node.source if node&.type == :const
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ module CrossPackageReferenceMetadata
6
+ module_function
7
+
8
+ def counts(reference_set)
9
+ { "referring_file_count" => reference_set.files.size,
10
+ "referring_package_count" => reference_set.packages.size,
11
+ "referring_packages" => reference_set.packages,
12
+ "reference_shape" => reference_shape(reference_set) }
13
+ end
14
+
15
+ def reference_shape(reference_set)
16
+ { "referring_file_count" => reference_set.files.size,
17
+ "referring_package_count" => reference_set.packages.size,
18
+ "referring_package_roots" => reference_set.package_roots,
19
+ "referring_package_leafs" => reference_set.package_leafs }
20
+ end
21
+
22
+ def declaration_metadata(declaration)
23
+ { "name" => declaration.name, "kind" => declaration.kind.to_s, "path" => declaration.path }.compact
24
+ end
25
+
26
+ def references_metadata(reference_set)
27
+ reference_set.references.map { |reference| reference_metadata(reference) }
28
+ end
29
+
30
+ def reference_metadata(reference)
31
+ { "path" => reference.path, "line" => reference.line, "column" => reference.column,
32
+ "package" => reference.package }.compact
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ class CrossPackageReferenceSet
6
+ def initialize(references)
7
+ @references = references
8
+ end
9
+
10
+ attr_reader :references
11
+
12
+ def files
13
+ references.map(&:path).uniq.sort
14
+ end
15
+
16
+ def packages
17
+ references.map(&:package).uniq.sort
18
+ end
19
+
20
+ def package_roots
21
+ packages.map { |package| package.to_s.split("/").first }.uniq.sort
22
+ end
23
+
24
+ def package_leafs
25
+ packages.map { |package| package.to_s.split("/").last }.uniq.sort
26
+ end
27
+
28
+ def enough?(minimum_files, minimum_packages)
29
+ files.size >= minimum_files && packages.size >= minimum_packages
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ require_relative "../contextual_node_walker"
6
+ require_relative "ambient_context_reference"
7
+ require_relative "current_attribute_collector"
8
+ require_relative "thread_current_collector"
9
+
10
+ module MetzScan
11
+ module Analyzers
12
+ class ImplicitContextPressure
13
+ class AmbientContextCollector
14
+ def initialize(path)
15
+ @path = path
16
+ end
17
+
18
+ def call
19
+ contextual_nodes.flat_map { |contextual_node| references_for(contextual_node) }
20
+ rescue Parser::SyntaxError
21
+ []
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :path
27
+
28
+ def references_for(contextual_node)
29
+ reference_collectors.filter_map { |collector| collector.reference_for(contextual_node) }
30
+ end
31
+
32
+ def reference_collectors
33
+ [current_attribute_collector, thread_current_collector]
34
+ end
35
+
36
+ def current_attribute_collector
37
+ @current_attribute_collector ||= CurrentAttributeCollector.new(path)
38
+ end
39
+
40
+ def thread_current_collector
41
+ @thread_current_collector ||= ThreadCurrentCollector.new(path)
42
+ end
43
+
44
+ def processed_source
45
+ RuboCop::ProcessedSource.new(File.read(path), RUBY_VERSION.to_f)
46
+ end
47
+
48
+ def contextual_nodes
49
+ ContextualNodeWalker.new(processed_source.ast).nodes
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ class ImplicitContextPressure
6
+ class AmbientContextCollector
7
+ Reference = Struct.new(:ambient_context, :kind, :context_key, :attribute, :access_mode,
8
+ :enclosing_name, :method_name, :path, :line, :expression,
9
+ keyword_init: true) do
10
+ def context
11
+ return "#{enclosing_name}#{method_name}" if enclosing_name && method_name
12
+
13
+ method_name || enclosing_name
14
+ end
15
+ end
16
+
17
+ module ReferenceMetadata
18
+ private
19
+
20
+ attr_reader :path
21
+
22
+ def context_attributes(contextual_node)
23
+ { enclosing_name: contextual_node.enclosing_name, method_name: contextual_node.method_name }
24
+ end
25
+
26
+ def location_attributes(node)
27
+ { path: path, line: node.loc.expression.line, expression: first_line(node) }
28
+ end
29
+
30
+ def first_line(node)
31
+ node.loc.expression.source.lines.first.strip
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ class ImplicitContextPressure
6
+ class AmbientContextCollector
7
+ class CurrentAttributeCollector
8
+ include ReferenceMetadata
9
+
10
+ ATTRIBUTE_METHOD = /\A[a-z_]\w*=?\z/
11
+ IGNORED_CURRENT_METHODS = %w[after_reset attributes before_reset reset resets set].freeze
12
+ private_constant :ATTRIBUTE_METHOD, :IGNORED_CURRENT_METHODS
13
+
14
+ def initialize(path)
15
+ @path = path
16
+ end
17
+
18
+ def reference_for(contextual_node)
19
+ node = contextual_node.node
20
+ return unless current_attribute_reference?(node)
21
+
22
+ Reference.new(reference_attributes(contextual_node))
23
+ end
24
+
25
+ private
26
+
27
+ def reference_attributes(contextual_node)
28
+ node = contextual_node.node
29
+ current_attribute_attributes(node)
30
+ .merge(context_attributes(contextual_node))
31
+ .merge(location_attributes(node))
32
+ end
33
+
34
+ def current_attribute_attributes(node)
35
+ attribute = attribute_name(node.method_name)
36
+ { ambient_context: "#{node.receiver.source}.#{attribute}", kind: "current_attributes",
37
+ context_key: attribute, attribute: attribute, access_mode: access_mode(node.method_name) }
38
+ end
39
+
40
+ def current_attribute_reference?(node)
41
+ node.type == :send && current_receiver?(node.receiver) && attribute_method?(node)
42
+ end
43
+
44
+ def current_receiver?(node)
45
+ node&.type == :const && node.source.split("::").last == "Current"
46
+ end
47
+
48
+ def attribute_method?(node)
49
+ method_name = node.method_name.to_s
50
+ method_name.match?(ATTRIBUTE_METHOD) && counted_current_method?(method_name, node.arguments)
51
+ end
52
+
53
+ def counted_current_method?(method_name, arguments)
54
+ !IGNORED_CURRENT_METHODS.include?(attribute_name(method_name)) &&
55
+ valid_attribute_argument_count?(method_name, arguments)
56
+ end
57
+
58
+ def valid_attribute_argument_count?(method_name, arguments)
59
+ method_name.end_with?("=") ? arguments.size == 1 : arguments.empty?
60
+ end
61
+
62
+ def attribute_name(method_name)
63
+ method_name.to_s.delete_suffix("=")
64
+ end
65
+
66
+ def access_mode(method_name)
67
+ method_name.to_s.end_with?("=") ? "write" : "read"
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../package_map"
4
+
5
+ module MetzScan
6
+ module Analyzers
7
+ class ImplicitContextPressure
8
+ class ReferenceSet
9
+ def initialize(references)
10
+ @references = references.sort_by { |reference| [reference.path, reference.line.to_i] }
11
+ end
12
+
13
+ attr_reader :references
14
+
15
+ def referring_files
16
+ references.map(&:path).uniq
17
+ end
18
+
19
+ def referring_packages
20
+ references.map { |reference| PackageMap.package_for(reference.path) }.compact.uniq.sort
21
+ end
22
+
23
+ def access_modes
24
+ references.map(&:access_mode).uniq.sort
25
+ end
26
+
27
+ def context_kind
28
+ references.first.kind
29
+ end
30
+
31
+ def context_key
32
+ references.first.context_key
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ class ImplicitContextPressure
6
+ class AmbientContextCollector
7
+ class ThreadCurrentCollector
8
+ include ReferenceMetadata
9
+
10
+ THREAD_KEY_READ = :[]
11
+ THREAD_KEY_WRITE = :[]=
12
+ private_constant :THREAD_KEY_READ, :THREAD_KEY_WRITE
13
+
14
+ def initialize(path)
15
+ @path = path
16
+ end
17
+
18
+ def reference_for(contextual_node)
19
+ node = contextual_node.node
20
+ return unless thread_current_reference?(node)
21
+
22
+ Reference.new(reference_attributes(contextual_node))
23
+ end
24
+
25
+ private
26
+
27
+ def reference_attributes(contextual_node)
28
+ node = contextual_node.node
29
+ thread_current_attributes(node)
30
+ .merge(context_attributes(contextual_node))
31
+ .merge(location_attributes(node))
32
+ end
33
+
34
+ def thread_current_attributes(node)
35
+ { ambient_context: thread_current_context(node), kind: "thread_current",
36
+ context_key: literal_thread_key(node.arguments.first), access_mode: access_mode(node) }
37
+ end
38
+
39
+ def thread_current_reference?(node)
40
+ thread_key_access?(node) && thread_current_receiver?(node.receiver) &&
41
+ literal_thread_key(node.arguments.first)
42
+ end
43
+
44
+ def thread_key_access?(node)
45
+ return false unless node.type == :send
46
+ return node.arguments.size == 1 if node.method_name == THREAD_KEY_READ
47
+ return node.arguments.size == 2 if node.method_name == THREAD_KEY_WRITE
48
+
49
+ false
50
+ end
51
+
52
+ def thread_current_receiver?(node)
53
+ node&.type == :send && node.method_name == :current &&
54
+ node.arguments.empty? && thread_constant?(node.receiver)
55
+ end
56
+
57
+ def thread_constant?(node)
58
+ node&.type == :const && node.source == "Thread"
59
+ end
60
+
61
+ def literal_thread_key(node)
62
+ node.value.to_s if node && %i[sym str].include?(node.type)
63
+ end
64
+
65
+ def thread_current_context(node)
66
+ "Thread.current[#{node.arguments.first.source}]"
67
+ end
68
+
69
+ def access_mode(node)
70
+ node.method_name == THREAD_KEY_WRITE ? "write" : "read"
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MetzScan
4
+ module Analyzers
5
+ class ImplicitContextPressure
6
+ module Triage
7
+ CATEGORY_TRIAGE = {
8
+ "root_current_read" => {
9
+ triage_summary: "Candidate application Current signal; review whether callers should receive context.",
10
+ why: "Application-wide Current reads hide dependencies from method signatures and make workflows " \
11
+ "harder to reason about.",
12
+ suggested_next_moves: [
13
+ "Pass the context value explicitly into collaborators that use it.",
14
+ "Keep request-scoped globals at application boundaries when possible."
15
+ ]
16
+ },
17
+ "root_current_write" => {
18
+ triage_summary: "Candidate mutable application Current signal; review whether writes should stay at " \
19
+ "request boundaries.",
20
+ why: "Mutable application Current access hides both dependency and mutation order from the callers " \
21
+ "that depend on it.",
22
+ suggested_next_moves: [
23
+ "Keep Current writes near request, job, or tenant setup boundaries.",
24
+ "Pass context explicitly once domain collaborators need the value."
25
+ ]
26
+ },
27
+ "namespaced_current_read" => {
28
+ triage_summary: "Candidate namespaced Current signal; review whether the namespace is leaking ambient " \
29
+ "context.",
30
+ why: "Namespaced Current reads can make a package depend on ambient state owned by another namespace.",
31
+ suggested_next_moves: [
32
+ "Pass the namespaced context value into collaborators that use it repeatedly.",
33
+ "Keep namespace-owned Current access behind the namespace boundary when possible."
34
+ ]
35
+ },
36
+ "namespaced_current_write" => {
37
+ triage_summary: "Candidate namespaced Current signal with writes; review whether mutation should stay " \
38
+ "at namespace boundaries.",
39
+ why: "Namespaced Current writes spread mutation order across packages and make the ambient context " \
40
+ "harder to audit.",
41
+ suggested_next_moves: [
42
+ "Keep namespaced Current writes near the boundary that establishes the namespace context.",
43
+ "Pass explicit context to downstream collaborators instead of writing ambient state mid-workflow."
44
+ ]
45
+ },
46
+ "thread_current_read" => {
47
+ triage_summary: "Candidate Thread.current signal; review whether thread-local dependencies should be " \
48
+ "explicit.",
49
+ why: "Repeated thread-local reads hide dependencies from method signatures and make context setup " \
50
+ "harder to audit.",
51
+ suggested_next_moves: [
52
+ "Pass the thread-local value explicitly into collaborators that use it repeatedly.",
53
+ "Keep thread-local reads at framework, request, or instrumentation boundaries when possible."
54
+ ]
55
+ },
56
+ "thread_current_write" => {
57
+ triage_summary: "Candidate mutable Thread.current signal; review whether thread-local writes should " \
58
+ "stay at setup boundaries.",
59
+ why: "Repeated thread-local writes hide mutation order in ambient state and make cleanup requirements " \
60
+ "harder to audit.",
61
+ suggested_next_moves: [
62
+ "Keep Thread.current writes near setup/teardown boundaries that own the thread-local lifecycle.",
63
+ "Pass explicit context to downstream collaborators instead of mutating thread-local state mid-flow."
64
+ ]
65
+ }
66
+ }.freeze
67
+ private_constant :CATEGORY_TRIAGE
68
+
69
+ private
70
+
71
+ def implicit_context_category_for(ambient_context, grouped)
72
+ return "thread_current_#{access_category_for(grouped)}" if grouped.context_kind == "thread_current"
73
+
74
+ "#{current_receiver_scope_for(ambient_context)}_current_#{access_category_for(grouped)}"
75
+ end
76
+
77
+ def current_receiver_scope_for(ambient_context)
78
+ ambient_context.include?("::Current.") ? "namespaced" : "root"
79
+ end
80
+
81
+ def current_attribute_for(ambient_context)
82
+ ambient_context.split(".").last
83
+ end
84
+
85
+ def access_category_for(grouped)
86
+ grouped.access_modes.include?("write") ? "write" : "read"
87
+ end
88
+
89
+ def access_phrase(grouped)
90
+ return "read and written" if mixed_access?(grouped)
91
+ return "written" if grouped.access_modes.include?("write")
92
+
93
+ "read"
94
+ end
95
+
96
+ def mixed_access?(grouped)
97
+ grouped.access_modes.include?("read") && grouped.access_modes.include?("write")
98
+ end
99
+
100
+ def category_triage_attributes(category)
101
+ profile = CATEGORY_TRIAGE.fetch(category)
102
+
103
+ { triage_summary: profile.fetch(:triage_summary), why_it_matters: profile.fetch(:why),
104
+ suggested_next_moves: profile.fetch(:suggested_next_moves) }
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end