metz-scan 0.5.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/bin/metz-scan +10 -0
- data/lib/metz_scan/analyzers/contextual_node_walker.rb +82 -0
- data/lib/metz_scan/analyzers/cross_package_reference_metadata.rb +36 -0
- data/lib/metz_scan/analyzers/cross_package_reference_set.rb +33 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_collector.rb +54 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/ambient_context_reference.rb +37 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/current_attribute_collector.rb +73 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/reference_set.rb +37 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/thread_current_collector.rb +76 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure/triage.rb +109 -0
- data/lib/metz_scan/analyzers/implicit_context_pressure.rb +139 -0
- data/lib/metz_scan/analyzers/inheritance_descendants/finding.rb +18 -0
- data/lib/metz_scan/analyzers/inheritance_descendants/metadata.rb +23 -0
- data/lib/metz_scan/analyzers/inheritance_descendants/root_kind.rb +58 -0
- data/lib/metz_scan/analyzers/inheritance_descendants/triage.rb +30 -0
- data/lib/metz_scan/analyzers/inheritance_descendants.rb +137 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/finding.rb +20 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/metadata.rb +40 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/namespace.rb +41 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_collector.rb +50 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/reference_set.rb +12 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage/exception_declaration.rb +117 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure/shared_namespace_triage.rb +69 -0
- data/lib/metz_scan/analyzers/namespace_leak_pressure.rb +139 -0
- data/lib/metz_scan/analyzers/occurrence.rb +28 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/finding.rb +20 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/metadata.rb +27 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/package_map.rb +11 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/reference_collector.rb +38 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/reference_set.rb +12 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure/shared_dependency_triage.rb +124 -0
- data/lib/metz_scan/analyzers/package_dependency_pressure.rb +139 -0
- data/lib/metz_scan/analyzers/package_map.rb +81 -0
- data/lib/metz_scan/analyzers/project_analyzer_triage.rb +20 -0
- data/lib/metz_scan/analyzers/repeated_branching/branch_site_collector.rb +138 -0
- data/lib/metz_scan/analyzers/repeated_branching/branch_value.rb +27 -0
- data/lib/metz_scan/analyzers/repeated_branching/contextual_node_walker.rb +11 -0
- data/lib/metz_scan/analyzers/repeated_branching/decision_subject.rb +46 -0
- data/lib/metz_scan/analyzers/repeated_branching/predicate_chain.rb +58 -0
- data/lib/metz_scan/analyzers/repeated_branching/triage.rb +28 -0
- data/lib/metz_scan/analyzers/repeated_branching.rb +135 -0
- data/lib/metz_scan/analyzers/repeated_query_criteria/triage.rb +103 -0
- data/lib/metz_scan/analyzers/repeated_query_criteria.rb +285 -0
- data/lib/metz_scan/analyzers/ruby_file_enumerator.rb +37 -0
- data/lib/metz_scan/analyzers/service_soup/service_call_pattern.rb +53 -0
- data/lib/metz_scan/analyzers/service_soup/setup_workflow_triage.rb +48 -0
- data/lib/metz_scan/analyzers/service_soup/workflow_collector.rb +130 -0
- data/lib/metz_scan/analyzers/service_soup.rb +128 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure/family_builder.rb +69 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure/finding.rb +30 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure/metadata.rb +74 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure/method_body_facts.rb +100 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure/triage.rb +106 -0
- data/lib/metz_scan/analyzers/subclass_override_pressure.rb +133 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/artifact_writer.rb +51 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/baseline_delta.rb +231 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/collaborators.rb +76 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/markdown_renderer.rb +353 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/readiness_catalog.rb +123 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/summary.rb +333 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner/target_set.rb +139 -0
- data/lib/metz_scan/calibration/project_analyzer_evidence_runner.rb +64 -0
- data/lib/metz_scan/calibration/rubydex_drift_formatter.rb +91 -0
- data/lib/metz_scan/cli.rb +120 -0
- data/lib/metz_scan/commands/explain.rb +128 -0
- data/lib/metz_scan/commands/project_analyzers.rb +105 -0
- data/lib/metz_scan/commands/report.rb +115 -0
- data/lib/metz_scan/commands/rules.rb +92 -0
- data/lib/metz_scan/commands/scan/auto_fix.rb +138 -0
- data/lib/metz_scan/commands/scan/compliance_scorecard.rb +124 -0
- data/lib/metz_scan/commands/scan/github_annotations_renderer.rb +55 -0
- data/lib/metz_scan/commands/scan/offense_extractor.rb +42 -0
- data/lib/metz_scan/commands/scan/project_analyzer_breakdown.rb +55 -0
- data/lib/metz_scan/commands/scan/project_analyzer_metadata.rb +78 -0
- data/lib/metz_scan/commands/scan/project_analyzer_offenses.rb +85 -0
- data/lib/metz_scan/commands/scan/project_analyzer_runner.rb +148 -0
- data/lib/metz_scan/commands/scan/project_analyzer_summary_aggregate_formatter.rb +74 -0
- data/lib/metz_scan/commands/scan/project_analyzer_summary_breakdown_formatter.rb +46 -0
- data/lib/metz_scan/commands/scan/project_analyzer_triage_formatter.rb +40 -0
- data/lib/metz_scan/commands/scan/project_analyzer_triage_priority.rb +38 -0
- data/lib/metz_scan/commands/scan/project_config_scope.rb +231 -0
- data/lib/metz_scan/commands/scan/runner.rb +199 -0
- data/lib/metz_scan/commands/scan/sarif_renderer.rb +103 -0
- data/lib/metz_scan/commands/scan/sarif_rule_descriptors.rb +82 -0
- data/lib/metz_scan/commands/scan/sarif_severity.rb +27 -0
- data/lib/metz_scan/commands/scan/target_ruby_version.rb +56 -0
- data/lib/metz_scan/commands/scan/text_renderer.rb +145 -0
- data/lib/metz_scan/commands/scan.rb +201 -0
- data/lib/metz_scan/project_index/null_backend.rb +35 -0
- data/lib/metz_scan/project_index/rubydex_backend/file_discovery.rb +33 -0
- data/lib/metz_scan/project_index/rubydex_backend/location_formatting.rb +36 -0
- data/lib/metz_scan/project_index/rubydex_backend/method_declarations.rb +118 -0
- data/lib/metz_scan/project_index/rubydex_backend.rb +133 -0
- data/lib/metz_scan/project_index.rb +109 -0
- data/lib/metz_scan/version.rb +5 -0
- data/lib/metz_scan.rb +5 -0
- data/metz-scan.gemspec +41 -0
- metadata +160 -0
|
@@ -0,0 +1,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,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,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
|