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.
- checksums.yaml +7 -0
- data/LICENSE +21 -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
- metadata +185 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class RepeatedQueryCriteria
|
|
6
|
+
module Triage
|
|
7
|
+
CATEGORY_TRIAGE = {
|
|
8
|
+
"polymorphic_where_criteria" => {
|
|
9
|
+
triage_summary: "Candidate polymorphic query signal; review whether polymorphic lookup rules need a " \
|
|
10
|
+
"name.",
|
|
11
|
+
why: "Repeated polymorphic query criteria spread type/id coupling across callers and make policy " \
|
|
12
|
+
"changes harder to find.",
|
|
13
|
+
suggested_next_moves: [
|
|
14
|
+
"Extract a named scope or query object when the polymorphic lookup is a domain concept.",
|
|
15
|
+
"Keep inline polymorphic criteria only when the repetition is mechanical and local."
|
|
16
|
+
]
|
|
17
|
+
},
|
|
18
|
+
"compound_association_where_criteria" => {
|
|
19
|
+
triage_summary: "Candidate compound association query signal; review whether association pairs need a " \
|
|
20
|
+
"named query.",
|
|
21
|
+
why: "Repeated compound association criteria can hide a relationship rule in several callers instead " \
|
|
22
|
+
"of behind one named query.",
|
|
23
|
+
suggested_next_moves: [
|
|
24
|
+
"Extract a named scope when the association pair represents a reusable relationship.",
|
|
25
|
+
"Keep simple join-table lookups inline when the criteria are purely mechanical."
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"scoped_association_where_criteria" => {
|
|
29
|
+
triage_summary: "Candidate scoped association query signal; review whether scoped criteria belong " \
|
|
30
|
+
"behind a named query.",
|
|
31
|
+
why: "Repeated association-scoped criteria spread filtering rules across callers and make policy " \
|
|
32
|
+
"changes harder to find.",
|
|
33
|
+
suggested_next_moves: [
|
|
34
|
+
"Extract a named scope or query object when the predicate represents a reusable business concept.",
|
|
35
|
+
"Keep one-off lookup criteria inline when repetition is incidental or purely local."
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
"where_hash_criteria" => {
|
|
39
|
+
triage_summary: "Candidate repeated query signal; review whether criteria belong behind a named query.",
|
|
40
|
+
why: "Repeated query predicates spread data-access rules across callers and make policy changes " \
|
|
41
|
+
"harder to find.",
|
|
42
|
+
suggested_next_moves: [
|
|
43
|
+
"Extract a named scope or query object when the predicate represents a reusable business concept.",
|
|
44
|
+
"Keep one-off lookup criteria inline when repetition is incidental or purely local."
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
}.freeze
|
|
48
|
+
MESSAGE_PHRASES = {
|
|
49
|
+
"polymorphic_where_criteria" => "repeats polymorphic query criteria",
|
|
50
|
+
"compound_association_where_criteria" => "repeats compound association query criteria",
|
|
51
|
+
"scoped_association_where_criteria" => "repeats association-scoped query criteria",
|
|
52
|
+
"where_hash_criteria" => "appears"
|
|
53
|
+
}.freeze
|
|
54
|
+
private_constant :CATEGORY_TRIAGE, :MESSAGE_PHRASES
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def repeated_query_category_for(site)
|
|
59
|
+
return "polymorphic_where_criteria" if polymorphic_criteria?(site.criteria_keys)
|
|
60
|
+
return "compound_association_where_criteria" if foreign_key_count(site.criteria_keys) >= 2
|
|
61
|
+
return "scoped_association_where_criteria" if foreign_key_count(site.criteria_keys).positive?
|
|
62
|
+
|
|
63
|
+
"where_hash_criteria"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def polymorphic_criteria?(criteria_keys)
|
|
67
|
+
criteria_keys.any? do |key|
|
|
68
|
+
key.end_with?("_type") && criteria_keys.include?("#{key.delete_suffix('_type')}_id")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def foreign_key_count(criteria_keys)
|
|
73
|
+
criteria_keys.count { |key| key.end_with?("_id") }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def criteria_key_shape_for(site)
|
|
77
|
+
repeated_query_category_for(site).delete_suffix("_where_criteria")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def category_triage_attributes(category)
|
|
81
|
+
profile = CATEGORY_TRIAGE.fetch(category)
|
|
82
|
+
|
|
83
|
+
{ triage_summary: profile.fetch(:triage_summary), why_it_matters: profile.fetch(:why),
|
|
84
|
+
suggested_next_moves: profile.fetch(:suggested_next_moves) }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def query_message_phrase(site, category)
|
|
88
|
+
return "repeats finder query criteria" if site.query_operation == "finder"
|
|
89
|
+
return negative_query_message_phrase(category) if site.query_operation == "negative_filter"
|
|
90
|
+
|
|
91
|
+
MESSAGE_PHRASES.fetch(category)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def negative_query_message_phrase(category)
|
|
95
|
+
{ "polymorphic_where_criteria" => "repeats negative polymorphic query criteria",
|
|
96
|
+
"compound_association_where_criteria" => "repeats negative compound association query criteria",
|
|
97
|
+
"scoped_association_where_criteria" => "repeats negative association-scoped query criteria",
|
|
98
|
+
"where_hash_criteria" => "repeats negative query criteria" }.fetch(category)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
|
|
5
|
+
require_relative "contextual_node_walker"
|
|
6
|
+
require_relative "occurrence"
|
|
7
|
+
require_relative "package_map"
|
|
8
|
+
require_relative "project_analyzer_triage"
|
|
9
|
+
require_relative "repeated_query_criteria/triage"
|
|
10
|
+
require_relative "ruby_file_enumerator"
|
|
11
|
+
|
|
12
|
+
module MetzScan
|
|
13
|
+
module Analyzers
|
|
14
|
+
# Reports repeated hash-style ActiveRecord query predicates across packages.
|
|
15
|
+
class RepeatedQueryCriteria
|
|
16
|
+
include ProjectAnalyzerTriage
|
|
17
|
+
include Triage
|
|
18
|
+
|
|
19
|
+
RULE_ID = "MetzProject/RepeatedQueryCriteria"
|
|
20
|
+
PROJECT_ANALYZER_STATUS = "candidate"
|
|
21
|
+
CONFIDENCE = "medium"
|
|
22
|
+
TRIAGE_SEVERITY = "manual review"
|
|
23
|
+
TRIAGE_SUMMARY = "Candidate repeated query signal; review whether criteria belong behind a named query."
|
|
24
|
+
WHY = "Repeated query predicates spread data-access rules across callers and make policy changes harder to find."
|
|
25
|
+
SUGGESTED_NEXT_MOVES = [
|
|
26
|
+
"Extract a named scope or query object when the predicate represents a reusable business concept.",
|
|
27
|
+
"Keep one-off lookup criteria inline when repetition is incidental or purely local."
|
|
28
|
+
].freeze
|
|
29
|
+
MINIMUM_FILES = 3
|
|
30
|
+
MINIMUM_PACKAGES = 2
|
|
31
|
+
MINIMUM_CRITERIA_KEYS = 2
|
|
32
|
+
Finding = Struct.new(:source, :rule_id, :message, :query, :receiver, :criteria_keys,
|
|
33
|
+
:referring_files, :referring_packages, :occurrences,
|
|
34
|
+
:project_analyzer_status, :confidence, :triage_severity, :triage_summary,
|
|
35
|
+
:project_analyzer_metadata, :why_it_matters, :suggested_next_moves,
|
|
36
|
+
keyword_init: true) do
|
|
37
|
+
def report_occurrences
|
|
38
|
+
occurrence = occurrences.first
|
|
39
|
+
[Occurrence.from(occurrence, context: occurrence&.context)].compact
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def initialize(paths: nil, index: nil, minimum_files: MINIMUM_FILES, minimum_packages: MINIMUM_PACKAGES)
|
|
44
|
+
@paths = Array(paths)
|
|
45
|
+
@index = index
|
|
46
|
+
@minimum_files = minimum_files
|
|
47
|
+
@minimum_packages = minimum_packages
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def call
|
|
51
|
+
query_sites.group_by(&:fingerprint).filter_map do |_fingerprint, sites|
|
|
52
|
+
finding_for(sites)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
attr_reader :paths, :index, :minimum_files, :minimum_packages
|
|
59
|
+
|
|
60
|
+
def query_sites
|
|
61
|
+
ruby_files.flat_map { |path| QuerySiteCollector.new(path).call }
|
|
62
|
+
.reject { |site| PackageMap.ignored_path?(site.path) }
|
|
63
|
+
.select { |site| PackageMap.package_for(site.path) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ruby_files
|
|
67
|
+
RubyFileEnumerator.new(paths: paths, index: index).call
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def finding_for(sites)
|
|
71
|
+
grouped = QuerySiteSet.new(sites)
|
|
72
|
+
return unless grouped.referring_files.size >= minimum_files
|
|
73
|
+
return unless grouped.referring_packages.size >= minimum_packages
|
|
74
|
+
|
|
75
|
+
first = grouped.sites.first
|
|
76
|
+
Finding.new(finding_attributes(first, grouped))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def finding_attributes(first, grouped)
|
|
80
|
+
category = repeated_query_category_for(first)
|
|
81
|
+
|
|
82
|
+
core_finding_attributes(first, grouped, category)
|
|
83
|
+
.merge(project_analyzer_triage_attributes, category_triage_attributes(category))
|
|
84
|
+
.merge(project_analyzer_context_attributes(first, grouped, category))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def core_finding_attributes(first, grouped, category)
|
|
88
|
+
{ source: source_name, rule_id: RULE_ID, message: message_for(first, grouped, category),
|
|
89
|
+
query: first.query, receiver: first.receiver, criteria_keys: first.criteria_keys,
|
|
90
|
+
referring_files: grouped.referring_files, referring_packages: grouped.referring_packages,
|
|
91
|
+
occurrences: grouped.sites }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def project_analyzer_context_attributes(first, grouped, category)
|
|
95
|
+
{ project_analyzer_metadata: project_analyzer_metadata_for(first, grouped, category) }
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def message_for(site, grouped, category)
|
|
99
|
+
"#{site.query} #{query_message_phrase(site, category)} in #{grouped.referring_files.size} files across " \
|
|
100
|
+
"#{grouped.referring_packages.size} packages; consider naming the query criteria."
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def project_analyzer_metadata_for(site, grouped, category)
|
|
104
|
+
query_metadata(site, category).merge(query_reference_metadata(grouped))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def query_metadata(site, category)
|
|
108
|
+
query_identity_metadata(site, category).merge(query_shape_metadata(site))
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def query_identity_metadata(site, category)
|
|
112
|
+
{ "project_analyzer_category" => category, "repeated_query_category" => category, "query" => site.query,
|
|
113
|
+
"query_method" => site.query_method, "query_operation" => site.query_operation }
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def query_shape_metadata(site)
|
|
117
|
+
{ "receiver" => site.receiver, "criteria_keys" => site.criteria_keys,
|
|
118
|
+
"receiver_shape" => site.receiver_shape, "criteria_key_shape" => criteria_key_shape_for(site) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def query_reference_metadata(grouped)
|
|
122
|
+
{ "referring_files" => grouped.referring_files, "referring_packages" => grouped.referring_packages,
|
|
123
|
+
"occurrences" => occurrences_metadata(grouped) }
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def occurrences_metadata(grouped)
|
|
127
|
+
grouped.sites.map { |site| occurrence_metadata(site) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def occurrence_metadata(site)
|
|
131
|
+
{ "path" => site.path, "line" => site.line, "context" => site.context,
|
|
132
|
+
"expression" => site.expression }.compact
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
class QuerySiteSet
|
|
136
|
+
def initialize(sites)
|
|
137
|
+
@sites = sites.sort_by { |site| [site.path, site.line.to_i] }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
attr_reader :sites
|
|
141
|
+
|
|
142
|
+
def referring_files
|
|
143
|
+
sites.map(&:path).uniq
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def referring_packages
|
|
147
|
+
sites.map { |site| PackageMap.package_for(site.path) }.compact.uniq.sort
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
class QuerySiteCollector
|
|
152
|
+
FINDER_QUERY_METHODS = %i[find_by find_or_create_by find_or_initialize_by].freeze
|
|
153
|
+
QuerySite = Struct.new(:receiver, :criteria_keys, :query_method, :query_operation, :enclosing_name,
|
|
154
|
+
:method_name, :path, :line, :expression, keyword_init: true) do
|
|
155
|
+
def query
|
|
156
|
+
"#{receiver}.#{query_method}(#{criteria_keys.join(', ')})"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def receiver_shape
|
|
160
|
+
receiver.include?(".") ? "scope_chain" : "constant"
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def fingerprint
|
|
164
|
+
[receiver, query_method, criteria_keys].join(":")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def context
|
|
168
|
+
return "#{enclosing_name}#{method_name}" if enclosing_name && method_name
|
|
169
|
+
|
|
170
|
+
method_name || enclosing_name
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def initialize(path)
|
|
175
|
+
@path = path
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def call
|
|
179
|
+
contextual_nodes.filter_map { |contextual_node| query_site_for(contextual_node) }
|
|
180
|
+
rescue Parser::SyntaxError
|
|
181
|
+
[]
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
private
|
|
185
|
+
|
|
186
|
+
attr_reader :path
|
|
187
|
+
|
|
188
|
+
def processed_source
|
|
189
|
+
RuboCop::ProcessedSource.new(File.read(path), RUBY_VERSION.to_f)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def contextual_nodes
|
|
193
|
+
ContextualNodeWalker.new(processed_source.ast).nodes
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def receiver_fingerprint_for(node)
|
|
197
|
+
return unless node
|
|
198
|
+
return node.source if node.type == :const
|
|
199
|
+
return unless simple_scope_call?(node)
|
|
200
|
+
|
|
201
|
+
parent = receiver_fingerprint_for(node.receiver)
|
|
202
|
+
"#{parent}.#{node.method_name}" if parent
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def simple_scope_call?(node)
|
|
206
|
+
node.type == :send && node.receiver && node.arguments.empty? &&
|
|
207
|
+
node.method_name.to_s.match?(/\A[a-z_]\w*[!?]?\z/)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def query_site_for(contextual_node)
|
|
211
|
+
node = contextual_node.node
|
|
212
|
+
query_attributes = query_attributes_for(node)
|
|
213
|
+
return unless query_attributes
|
|
214
|
+
|
|
215
|
+
QuerySite.new(query_attributes.merge(context_attributes_for(contextual_node, node)))
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def context_attributes_for(contextual_node, node)
|
|
219
|
+
{ enclosing_name: contextual_node.enclosing_name, method_name: contextual_node.method_name,
|
|
220
|
+
path: path, line: node.loc.expression.line, expression: first_line(node) }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def query_attributes_for(node)
|
|
224
|
+
return unless node.type == :send
|
|
225
|
+
|
|
226
|
+
positive_where_query_attributes(node) ||
|
|
227
|
+
finder_query_attributes(node) ||
|
|
228
|
+
negative_where_query_attributes(node)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def positive_where_query_attributes(node)
|
|
232
|
+
return unless node.method_name == :where
|
|
233
|
+
|
|
234
|
+
query_attributes_for_parts(node.receiver, criteria_keys_for(node), "where", "filter")
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def finder_query_attributes(node)
|
|
238
|
+
return unless FINDER_QUERY_METHODS.include?(node.method_name)
|
|
239
|
+
|
|
240
|
+
query_attributes_for_parts(node.receiver, criteria_keys_for(node), node.method_name.to_s, "finder")
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
def negative_where_query_attributes(node)
|
|
244
|
+
return unless node.method_name == :not
|
|
245
|
+
|
|
246
|
+
receiver_node = empty_where_receiver_for(node)
|
|
247
|
+
return unless receiver_node
|
|
248
|
+
|
|
249
|
+
query_attributes_for_parts(receiver_node.receiver, criteria_keys_for(node), "where.not", "negative_filter")
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def empty_where_receiver_for(node)
|
|
253
|
+
receiver_node = node.receiver
|
|
254
|
+
receiver_node if receiver_node&.type == :send && receiver_node.method_name == :where &&
|
|
255
|
+
receiver_node.arguments.empty?
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def query_attributes_for_parts(receiver_node, criteria_keys, query_method, query_operation)
|
|
259
|
+
receiver = receiver_fingerprint_for(receiver_node)
|
|
260
|
+
return unless receiver
|
|
261
|
+
return unless criteria_keys.size >= MINIMUM_CRITERIA_KEYS
|
|
262
|
+
|
|
263
|
+
{ receiver: receiver, criteria_keys: criteria_keys, query_method: query_method,
|
|
264
|
+
query_operation: query_operation }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def criteria_keys_for(node)
|
|
268
|
+
hash = node.arguments.first
|
|
269
|
+
return [] unless hash&.type == :hash
|
|
270
|
+
return [] unless hash.children.all? { |child| child.type == :pair }
|
|
271
|
+
|
|
272
|
+
hash.children.filter_map { |pair| literal_key(pair.children.first) }.sort
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def literal_key(node)
|
|
276
|
+
node.value.to_s if %i[sym str].include?(node.type)
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
def first_line(node)
|
|
280
|
+
node.loc.expression.source.lines.first.strip
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class RubyFileEnumerator
|
|
6
|
+
RUBY_GLOB = "**/*.rb"
|
|
7
|
+
|
|
8
|
+
def initialize(paths:, index: nil)
|
|
9
|
+
@paths = Array(paths)
|
|
10
|
+
@index = index
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
return ruby_files_for(paths) unless paths.empty?
|
|
15
|
+
return index.indexed_files if index&.available?
|
|
16
|
+
|
|
17
|
+
[]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :paths, :index
|
|
23
|
+
|
|
24
|
+
def ruby_files_for(paths)
|
|
25
|
+
paths.flat_map { |path| ruby_files_under(path) }.uniq.sort
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ruby_files_under(path)
|
|
29
|
+
expanded = File.expand_path(path)
|
|
30
|
+
return Dir.glob(File.join(expanded, RUBY_GLOB)) if File.directory?(expanded)
|
|
31
|
+
return [expanded] if File.file?(expanded) && File.extname(expanded) == ".rb"
|
|
32
|
+
|
|
33
|
+
[]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class ServiceSoup
|
|
6
|
+
class ServiceCallPattern
|
|
7
|
+
Match = Struct.new(:service_name, :style, keyword_init: true)
|
|
8
|
+
CLASS_SERVICE_METHODS = %i[call].freeze
|
|
9
|
+
NEW_SERVICE_METHOD_STYLES = { call: :new_call, perform: :new_perform }.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(node)
|
|
12
|
+
@node = node
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def match
|
|
16
|
+
return class_match if class_service_call?
|
|
17
|
+
return new_match if new_service_call?
|
|
18
|
+
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
attr_reader :node
|
|
25
|
+
|
|
26
|
+
def class_match
|
|
27
|
+
Match.new(service_name: node.receiver.source, style: :class_call)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def new_match
|
|
31
|
+
Match.new(service_name: node.receiver.receiver.source,
|
|
32
|
+
style: NEW_SERVICE_METHOD_STYLES.fetch(node.method_name))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def class_service_call?
|
|
36
|
+
CLASS_SERVICE_METHODS.include?(node.method_name) && constant_receiver?(node.receiver)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def new_service_call?
|
|
40
|
+
NEW_SERVICE_METHOD_STYLES.key?(node.method_name) && new_service_receiver?(node.receiver)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def new_service_receiver?(receiver)
|
|
44
|
+
receiver&.type == :send && receiver.method_name == :new && constant_receiver?(receiver.receiver)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def constant_receiver?(receiver)
|
|
48
|
+
receiver&.type == :const
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MetzScan
|
|
4
|
+
module Analyzers
|
|
5
|
+
class ServiceSoup
|
|
6
|
+
module SetupWorkflowTriage
|
|
7
|
+
CONFIDENCE = "low"
|
|
8
|
+
TRIAGE_SEVERITY = "setup orchestration"
|
|
9
|
+
TRIAGE_SUMMARY = "Setup workflow signal; review only when setup orchestration changes often " \
|
|
10
|
+
"or hides domain workflow pressure."
|
|
11
|
+
WHY = "Setup orchestration often intentionally runs a list of tasks; review only when it " \
|
|
12
|
+
"changes often or hides domain workflow pressure."
|
|
13
|
+
SUGGESTED_NEXT_MOVES = [
|
|
14
|
+
"Leave setup orchestration as a list when it is stable and declarative.",
|
|
15
|
+
"Extract a named workflow only when setup steps change together or need clearer sequencing."
|
|
16
|
+
].freeze
|
|
17
|
+
TOKEN_PATTERN = /\A(?:seeds?|seeders?|setup|install(?:er|ation)?|bootstrap)\z/i
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def setup?(workflow)
|
|
22
|
+
setup_path?(workflow.path) || setup_name?(workflow.name) || setup_method?(workflow.method_name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def attributes(status)
|
|
26
|
+
{ project_analyzer_status: status, confidence: CONFIDENCE,
|
|
27
|
+
triage_severity: TRIAGE_SEVERITY, triage_summary: TRIAGE_SUMMARY }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def setup_path?(path)
|
|
31
|
+
path.to_s.split(%r{[/\\]}).any? { |segment| setup_token?(File.basename(segment, ".rb")) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def setup_name?(name)
|
|
35
|
+
name.to_s.split("::").any? { |segment| setup_token?(segment.delete_prefix("#").delete_prefix(".")) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def setup_method?(method_name)
|
|
39
|
+
setup_token?(method_name.to_s.delete_prefix("#").delete_prefix("."))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def setup_token?(token)
|
|
43
|
+
token.to_s.split(/[_\-.]/).any? { |part| part.match?(TOKEN_PATTERN) }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rubocop"
|
|
4
|
+
|
|
5
|
+
require_relative "service_call_pattern"
|
|
6
|
+
|
|
7
|
+
module MetzScan
|
|
8
|
+
module Analyzers
|
|
9
|
+
class ServiceSoup
|
|
10
|
+
class WorkflowCollector
|
|
11
|
+
Workflow = Struct.new(:name, :enclosing_name, :method_name, :path, :line, :expression, :service_calls,
|
|
12
|
+
keyword_init: true)
|
|
13
|
+
ServiceCall = Struct.new(:service_name, :path, :line, :expression, :style, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
def initialize(path)
|
|
16
|
+
@path = path
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call
|
|
20
|
+
collect_workflows(processed_source.ast, [], [])
|
|
21
|
+
rescue Parser::SyntaxError
|
|
22
|
+
[]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
attr_reader :path
|
|
28
|
+
|
|
29
|
+
def processed_source
|
|
30
|
+
RuboCop::ProcessedSource.new(File.read(path), RUBY_VERSION.to_f)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def collect_workflows(node, namespace, workflows)
|
|
34
|
+
return workflows unless node
|
|
35
|
+
|
|
36
|
+
collect_node(node, namespace, workflows)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def collect_node(node, namespace, workflows)
|
|
40
|
+
return collect_namespace(node, namespace, workflows) if %i[class module].include?(node.type)
|
|
41
|
+
return collect_instance_method(node, namespace, workflows) if node.type == :def
|
|
42
|
+
return collect_singleton_method(node, namespace, workflows) if node.type == :defs
|
|
43
|
+
|
|
44
|
+
collect_child_workflows(node, namespace, workflows)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def collect_namespace(node, namespace, workflows)
|
|
48
|
+
name = constant_name(node.children.first)
|
|
49
|
+
body = node.children.last
|
|
50
|
+
collect_workflows(body, namespace + [name].compact, workflows)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def collect_instance_method(node, namespace, workflows)
|
|
54
|
+
service_calls = service_calls_under(node.children[2])
|
|
55
|
+
workflows << workflow_for(namespace, "##{node.method_name}", node, service_calls) unless service_calls.empty?
|
|
56
|
+
workflows
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def collect_singleton_method(node, namespace, workflows)
|
|
60
|
+
service_calls = service_calls_under(node.children[3])
|
|
61
|
+
workflows << workflow_for(namespace, ".#{node.method_name}", node, service_calls) unless service_calls.empty?
|
|
62
|
+
workflows
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def collect_child_workflows(node, namespace, workflows)
|
|
66
|
+
node.children.grep(RuboCop::AST::Node).each { |child| collect_workflows(child, namespace, workflows) }
|
|
67
|
+
workflows
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def workflow_for(namespace, method_suffix, node, service_calls)
|
|
71
|
+
enclosing_name = namespace.join("::")
|
|
72
|
+
Workflow.new(name: "#{enclosing_name}#{method_suffix}", enclosing_name: optional_name(enclosing_name),
|
|
73
|
+
method_name: method_suffix, path: path, line: node.loc.expression.line,
|
|
74
|
+
expression: first_line(node), service_calls: service_calls)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def service_calls_under(node, service_calls = [])
|
|
78
|
+
return service_calls unless service_search_node?(node)
|
|
79
|
+
|
|
80
|
+
collect_service_call(node, service_calls)
|
|
81
|
+
collect_child_service_calls(node, service_calls)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def nested_scope?(node)
|
|
85
|
+
%i[class module def defs].include?(node.type)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def service_search_node?(node)
|
|
89
|
+
node && !nested_scope?(node)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def collect_service_call(node, service_calls)
|
|
93
|
+
service_call = service_call_for(node)
|
|
94
|
+
service_calls << service_call if service_call
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def collect_child_service_calls(node, service_calls)
|
|
98
|
+
node.children.grep(RuboCop::AST::Node).each { |child| service_calls_under(child, service_calls) }
|
|
99
|
+
service_calls
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def service_call_for(node)
|
|
103
|
+
return unless node.type == :send
|
|
104
|
+
|
|
105
|
+
pattern = ServiceCallPattern.new(node).match
|
|
106
|
+
return unless pattern
|
|
107
|
+
|
|
108
|
+
ServiceCall.new(service_name: pattern.service_name, path: path, line: node.loc.expression.line,
|
|
109
|
+
expression: first_line(node), style: pattern.style)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def constant_receiver?(node)
|
|
113
|
+
node&.type == :const
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def constant_name(node)
|
|
117
|
+
node.source if constant_receiver?(node)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def optional_name(name)
|
|
121
|
+
name unless name.empty?
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def first_line(node)
|
|
125
|
+
node.loc.expression.source.lines.first.strip
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|