necropsy 0.1.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/.rubocop.yml +33 -0
- data/CHANGELOG.md +44 -0
- data/MEASUREMENTS.md +112 -0
- data/README.md +233 -16
- data/Rakefile +3 -1
- 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 +81 -19
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
- 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 +225 -20
- 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 -69
- data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
- data/lib/necropsy/analyzers/static/rta.rb +249 -25
- data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
- 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 +532 -0
- data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
- data/lib/necropsy/ast_scanner/references.rb +115 -0
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
- data/lib/necropsy/ast_scanner/traversal.rb +347 -0
- data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
- data/lib/necropsy/ast_scanner.rb +89 -564
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +356 -24
- 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 +94 -18
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +296 -53
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +181 -58
- data/lib/necropsy/configuration.rb +339 -18
- 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 +342 -0
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +125 -7
- data/lib/necropsy/entry_points/rails.rb +422 -105
- 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 +1234 -62
- 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 +359 -13
- data/lib/necropsy/guardrail/diff.rb +5 -2
- data/lib/necropsy/guardrail/quarantine.rb +101 -10
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +894 -21
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +361 -13
- data/lib/necropsy/reachability/engine.rb +80 -15
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +153 -14
- data/lib/necropsy/reporter.rb +441 -19
- data/lib/necropsy/runner.rb +258 -14
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/trace_point_runtime.rb +19 -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 +39 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- data/script/measure.rb +20 -0
- metadata +106 -2
|
@@ -1,33 +1,54 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
3
5
|
module Necropsy
|
|
4
6
|
module EntryPoints
|
|
5
7
|
class Rails
|
|
6
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
|
|
7
12
|
RESTFUL_ACTIONS = %w[index show new create edit update destroy].freeze
|
|
8
13
|
SINGULAR_ACTIONS = %w[show new create edit update destroy].freeze
|
|
14
|
+
IRREGULAR_PLURALS = { 'person' => 'people', 'man' => 'men', 'woman' => 'women', 'child' => 'children' }.freeze
|
|
15
|
+
INFLECTION_RULES_AFFECTING_PLURALIZATION = %i[clear plural].freeze
|
|
9
16
|
RouteContext = Struct.new(:modules, :resource, :controller, keyword_init: true)
|
|
10
17
|
|
|
11
18
|
def apply(graph, project)
|
|
12
|
-
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)
|
|
13
23
|
|
|
24
|
+
referenced_view_methods = view_method_names(project)
|
|
14
25
|
graph.method_nodes.each do |node|
|
|
15
26
|
case node.file
|
|
16
27
|
when %r{\Aapp/jobs/}
|
|
17
|
-
graph.add_entry_point(node.
|
|
28
|
+
graph.add_entry_point(node.graph_id, :job_perform) if node.name == 'perform'
|
|
18
29
|
when %r{\Aapp/mailers/}
|
|
19
|
-
|
|
30
|
+
if node.kind == :instance_method && node.visibility == :public
|
|
31
|
+
graph.add_entry_point(node.graph_id,
|
|
32
|
+
:mailer_action)
|
|
33
|
+
end
|
|
20
34
|
when %r{\Aapp/helpers/}
|
|
21
|
-
graph.add_entry_point(node.
|
|
35
|
+
graph.add_entry_point(node.graph_id, :rails_view_helper) if referenced_view_methods.include?(node.name)
|
|
22
36
|
when %r{\Aapp/components/}
|
|
23
|
-
graph.add_entry_point(node.
|
|
37
|
+
graph.add_entry_point(node.graph_id, :rails_component) if component_entrypoint?(node)
|
|
38
|
+
when %r{\Adb/migrate/}
|
|
39
|
+
graph.add_entry_point(node.graph_id, :rails_migration) if %w[change up down].include?(node.name)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if node.file.start_with?('app/') && !node.file.start_with?('app/helpers/', 'app/components/') &&
|
|
43
|
+
referenced_view_methods.include?(node.name)
|
|
44
|
+
graph.add_entry_point(node.graph_id, :rails_view_reference)
|
|
24
45
|
end
|
|
25
46
|
end
|
|
26
47
|
|
|
27
48
|
graph.nodes.values.select do |node|
|
|
28
49
|
node.kind == :block_entry && node.file.start_with?('config/initializers/')
|
|
29
50
|
end.each do |node|
|
|
30
|
-
graph.add_entry_point(node.
|
|
51
|
+
graph.add_entry_point(node.graph_id, :callback_registered)
|
|
31
52
|
end
|
|
32
53
|
|
|
33
54
|
route_entry_points(project).each do |node_id|
|
|
@@ -35,64 +56,328 @@ module Necropsy
|
|
|
35
56
|
graph.add_entry_point(matching_id, :rails_route)
|
|
36
57
|
end
|
|
37
58
|
end
|
|
59
|
+
@route_blockers.each { |blocker| graph.add_blocker(blocker) }
|
|
38
60
|
end
|
|
39
61
|
|
|
40
62
|
private
|
|
41
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
|
+
|
|
42
170
|
def route_entry_points(project)
|
|
43
171
|
routes = File.join(project.root, 'config/routes.rb')
|
|
44
|
-
parse_route_file(project
|
|
172
|
+
parse_route_file(project, routes, seen: {}, concerns: {})
|
|
45
173
|
end
|
|
46
174
|
|
|
47
|
-
def parse_route_file(
|
|
48
|
-
|
|
49
|
-
|
|
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)
|
|
50
179
|
return [] if seen[expanded]
|
|
51
180
|
|
|
52
181
|
seen[expanded] = true
|
|
53
|
-
|
|
182
|
+
@current_route_path = project.relative_path(expanded)
|
|
183
|
+
source = File.read(expanded)
|
|
184
|
+
result = Prism.parse(source)
|
|
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
|
|
191
|
+
|
|
192
|
+
parse_route_statements(
|
|
193
|
+
result.value.statements,
|
|
194
|
+
source: source,
|
|
195
|
+
project: project,
|
|
196
|
+
seen: seen,
|
|
197
|
+
concerns: concerns,
|
|
198
|
+
context: RouteContext.new(modules: [], resource: nil)
|
|
199
|
+
)
|
|
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
|
+
)
|
|
205
|
+
[]
|
|
206
|
+
ensure
|
|
207
|
+
@current_route_path = previous_route_path
|
|
54
208
|
end
|
|
55
209
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
210
|
+
def parse_route_statements(statements, source:, project:, seen:, concerns:, context:)
|
|
211
|
+
Array(statements&.body).flat_map do |statement|
|
|
212
|
+
parse_route_statement(statement, source: source, project: project, seen: seen, concerns: concerns,
|
|
213
|
+
context: context)
|
|
214
|
+
end.compact.uniq
|
|
215
|
+
end
|
|
60
216
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
217
|
+
def parse_route_statement(statement, source:, project:, seen:, concerns:, context:)
|
|
218
|
+
unless statement.is_a?(Prism::CallNode)
|
|
219
|
+
return statement.child_nodes.compact.flat_map do |child|
|
|
220
|
+
parse_route_statement(child, source: source, project: project, seen: seen, concerns: concerns,
|
|
221
|
+
context: context)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
64
224
|
|
|
65
|
-
|
|
66
|
-
capture[:depth] += block_openings(stripped)
|
|
67
|
-
capture[:depth] -= 1 if stripped == 'end'
|
|
225
|
+
return [] unless ROUTE_DSL_CALLS.include?(statement.name)
|
|
68
226
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
end
|
|
227
|
+
call_source = route_call_source(statement, source)
|
|
228
|
+
record_dynamic_route(statement, context) if dynamic_route_statement?(statement)
|
|
229
|
+
if statement.name == :concern && statement.block
|
|
230
|
+
name = literal_route_argument(statement.arguments&.arguments&.first)
|
|
231
|
+
concerns[name] = [statement.block.body, source] if name
|
|
232
|
+
return []
|
|
233
|
+
end
|
|
77
234
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
235
|
+
targets = route_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))
|
|
238
|
+
return targets unless statement.block
|
|
239
|
+
|
|
240
|
+
child_context = nested_route_context(call_source, context)
|
|
241
|
+
targets.concat(
|
|
242
|
+
parse_route_statements(
|
|
243
|
+
statement.block.body,
|
|
244
|
+
source: source,
|
|
245
|
+
project: project,
|
|
246
|
+
seen: seen,
|
|
247
|
+
concerns: concerns,
|
|
248
|
+
context: child_context
|
|
249
|
+
)
|
|
250
|
+
)
|
|
251
|
+
end
|
|
82
252
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
253
|
+
def route_call_source(node, source)
|
|
254
|
+
finish = node.block ? node.block.opening_loc.start_offset : node.location.end_offset
|
|
255
|
+
source.byteslice(node.location.start_offset...finish).gsub(/\s+/, ' ').strip
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def literal_route_argument(node)
|
|
259
|
+
return unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
|
|
260
|
+
|
|
261
|
+
node.unescaped.to_s
|
|
262
|
+
end
|
|
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)
|
|
86
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
|
|
87
302
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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]
|
|
93
324
|
end
|
|
94
325
|
|
|
95
|
-
|
|
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
|
+
|
|
377
|
+
def nested_route_context(line, context)
|
|
378
|
+
contexts = [context]
|
|
379
|
+
push_context(contexts, line, context)
|
|
380
|
+
contexts.last
|
|
96
381
|
end
|
|
97
382
|
|
|
98
383
|
def route_targets(line, context)
|
|
@@ -152,37 +437,45 @@ module Necropsy
|
|
|
152
437
|
elsif (match = line.match(/\bscope\b.*\bmodule:\s+:?["']?([a-zA-Z_]\w*)/))
|
|
153
438
|
contexts << RouteContext.new(modules: context.modules + [match[1]], resource: context.resource,
|
|
154
439
|
controller: scoped_controller_option(line, context.controller))
|
|
155
|
-
elsif (match = line.match(%r{\bcontroller\s+:?["']?([a-zA-Z_][\w/]*)["']
|
|
440
|
+
elsif (match = line.match(%r{\bcontroller\s+:?["']?([a-zA-Z_][\w/]*)["']?}))
|
|
156
441
|
contexts << RouteContext.new(modules: context.modules, resource: context.resource, controller: match[1])
|
|
157
|
-
elsif line.match
|
|
158
|
-
contexts << RouteContext.new(modules: context.modules, resource: context.resource,
|
|
159
|
-
controller: scoped_controller_option(line, context.controller))
|
|
160
|
-
elsif (match = line.match(/\bresources\s+:([a-zA-Z_]\w*).*do\b/))
|
|
442
|
+
elsif (match = line.match(/\bresources\s+:([a-zA-Z_]\w*)/))
|
|
161
443
|
contexts << RouteContext.new(modules: context.modules, resource: resource_controller(line, match[1]),
|
|
162
444
|
controller: context.controller)
|
|
163
|
-
elsif (match = line.match(/\bresource\s+:([a-zA-Z_]\w*)
|
|
445
|
+
elsif (match = line.match(/\bresource\s+:([a-zA-Z_]\w*)/))
|
|
164
446
|
contexts << RouteContext.new(modules: context.modules,
|
|
165
447
|
resource: resource_controller(line, pluralize(match[1])), controller: context.controller)
|
|
166
|
-
elsif line.match?(/\b(?:member|collection)\
|
|
167
|
-
contexts << RouteContext.new(modules: context.modules, resource: context.resource,
|
|
168
|
-
controller: context.controller)
|
|
169
|
-
elsif line.match?(/\b(?:constraints|defaults|scope)\b.*do\b/)
|
|
448
|
+
elsif line.match?(/\b(?:member|collection|constraints|defaults|scope)\b/)
|
|
170
449
|
contexts << RouteContext.new(modules: context.modules, resource: context.resource,
|
|
171
450
|
controller: scoped_controller_option(line, context.controller))
|
|
172
451
|
end
|
|
173
452
|
end
|
|
174
453
|
|
|
175
|
-
def route_file_targets(line,
|
|
454
|
+
def route_file_targets(line, project, seen, concerns)
|
|
176
455
|
return [] unless (match = line.match(/\bdraw\s+:?["']?([a-zA-Z_]\w*)/))
|
|
177
456
|
|
|
178
|
-
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
|
+
)
|
|
179
463
|
end
|
|
180
464
|
|
|
181
|
-
def concern_targets(line, context,
|
|
465
|
+
def concern_targets(line, context, project, seen, concerns)
|
|
182
466
|
target_context = concern_context(line, context)
|
|
183
467
|
concern_names(line).flat_map do |name|
|
|
184
|
-
|
|
185
|
-
|
|
468
|
+
statements, source = concerns[name]
|
|
469
|
+
next [] unless statements && source
|
|
470
|
+
|
|
471
|
+
parse_route_statements(
|
|
472
|
+
statements,
|
|
473
|
+
source: source,
|
|
474
|
+
project: project,
|
|
475
|
+
seen: seen,
|
|
476
|
+
concerns: concerns,
|
|
477
|
+
context: target_context
|
|
478
|
+
)
|
|
186
479
|
end
|
|
187
480
|
end
|
|
188
481
|
|
|
@@ -219,7 +512,7 @@ module Necropsy
|
|
|
219
512
|
def action_option(line, name)
|
|
220
513
|
array_value = line[/\b#{name}:\s*(?:\[([^\]]+)\]|%i\[([^\]]+)\])/, 1] ||
|
|
221
514
|
line[/\b#{name}:\s*(?:\[([^\]]+)\]|%i\[([^\]]+)\])/, 2]
|
|
222
|
-
return array_value.scan(/:?["']?([a-zA-Z_]\w*)["']?/) if array_value
|
|
515
|
+
return array_value.scan(/:?["']?([a-zA-Z_]\w*)["']?/).flatten if array_value
|
|
223
516
|
|
|
224
517
|
Array(line[/\b#{name}:\s+:?["']?([a-zA-Z_]\w*)/, 1])
|
|
225
518
|
end
|
|
@@ -244,21 +537,42 @@ module Necropsy
|
|
|
244
537
|
end
|
|
245
538
|
|
|
246
539
|
def controller_action_target(line, context)
|
|
247
|
-
controller = line
|
|
248
|
-
action = line
|
|
540
|
+
controller = literal_source_option(line, 'controller', allow_path: true)
|
|
541
|
+
action = literal_source_option(line, 'action')
|
|
249
542
|
controller ||= context.controller
|
|
250
543
|
return nil unless controller && action
|
|
251
544
|
|
|
252
545
|
controller_action_id(route_controller(context, controller), action)
|
|
253
546
|
end
|
|
254
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
|
+
|
|
255
554
|
def mounted_engine_targets(engine_name)
|
|
256
555
|
["#{engine_name}.call", "#{engine_name}#call"]
|
|
257
556
|
end
|
|
258
557
|
|
|
259
558
|
def pluralize(name)
|
|
260
|
-
|
|
261
|
-
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')
|
|
262
576
|
|
|
263
577
|
"#{name}s"
|
|
264
578
|
end
|
|
@@ -268,29 +582,52 @@ module Necropsy
|
|
|
268
582
|
end
|
|
269
583
|
|
|
270
584
|
def matching_route_nodes(graph, node_id)
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
585
|
+
exact = graph.definitions_for(node_id)
|
|
586
|
+
return exact.select { |node| node.visibility == :public }.map(&:graph_id) unless exact.empty?
|
|
587
|
+
return [] unless node_id.include?('Controller#')
|
|
588
|
+
|
|
589
|
+
controller, action = node_id.split('#', 2)
|
|
590
|
+
expected_file = "app/controllers/#{underscore(controller.delete_suffix('Controller'))}_controller.rb"
|
|
591
|
+
graph.method_nodes.filter_map do |node|
|
|
592
|
+
next unless node.name == action && node.owner&.end_with?(controller)
|
|
593
|
+
next unless node.visibility == :public
|
|
594
|
+
next unless node.file == expected_file
|
|
595
|
+
|
|
596
|
+
node.graph_id
|
|
597
|
+
end
|
|
275
598
|
end
|
|
276
599
|
|
|
277
600
|
def helper_referenced?(project, method_name)
|
|
278
|
-
|
|
279
|
-
view_source(path).match?(/\b#{Regexp.escape(method_name)}\b/)
|
|
280
|
-
end
|
|
281
|
-
rescue SystemCallError, EncodingError
|
|
282
|
-
false
|
|
601
|
+
view_method_names(project).include?(method_name)
|
|
283
602
|
end
|
|
284
603
|
|
|
285
604
|
def view_files(project)
|
|
286
|
-
|
|
605
|
+
project.reference_files.select do |path|
|
|
606
|
+
project.relative_path(path).start_with?('app/views/', 'app/components/')
|
|
607
|
+
end
|
|
287
608
|
end
|
|
288
609
|
|
|
289
610
|
def view_source(path)
|
|
290
|
-
File.read(path)
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
615
|
+
end
|
|
616
|
+
|
|
617
|
+
def view_method_names(project)
|
|
618
|
+
@view_method_names ||= {}
|
|
619
|
+
@view_method_names[project.root] ||= view_files(project).each_with_object(Set.new) do |path, names|
|
|
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
|
|
627
|
+
end
|
|
628
|
+
rescue SystemCallError, EncodingError
|
|
629
|
+
next
|
|
630
|
+
end
|
|
294
631
|
end
|
|
295
632
|
|
|
296
633
|
def component_entrypoint?(node)
|
|
@@ -298,37 +635,17 @@ module Necropsy
|
|
|
298
635
|
end
|
|
299
636
|
|
|
300
637
|
def camelize(path)
|
|
301
|
-
|
|
638
|
+
acronyms = @acronyms || {}
|
|
639
|
+
path.split('/').map do |part|
|
|
640
|
+
part.split('_').map { |word| acronyms.fetch(word.downcase, word.capitalize) }.join
|
|
641
|
+
end.join('::')
|
|
302
642
|
end
|
|
303
643
|
|
|
304
|
-
def
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
quote = nil
|
|
310
|
-
escaped = false
|
|
311
|
-
line.each_char.with_index do |char, index|
|
|
312
|
-
if escaped
|
|
313
|
-
escaped = false
|
|
314
|
-
next
|
|
315
|
-
end
|
|
316
|
-
|
|
317
|
-
if char == '\\'
|
|
318
|
-
escaped = true
|
|
319
|
-
next
|
|
320
|
-
end
|
|
321
|
-
|
|
322
|
-
if quote
|
|
323
|
-
quote = nil if char == quote
|
|
324
|
-
next
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
quote = char if ["'", '"'].include?(char)
|
|
328
|
-
return line[0...index] if char == '#'
|
|
329
|
-
end
|
|
330
|
-
|
|
331
|
-
line
|
|
644
|
+
def underscore(constant)
|
|
645
|
+
constant.gsub('::', '/')
|
|
646
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2')
|
|
647
|
+
.gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
|
|
648
|
+
.downcase
|
|
332
649
|
end
|
|
333
650
|
end
|
|
334
651
|
end
|
|
@@ -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
|