necropsy 0.2.0 → 0.3.0
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 +4 -4
- data/CHANGELOG.md +26 -0
- data/MEASUREMENTS.md +27 -0
- data/README.md +182 -17
- data/bench/README.md +92 -0
- data/bench/audit.rb +113 -0
- data/bench/audits/0.2.1/audit.json +501 -0
- data/bench/audits/0.2.1/audit.md +55 -0
- data/bench/audits/0.2.1/baseline_performance.yml +16 -0
- data/bench/audits/0.2.1/config.yml +42 -0
- data/bench/audits/0.2.1/review.yml +56 -0
- data/bench/audits/0.2.1/review_queue.yml +3651 -0
- data/bench/corpora/v1/README.md +30 -0
- data/bench/corpora/v1/labels.yml +37 -0
- data/bench/corpora/v1/manifest.yml +63 -0
- data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
- data/bench/corpora/v1/self.necropsy.yml +8 -0
- data/bench/corpora/v1/tools/debride.yml +42 -0
- data/bench/corpora/v1/tools/spoom.yml +12 -0
- data/bench/corpora/v1/tools/type_aware.yml +11 -0
- data/bench/golden/v1/candidate_union.json +21918 -0
- data/bench/golden/v1/metadata.json +12 -0
- data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
- data/bench/golden/v1/reports/plain_ruby.json +135 -0
- data/bench/golden/v1/reports/rails.json +176 -0
- data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
- data/bench/golden/v1/reports/self.json +3449 -0
- data/bench/review_queue.rb +35 -0
- data/bench/run.rb +31 -0
- data/bench/schema/candidate-union-v1.schema.json +70 -0
- data/docs/impv_implementation_matrix.md +179 -0
- data/docs/migrations/0.2.1.md +57 -0
- data/docs/migrations/0.3.0.md +207 -0
- data/docs/migrations/0.4.0.md +13 -0
- data/docs/necropsy_performance_adr.md +43 -0
- data/docs/necropsy_scope_decisions.md +58 -0
- data/docs/necropsy_type_facts_adr.md +22 -0
- data/gemfiles/prism_min.gemfile +9 -0
- data/gemfiles/prism_min.gemfile.lock +50 -0
- data/lib/necropsy/analyzer.rb +121 -2
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
- data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
- data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
- data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
- data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
- data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
- data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
- data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
- data/lib/necropsy/analyzers/static/cha.rb +33 -73
- data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
- data/lib/necropsy/analyzers/static/rta.rb +237 -29
- data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
- data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
- data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
- data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
- data/lib/necropsy/ast_scanner/references.rb +43 -9
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
- data/lib/necropsy/ast_scanner/traversal.rb +234 -71
- data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
- data/lib/necropsy/ast_scanner.rb +67 -6
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +329 -16
- data/lib/necropsy/bench/finding_facts.rb +152 -0
- data/lib/necropsy/bench/precision_gate.rb +144 -0
- data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
- data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
- data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
- data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
- data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
- data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
- data/lib/necropsy/bench/release_audit.rb +360 -0
- data/lib/necropsy/bench/report_normalizer.rb +140 -0
- data/lib/necropsy/bench/review_queue.rb +154 -0
- data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
- data/lib/necropsy/bench/seed_runner.rb +408 -0
- data/lib/necropsy/bounded_canonicalizer.rb +218 -0
- data/lib/necropsy/cache/scan_cache.rb +85 -17
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +220 -33
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +103 -58
- data/lib/necropsy/configuration.rb +224 -21
- data/lib/necropsy/convention_rules.rb +138 -0
- data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
- data/lib/necropsy/definition_identity.rb +37 -0
- data/lib/necropsy/diagnostics.rb +176 -36
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +111 -10
- data/lib/necropsy/entry_points/rails.rb +322 -41
- data/lib/necropsy/entry_points/test.rb +6 -1
- data/lib/necropsy/flow_interpreter.rb +460 -0
- data/lib/necropsy/graph/blocker_matching.rb +338 -0
- data/lib/necropsy/graph/call_graph.rb +1099 -109
- data/lib/necropsy/graph/definition_index.rb +149 -0
- data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
- data/lib/necropsy/graph/evidence_store.rb +213 -0
- data/lib/necropsy/graph/resolution_store.rb +497 -0
- data/lib/necropsy/graph_self_check.rb +79 -0
- data/lib/necropsy/guardrail/baseline.rb +350 -13
- data/lib/necropsy/guardrail/quarantine.rb +94 -9
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +878 -13
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +327 -25
- data/lib/necropsy/reachability/engine.rb +54 -13
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +113 -4
- data/lib/necropsy/reporter.rb +431 -15
- data/lib/necropsy/runner.rb +233 -18
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/type_facts.rb +53 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy/why_not_explanation.rb +436 -0
- data/lib/necropsy/why_not_renderer.rb +197 -0
- data/lib/necropsy/world_policy.rb +90 -0
- data/lib/necropsy.rb +35 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- metadata +85 -1
|
@@ -6,42 +6,49 @@ module Necropsy
|
|
|
6
6
|
module EntryPoints
|
|
7
7
|
class Rails
|
|
8
8
|
ROUTE_VERBS = %w[get post put patch delete match].freeze
|
|
9
|
+
ROUTE_DSL_CALLS = %i[
|
|
10
|
+
collection concern concerns constraints controller defaults draw member mount namespace resource resources root scope
|
|
11
|
+
].to_set.merge(ROUTE_VERBS.map(&:to_sym)).freeze
|
|
9
12
|
RESTFUL_ACTIONS = %w[index show new create edit update destroy].freeze
|
|
10
13
|
SINGULAR_ACTIONS = %w[show new create edit update destroy].freeze
|
|
11
14
|
IRREGULAR_PLURALS = { 'person' => 'people', 'man' => 'men', 'woman' => 'women', 'child' => 'children' }.freeze
|
|
15
|
+
INFLECTION_RULES_AFFECTING_PLURALIZATION = %i[clear plural].freeze
|
|
12
16
|
RouteContext = Struct.new(:modules, :resource, :controller, keyword_init: true)
|
|
13
17
|
|
|
14
18
|
def apply(graph, project)
|
|
15
|
-
return unless project.config.rails_enabled?
|
|
19
|
+
return unless project.config.rails_enabled?(reference_files: project.reference_files)
|
|
20
|
+
|
|
21
|
+
@route_blockers = []
|
|
22
|
+
load_inflections(project)
|
|
16
23
|
|
|
17
24
|
referenced_view_methods = view_method_names(project)
|
|
18
25
|
graph.method_nodes.each do |node|
|
|
19
26
|
case node.file
|
|
20
27
|
when %r{\Aapp/jobs/}
|
|
21
|
-
graph.add_entry_point(node.
|
|
28
|
+
graph.add_entry_point(node.graph_id, :job_perform) if node.name == 'perform'
|
|
22
29
|
when %r{\Aapp/mailers/}
|
|
23
30
|
if node.kind == :instance_method && node.visibility == :public
|
|
24
|
-
graph.add_entry_point(node.
|
|
31
|
+
graph.add_entry_point(node.graph_id,
|
|
25
32
|
:mailer_action)
|
|
26
33
|
end
|
|
27
34
|
when %r{\Aapp/helpers/}
|
|
28
|
-
graph.add_entry_point(node.
|
|
35
|
+
graph.add_entry_point(node.graph_id, :rails_view_helper) if referenced_view_methods.include?(node.name)
|
|
29
36
|
when %r{\Aapp/components/}
|
|
30
|
-
graph.add_entry_point(node.
|
|
37
|
+
graph.add_entry_point(node.graph_id, :rails_component) if component_entrypoint?(node)
|
|
31
38
|
when %r{\Adb/migrate/}
|
|
32
|
-
graph.add_entry_point(node.
|
|
39
|
+
graph.add_entry_point(node.graph_id, :rails_migration) if %w[change up down].include?(node.name)
|
|
33
40
|
end
|
|
34
41
|
|
|
35
42
|
if node.file.start_with?('app/') && !node.file.start_with?('app/helpers/', 'app/components/') &&
|
|
36
43
|
referenced_view_methods.include?(node.name)
|
|
37
|
-
graph.add_entry_point(node.
|
|
44
|
+
graph.add_entry_point(node.graph_id, :rails_view_reference)
|
|
38
45
|
end
|
|
39
46
|
end
|
|
40
47
|
|
|
41
48
|
graph.nodes.values.select do |node|
|
|
42
49
|
node.kind == :block_entry && node.file.start_with?('config/initializers/')
|
|
43
50
|
end.each do |node|
|
|
44
|
-
graph.add_entry_point(node.
|
|
51
|
+
graph.add_entry_point(node.graph_id, :callback_registered)
|
|
45
52
|
end
|
|
46
53
|
|
|
47
54
|
route_entry_points(project).each do |node_id|
|
|
@@ -49,52 +56,176 @@ module Necropsy
|
|
|
49
56
|
graph.add_entry_point(matching_id, :rails_route)
|
|
50
57
|
end
|
|
51
58
|
end
|
|
59
|
+
@route_blockers.each { |blocker| graph.add_blocker(blocker) }
|
|
52
60
|
end
|
|
53
61
|
|
|
54
62
|
private
|
|
55
63
|
|
|
64
|
+
def load_inflections(project)
|
|
65
|
+
@irregular_plurals = IRREGULAR_PLURALS.dup
|
|
66
|
+
@acronyms = {}
|
|
67
|
+
@uncountables = Set.new
|
|
68
|
+
path = project.reference_files.find do |candidate|
|
|
69
|
+
project.relative_path(candidate) == 'config/initializers/inflections.rb'
|
|
70
|
+
end
|
|
71
|
+
return unless path
|
|
72
|
+
|
|
73
|
+
source = File.read(path)
|
|
74
|
+
result = Prism.parse(source)
|
|
75
|
+
unless result.success?
|
|
76
|
+
@route_blockers << route_health_blocker(
|
|
77
|
+
project.relative_path(path), result.errors.first&.location&.start_line,
|
|
78
|
+
'Rails inflections could not be parsed'
|
|
79
|
+
)
|
|
80
|
+
return
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
inflection_blocks(result.value).each do |block, parameter_name|
|
|
84
|
+
each_call(block.body) do |call|
|
|
85
|
+
next unless call.receiver.is_a?(Prism::LocalVariableReadNode)
|
|
86
|
+
next unless call.receiver.name == parameter_name
|
|
87
|
+
|
|
88
|
+
case call.name
|
|
89
|
+
when :irregular then register_irregular(call, project.relative_path(path))
|
|
90
|
+
when :acronym then register_acronym(call, project.relative_path(path))
|
|
91
|
+
when :uncountable then register_uncountable(call, project.relative_path(path))
|
|
92
|
+
when *INFLECTION_RULES_AFFECTING_PLURALIZATION
|
|
93
|
+
@route_blockers << route_health_blocker(
|
|
94
|
+
project.relative_path(path), call.location.start_line,
|
|
95
|
+
"Rails #{call.name} inflection cannot be evaluated statically"
|
|
96
|
+
)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
rescue SystemCallError, EncodingError => e
|
|
101
|
+
@route_blockers << route_health_blocker(
|
|
102
|
+
'config/initializers/inflections.rb', 1, "Rails inflections could not be read: #{e.class}"
|
|
103
|
+
)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def each_call(root)
|
|
107
|
+
pending = [root]
|
|
108
|
+
until pending.empty?
|
|
109
|
+
node = pending.pop
|
|
110
|
+
yield node if node.is_a?(Prism::CallNode)
|
|
111
|
+
pending.concat(node.child_nodes.compact)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def inflection_blocks(root)
|
|
116
|
+
blocks = []
|
|
117
|
+
each_call(root) do |call|
|
|
118
|
+
next unless call.name == :inflections && constant_name(call.receiver) == 'ActiveSupport::Inflector'
|
|
119
|
+
next unless call.block
|
|
120
|
+
|
|
121
|
+
parameter = required_block_parameter(call.block)
|
|
122
|
+
unless parameter.respond_to?(:name)
|
|
123
|
+
@route_blockers << route_health_blocker(
|
|
124
|
+
'config/initializers/inflections.rb', call.location.start_line,
|
|
125
|
+
'Rails inflections block has no static block parameter'
|
|
126
|
+
)
|
|
127
|
+
next
|
|
128
|
+
end
|
|
129
|
+
blocks << [call.block, parameter.name]
|
|
130
|
+
end
|
|
131
|
+
blocks
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def required_block_parameter(block)
|
|
135
|
+
block_parameters = block.parameters
|
|
136
|
+
parameters = block_parameters.parameters if block_parameters
|
|
137
|
+
Array(parameters&.requireds).first
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def register_irregular(call, path)
|
|
141
|
+
singular, plural = Array(call.arguments&.arguments).first(2).map { |node| literal_route_argument(node) }
|
|
142
|
+
return @irregular_plurals[singular] = plural if singular && plural
|
|
143
|
+
|
|
144
|
+
@route_blockers << route_health_blocker(path, call.location.start_line, 'Rails irregular inflection is dynamic')
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def register_acronym(call, path)
|
|
148
|
+
value = literal_route_argument(Array(call.arguments&.arguments).first)
|
|
149
|
+
return @acronyms[value.downcase] = value if value
|
|
150
|
+
|
|
151
|
+
@route_blockers << route_health_blocker(path, call.location.start_line, 'Rails acronym inflection is dynamic')
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def register_uncountable(call, path)
|
|
155
|
+
words = Array(call.arguments&.arguments).flat_map do |argument|
|
|
156
|
+
if argument.is_a?(Prism::ArrayNode) && !argument.contains_splat?
|
|
157
|
+
argument.elements.map { |element| literal_route_argument(element) }
|
|
158
|
+
else
|
|
159
|
+
literal_route_argument(argument)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
if words.any? && words.none?(&:nil?)
|
|
163
|
+
@uncountables.merge(words)
|
|
164
|
+
return
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
@route_blockers << route_health_blocker(path, call.location.start_line, 'Rails uncountable inflection is dynamic')
|
|
168
|
+
end
|
|
169
|
+
|
|
56
170
|
def route_entry_points(project)
|
|
57
171
|
routes = File.join(project.root, 'config/routes.rb')
|
|
58
|
-
parse_route_file(project
|
|
172
|
+
parse_route_file(project, routes, seen: {}, concerns: {})
|
|
59
173
|
end
|
|
60
174
|
|
|
61
|
-
def parse_route_file(
|
|
62
|
-
|
|
63
|
-
|
|
175
|
+
def parse_route_file(project, path, seen:, concerns:)
|
|
176
|
+
previous_route_path = @current_route_path
|
|
177
|
+
expanded = File.expand_path(path, project.root)
|
|
178
|
+
return [] unless project.reference_file?(expanded)
|
|
64
179
|
return [] if seen[expanded]
|
|
65
180
|
|
|
66
181
|
seen[expanded] = true
|
|
182
|
+
@current_route_path = project.relative_path(expanded)
|
|
67
183
|
source = File.read(expanded)
|
|
68
184
|
result = Prism.parse(source)
|
|
69
|
-
|
|
185
|
+
if result.failure?
|
|
186
|
+
@route_blockers << route_health_blocker(
|
|
187
|
+
@current_route_path, result.errors.first&.location&.start_line, 'routes.rb could not be parsed'
|
|
188
|
+
)
|
|
189
|
+
return []
|
|
190
|
+
end
|
|
70
191
|
|
|
71
192
|
parse_route_statements(
|
|
72
193
|
result.value.statements,
|
|
73
194
|
source: source,
|
|
74
|
-
|
|
195
|
+
project: project,
|
|
75
196
|
seen: seen,
|
|
76
197
|
concerns: concerns,
|
|
77
198
|
context: RouteContext.new(modules: [], resource: nil)
|
|
78
199
|
)
|
|
79
|
-
rescue SystemCallError, EncodingError
|
|
200
|
+
rescue SystemCallError, EncodingError => e
|
|
201
|
+
@route_blockers << route_health_blocker(
|
|
202
|
+
@current_route_path || project.relative_path(expanded), 1,
|
|
203
|
+
"route source could not be read: #{e.class}"
|
|
204
|
+
)
|
|
80
205
|
[]
|
|
206
|
+
ensure
|
|
207
|
+
@current_route_path = previous_route_path
|
|
81
208
|
end
|
|
82
209
|
|
|
83
|
-
def parse_route_statements(statements, source:,
|
|
210
|
+
def parse_route_statements(statements, source:, project:, seen:, concerns:, context:)
|
|
84
211
|
Array(statements&.body).flat_map do |statement|
|
|
85
|
-
parse_route_statement(statement, source: source,
|
|
212
|
+
parse_route_statement(statement, source: source, project: project, seen: seen, concerns: concerns,
|
|
86
213
|
context: context)
|
|
87
214
|
end.compact.uniq
|
|
88
215
|
end
|
|
89
216
|
|
|
90
|
-
def parse_route_statement(statement, source:,
|
|
217
|
+
def parse_route_statement(statement, source:, project:, seen:, concerns:, context:)
|
|
91
218
|
unless statement.is_a?(Prism::CallNode)
|
|
92
219
|
return statement.child_nodes.compact.flat_map do |child|
|
|
93
|
-
parse_route_statement(child, source: source,
|
|
220
|
+
parse_route_statement(child, source: source, project: project, seen: seen, concerns: concerns,
|
|
221
|
+
context: context)
|
|
94
222
|
end
|
|
95
223
|
end
|
|
96
224
|
|
|
225
|
+
return [] unless ROUTE_DSL_CALLS.include?(statement.name)
|
|
226
|
+
|
|
97
227
|
call_source = route_call_source(statement, source)
|
|
228
|
+
record_dynamic_route(statement, context) if dynamic_route_statement?(statement)
|
|
98
229
|
if statement.name == :concern && statement.block
|
|
99
230
|
name = literal_route_argument(statement.arguments&.arguments&.first)
|
|
100
231
|
concerns[name] = [statement.block.body, source] if name
|
|
@@ -102,8 +233,8 @@ module Necropsy
|
|
|
102
233
|
end
|
|
103
234
|
|
|
104
235
|
targets = route_targets(call_source, context)
|
|
105
|
-
targets.concat(route_file_targets(call_source,
|
|
106
|
-
targets.concat(concern_targets(call_source, context,
|
|
236
|
+
targets.concat(route_file_targets(call_source, project, seen, concerns))
|
|
237
|
+
targets.concat(concern_targets(call_source, context, project, seen, concerns))
|
|
107
238
|
return targets unless statement.block
|
|
108
239
|
|
|
109
240
|
child_context = nested_route_context(call_source, context)
|
|
@@ -111,7 +242,7 @@ module Necropsy
|
|
|
111
242
|
parse_route_statements(
|
|
112
243
|
statement.block.body,
|
|
113
244
|
source: source,
|
|
114
|
-
|
|
245
|
+
project: project,
|
|
115
246
|
seen: seen,
|
|
116
247
|
concerns: concerns,
|
|
117
248
|
context: child_context
|
|
@@ -130,6 +261,119 @@ module Necropsy
|
|
|
130
261
|
node.unescaped.to_s
|
|
131
262
|
end
|
|
132
263
|
|
|
264
|
+
def dynamic_route_statement?(node)
|
|
265
|
+
return false unless ROUTE_VERBS.include?(node.name.to_s) ||
|
|
266
|
+
%i[root resources resource mount namespace scope controller].include?(node.name)
|
|
267
|
+
|
|
268
|
+
Array(node.arguments&.arguments).each_with_index.any? do |argument, index|
|
|
269
|
+
!literal_route_value?(argument) && !static_mount_target?(node, argument, index)
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def static_mount_target?(statement, argument, index)
|
|
274
|
+
statement.name == :mount && index.zero? && !constant_name(argument).nil?
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def constant_name(node)
|
|
278
|
+
case node
|
|
279
|
+
when Prism::ConstantReadNode
|
|
280
|
+
node.name.to_s
|
|
281
|
+
when Prism::ConstantPathNode
|
|
282
|
+
parent = node.parent ? constant_name(node.parent) : nil
|
|
283
|
+
[parent, node.name.to_s].compact.join('::')
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def literal_route_value?(node)
|
|
288
|
+
case node
|
|
289
|
+
when Prism::SymbolNode, Prism::StringNode, Prism::IntegerNode, Prism::TrueNode, Prism::FalseNode,
|
|
290
|
+
Prism::NilNode
|
|
291
|
+
true
|
|
292
|
+
when Prism::KeywordHashNode, Prism::HashNode
|
|
293
|
+
Array(node.elements).all? do |element|
|
|
294
|
+
element.is_a?(Prism::AssocNode) && literal_route_value?(element.value)
|
|
295
|
+
end
|
|
296
|
+
when Prism::ArrayNode
|
|
297
|
+
!node.contains_splat? && node.elements.all? { |element| literal_route_value?(element) }
|
|
298
|
+
else
|
|
299
|
+
false
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
def record_dynamic_route(statement, context)
|
|
304
|
+
arguments = Array(statement.arguments&.arguments)
|
|
305
|
+
dynamic = arguments.find { |argument| !literal_route_value?(argument) }
|
|
306
|
+
return unless dynamic
|
|
307
|
+
|
|
308
|
+
scope_kind, scope_value = dynamic_route_scope(statement, context)
|
|
309
|
+
@route_blockers << route_blocker(
|
|
310
|
+
@current_route_path,
|
|
311
|
+
statement.location.start_line,
|
|
312
|
+
"dynamic route argument for #{statement.name}",
|
|
313
|
+
scope_kind: scope_kind,
|
|
314
|
+
scope_value: scope_value
|
|
315
|
+
)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def dynamic_route_scope(statement, context)
|
|
319
|
+
controller = literal_route_option(statement, 'controller')
|
|
320
|
+
controller ||= context.controller || context.resource
|
|
321
|
+
if controller
|
|
322
|
+
owner = "#{camelize(route_controller(context, controller))}Controller"
|
|
323
|
+
return [:owner, owner]
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
action = literal_route_option(statement, 'action')
|
|
327
|
+
return [:message, action] if action
|
|
328
|
+
return [:namespace, camelize(context.modules.join('/'))] if context.modules.any?
|
|
329
|
+
|
|
330
|
+
[:global, '*']
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def literal_route_option(statement, name)
|
|
334
|
+
hash = Array(statement.arguments&.arguments).find do |argument|
|
|
335
|
+
argument.is_a?(Prism::KeywordHashNode) || argument.is_a?(Prism::HashNode)
|
|
336
|
+
end
|
|
337
|
+
pair = Array(hash&.elements).find do |element|
|
|
338
|
+
element.is_a?(Prism::AssocNode) && literal_route_argument(element.key) == name
|
|
339
|
+
end
|
|
340
|
+
literal_route_argument(pair&.value)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def route_blocker(path, line, reason, scope_kind:, scope_value:)
|
|
344
|
+
Blocker.new(
|
|
345
|
+
kind: :rails_route_dynamic,
|
|
346
|
+
scope_kind: scope_kind,
|
|
347
|
+
scope_value: scope_value,
|
|
348
|
+
source: :rails_rule,
|
|
349
|
+
reason: reason,
|
|
350
|
+
suggested_action: :review_dynamic_route,
|
|
351
|
+
metadata: {
|
|
352
|
+
'caller_domain' => 'runtime',
|
|
353
|
+
'rule_id' => 'rails.route',
|
|
354
|
+
'file' => path,
|
|
355
|
+
'line' => line
|
|
356
|
+
}.compact
|
|
357
|
+
)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def route_health_blocker(path, line, reason)
|
|
361
|
+
Blocker.new(
|
|
362
|
+
kind: :rails_route_health,
|
|
363
|
+
scope_kind: :global,
|
|
364
|
+
scope_value: '*',
|
|
365
|
+
source: :rails_rule,
|
|
366
|
+
reason: reason,
|
|
367
|
+
suggested_action: :fix_route_source,
|
|
368
|
+
metadata: {
|
|
369
|
+
'caller_domain' => 'runtime',
|
|
370
|
+
'rule_id' => 'rails.route.health',
|
|
371
|
+
'file' => path,
|
|
372
|
+
'line' => line
|
|
373
|
+
}.compact
|
|
374
|
+
)
|
|
375
|
+
end
|
|
376
|
+
|
|
133
377
|
def nested_route_context(line, context)
|
|
134
378
|
contexts = [context]
|
|
135
379
|
push_context(contexts, line, context)
|
|
@@ -207,13 +451,18 @@ module Necropsy
|
|
|
207
451
|
end
|
|
208
452
|
end
|
|
209
453
|
|
|
210
|
-
def route_file_targets(line,
|
|
454
|
+
def route_file_targets(line, project, seen, concerns)
|
|
211
455
|
return [] unless (match = line.match(/\bdraw\s+:?["']?([a-zA-Z_]\w*)/))
|
|
212
456
|
|
|
213
|
-
parse_route_file(
|
|
457
|
+
parse_route_file(
|
|
458
|
+
project,
|
|
459
|
+
File.join(project.root, "config/routes/#{match[1]}.rb"),
|
|
460
|
+
seen: seen,
|
|
461
|
+
concerns: concerns
|
|
462
|
+
)
|
|
214
463
|
end
|
|
215
464
|
|
|
216
|
-
def concern_targets(line, context,
|
|
465
|
+
def concern_targets(line, context, project, seen, concerns)
|
|
217
466
|
target_context = concern_context(line, context)
|
|
218
467
|
concern_names(line).flat_map do |name|
|
|
219
468
|
statements, source = concerns[name]
|
|
@@ -222,7 +471,7 @@ module Necropsy
|
|
|
222
471
|
parse_route_statements(
|
|
223
472
|
statements,
|
|
224
473
|
source: source,
|
|
225
|
-
|
|
474
|
+
project: project,
|
|
226
475
|
seen: seen,
|
|
227
476
|
concerns: concerns,
|
|
228
477
|
context: target_context
|
|
@@ -288,22 +537,42 @@ module Necropsy
|
|
|
288
537
|
end
|
|
289
538
|
|
|
290
539
|
def controller_action_target(line, context)
|
|
291
|
-
controller = line
|
|
292
|
-
action = line
|
|
540
|
+
controller = literal_source_option(line, 'controller', allow_path: true)
|
|
541
|
+
action = literal_source_option(line, 'action')
|
|
293
542
|
controller ||= context.controller
|
|
294
543
|
return nil unless controller && action
|
|
295
544
|
|
|
296
545
|
controller_action_id(route_controller(context, controller), action)
|
|
297
546
|
end
|
|
298
547
|
|
|
548
|
+
def literal_source_option(line, name, allow_path: false)
|
|
549
|
+
value_pattern = allow_path ? '[a-zA-Z_][\\w/]*' : '[a-zA-Z_]\\w*'
|
|
550
|
+
match = line.match(/\b#{Regexp.escape(name)}:\s*(?::(#{value_pattern})|["'](#{value_pattern})["'])/)
|
|
551
|
+
match && (match[1] || match[2])
|
|
552
|
+
end
|
|
553
|
+
|
|
299
554
|
def mounted_engine_targets(engine_name)
|
|
300
555
|
["#{engine_name}.call", "#{engine_name}#call"]
|
|
301
556
|
end
|
|
302
557
|
|
|
303
558
|
def pluralize(name)
|
|
304
|
-
|
|
305
|
-
return
|
|
306
|
-
return
|
|
559
|
+
irregular = @irregular_plurals || IRREGULAR_PLURALS
|
|
560
|
+
return irregular.fetch(name) if irregular.key?(name)
|
|
561
|
+
return name if @uncountables&.include?(name)
|
|
562
|
+
return name.sub(/(quiz)\z/i, '\\1zes') if name.match?(/quiz\z/i)
|
|
563
|
+
return name.sub(/^(ox)\z/i, '\\1en') if name.match?(/^ox\z/i)
|
|
564
|
+
return name.sub(/([ml])ouse\z/i, '\\1ice') if name.match?(/([ml])ouse\z/i)
|
|
565
|
+
return name.sub(/(matr|vert|ind)(?:ix|ex)\z/i, '\\1ices') if name.match?(/(matr|vert|ind)(?:ix|ex)\z/i)
|
|
566
|
+
return name.sub(/(x|ch|ss|sh)\z/i, '\\1es') if name.match?(/(x|ch|ss|sh)\z/i)
|
|
567
|
+
return name.sub(/([^aeiouy]|qu)y\z/i, '\\1ies') if name.match?(/([^aeiouy]|qu)y\z/i)
|
|
568
|
+
return name.sub(/(?:([^f])fe|([lr])f)\z/i, '\\1\\2ves') if name.match?(/(?:([^f])fe|([lr])f)\z/i)
|
|
569
|
+
return name.sub(/sis\z/i, 'ses') if name.match?(/sis\z/i)
|
|
570
|
+
return name.sub(/([ti])um\z/i, '\\1a') if name.match?(/([ti])um\z/i)
|
|
571
|
+
return name.sub(/(buffal|tomat)o\z/i, '\\1oes') if name.match?(/(buffal|tomat)o\z/i)
|
|
572
|
+
return name.sub(/(alias|status)\z/i, '\\1es') if name.match?(/(alias|status)\z/i)
|
|
573
|
+
return name.sub(/(octop|vir)us\z/i, '\\1i') if name.match?(/(octop|vir)us\z/i)
|
|
574
|
+
return name.sub(/^(ax|test)is\z/i, '\\1es') if name.match?(/^(ax|test)is\z/i)
|
|
575
|
+
return name if name.end_with?('s')
|
|
307
576
|
|
|
308
577
|
"#{name}s"
|
|
309
578
|
end
|
|
@@ -313,16 +582,18 @@ module Necropsy
|
|
|
313
582
|
end
|
|
314
583
|
|
|
315
584
|
def matching_route_nodes(graph, node_id)
|
|
316
|
-
|
|
585
|
+
exact = graph.definitions_for(node_id)
|
|
586
|
+
return exact.select { |node| node.visibility == :public }.map(&:graph_id) unless exact.empty?
|
|
317
587
|
return [] unless node_id.include?('Controller#')
|
|
318
588
|
|
|
319
589
|
controller, action = node_id.split('#', 2)
|
|
320
590
|
expected_file = "app/controllers/#{underscore(controller.delete_suffix('Controller'))}_controller.rb"
|
|
321
591
|
graph.method_nodes.filter_map do |node|
|
|
322
592
|
next unless node.name == action && node.owner&.end_with?(controller)
|
|
593
|
+
next unless node.visibility == :public
|
|
323
594
|
next unless node.file == expected_file
|
|
324
595
|
|
|
325
|
-
node.
|
|
596
|
+
node.graph_id
|
|
326
597
|
end
|
|
327
598
|
end
|
|
328
599
|
|
|
@@ -331,21 +602,28 @@ module Necropsy
|
|
|
331
602
|
end
|
|
332
603
|
|
|
333
604
|
def view_files(project)
|
|
334
|
-
|
|
605
|
+
project.reference_files.select do |path|
|
|
606
|
+
project.relative_path(path).start_with?('app/views/', 'app/components/')
|
|
607
|
+
end
|
|
335
608
|
end
|
|
336
609
|
|
|
337
610
|
def view_source(path)
|
|
338
|
-
File.read(path)
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
611
|
+
source = File.read(path)
|
|
612
|
+
return EmbeddedRuby.extract(source) if File.extname(path).downcase == '.erb'
|
|
613
|
+
|
|
614
|
+
source.gsub(/<!--.*?-->/m, '').lines.reject { |line| line.strip.start_with?('-#') }.join
|
|
342
615
|
end
|
|
343
616
|
|
|
344
617
|
def view_method_names(project)
|
|
345
618
|
@view_method_names ||= {}
|
|
346
619
|
@view_method_names[project.root] ||= view_files(project).each_with_object(Set.new) do |path, names|
|
|
347
|
-
|
|
348
|
-
|
|
620
|
+
source = File.read(path)
|
|
621
|
+
if File.extname(path).downcase == '.erb'
|
|
622
|
+
names.merge(EmbeddedRuby.call_names(source))
|
|
623
|
+
else
|
|
624
|
+
view_source(path).scan(/(?<![A-Za-z0-9_])([a-zA-Z_]\w*[!?=]?)(?![A-Za-z0-9_])/).each do |match|
|
|
625
|
+
names << match.first
|
|
626
|
+
end
|
|
349
627
|
end
|
|
350
628
|
rescue SystemCallError, EncodingError
|
|
351
629
|
next
|
|
@@ -357,7 +635,10 @@ module Necropsy
|
|
|
357
635
|
end
|
|
358
636
|
|
|
359
637
|
def camelize(path)
|
|
360
|
-
|
|
638
|
+
acronyms = @acronyms || {}
|
|
639
|
+
path.split('/').map do |part|
|
|
640
|
+
part.split('_').map { |word| acronyms.fetch(word.downcase, word.capitalize) }.join
|
|
641
|
+
end.join('::')
|
|
361
642
|
end
|
|
362
643
|
|
|
363
644
|
def underscore(constant)
|
|
@@ -5,7 +5,12 @@ module Necropsy
|
|
|
5
5
|
class Test
|
|
6
6
|
def apply(graph, _project)
|
|
7
7
|
graph.nodes.values.select { |node| node.kind == :block_entry && node.test }.each do |node|
|
|
8
|
-
graph.add_entry_point(
|
|
8
|
+
graph.add_entry_point(
|
|
9
|
+
node.graph_id,
|
|
10
|
+
:test_suite,
|
|
11
|
+
domain: :test,
|
|
12
|
+
evidence: { 'type' => 'test_file', 'file' => node.file }
|
|
13
|
+
)
|
|
9
14
|
end
|
|
10
15
|
end
|
|
11
16
|
end
|