necropsy 0.1.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.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +125 -0
  4. data/Rakefile +8 -0
  5. data/exe/necropsy +6 -0
  6. data/lib/necropsy/analyzer.rb +19 -0
  7. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +196 -0
  8. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +69 -0
  9. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +376 -0
  10. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +96 -0
  11. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +18 -0
  12. data/lib/necropsy/analyzers/static/cha.rb +106 -0
  13. data/lib/necropsy/analyzers/static/name_resolution.rb +53 -0
  14. data/lib/necropsy/analyzers/static/rta.rb +79 -0
  15. data/lib/necropsy/ast_scanner.rb +616 -0
  16. data/lib/necropsy/bench/evaluator.rb +127 -0
  17. data/lib/necropsy/cache/scan_cache.rb +158 -0
  18. data/lib/necropsy/cli.rb +287 -0
  19. data/lib/necropsy/confidence/scorer.rb +158 -0
  20. data/lib/necropsy/configuration.rb +116 -0
  21. data/lib/necropsy/coverage_runtime.rb +13 -0
  22. data/lib/necropsy/entry_points/plain.rb +36 -0
  23. data/lib/necropsy/entry_points/rails.rb +335 -0
  24. data/lib/necropsy/entry_points/test.rb +13 -0
  25. data/lib/necropsy/graph/call_graph.rb +199 -0
  26. data/lib/necropsy/guardrail/baseline.rb +47 -0
  27. data/lib/necropsy/guardrail/diff.rb +16 -0
  28. data/lib/necropsy/guardrail/quarantine.rb +46 -0
  29. data/lib/necropsy/models.rb +164 -0
  30. data/lib/necropsy/project.rb +65 -0
  31. data/lib/necropsy/reachability/engine.rb +42 -0
  32. data/lib/necropsy/report.rb +50 -0
  33. data/lib/necropsy/reporter.rb +112 -0
  34. data/lib/necropsy/runner.rb +74 -0
  35. data/lib/necropsy/version.rb +5 -0
  36. data/lib/necropsy.rb +46 -0
  37. metadata +95 -0
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ module Necropsy
6
+ module Confidence
7
+ class Scorer
8
+ def initialize(graph:, reachability:, project:)
9
+ @graph = graph
10
+ @reachability = reachability
11
+ @project = project
12
+ end
13
+
14
+ def findings
15
+ graph.method_nodes.filter_map do |node|
16
+ next if node.test
17
+
18
+ classification = classification_for(node)
19
+ next unless classification
20
+
21
+ score, level, reasons = score_for(node, classification)
22
+ Finding.new(
23
+ node: node,
24
+ classification: classification,
25
+ confidence: level,
26
+ score: score,
27
+ reasons: reasons,
28
+ evidences: graph.incoming_edges(node.id).flat_map(&:evidences) + graph.alive_evidences(node.id)
29
+ )
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ attr_reader :graph, :reachability, :project
36
+
37
+ def classification_for(node)
38
+ if reachability.runtime_alive.include?(node.id)
39
+ return :unused if graph.dynamic_enabled? && !graph.dynamic_alive?(node.id)
40
+
41
+ nil
42
+ elsif reachability.test_alive.include?(node.id)
43
+ :test_only_reachable
44
+ else
45
+ :unreachable
46
+ end
47
+ end
48
+
49
+ def score_for(node, classification)
50
+ reasons = []
51
+ score = base_score(classification)
52
+
53
+ if graph.uncertainties(node.id).any?
54
+ score -= 0.35
55
+ reasons << 'Lowered because this node is near unresolved metaprogramming.'
56
+ end
57
+
58
+ if graph.class_info(node.owner)&.dynamic
59
+ score -= 0.25
60
+ reasons << 'Lowered because the owner class defines dynamic dispatch.'
61
+ end
62
+
63
+ if generated_accessor?(node)
64
+ score -= 0.2
65
+ reasons << 'Lowered because this node is generated by a Ruby accessor DSL.'
66
+ end
67
+
68
+ static_count = graph.profiles.count { |profile| profile.kind == :static }
69
+ if static_count > 1
70
+ score += [static_count - 1, 3].min * 0.04
71
+ reasons << "Raised because #{static_count} static analyzers participated."
72
+ end
73
+
74
+ evidence_analyzers = graph.incoming_edges(node.id).flat_map(&:evidences).map(&:analyzer).uniq
75
+ if evidence_analyzers.length > 1
76
+ score += 0.08
77
+ reasons << 'Raised because multiple analyzers agree on incoming reachability evidence.'
78
+ end
79
+
80
+ if classification == :unreachable && graph.dynamic_enabled? && !graph.dynamic_alive?(node.id)
81
+ score += 0.25
82
+ reasons << 'Static unreachable and absent from dynamic observations.'
83
+ end
84
+
85
+ if classification == :unreachable && quarantine_expired?(node)
86
+ score = [score, 0.95].max
87
+ reasons << 'Raised because quarantine annotation has expired without alive evidence.'
88
+ end
89
+
90
+ if classification == :unused
91
+ days = observation_days
92
+ if days && days < project.config.min_observation_days
93
+ score -= 0.3
94
+ reasons << "Observation window is #{days} days, below the configured minimum."
95
+ else
96
+ reasons << 'Reachable statically but never observed dynamically.'
97
+ end
98
+ end
99
+
100
+ score = [[score, 0.0].max, 1.0].min
101
+ [score, level_for(score), reasons]
102
+ end
103
+
104
+ def base_score(classification)
105
+ case classification
106
+ when :unreachable
107
+ graph.profiles.any? { |profile| profile.name == :rta } ? 0.78 : 0.62
108
+ when :test_only_reachable
109
+ 0.55
110
+ when :unused
111
+ 0.58
112
+ else
113
+ 0.25
114
+ end
115
+ end
116
+
117
+ def generated_accessor?(node)
118
+ %i[attr_reader attr_writer attr_accessor struct_new data_define].include?(node.defined_via)
119
+ end
120
+
121
+ def level_for(score)
122
+ return :certain if score >= 0.9
123
+ return :high if score >= 0.7
124
+ return :medium if score >= 0.45
125
+
126
+ :low
127
+ end
128
+
129
+ def observation_days
130
+ graph.observation.values.filter_map do |value|
131
+ next unless value.is_a?(Hash)
132
+
133
+ value['days'] || value[:days] || value['observation_days'] || value[:observation_days]
134
+ end.map(&:to_i).max
135
+ end
136
+
137
+ def quarantine_expired?(node)
138
+ since = quarantine_since(node)
139
+ return false unless since
140
+
141
+ since <= Date.today - project.config.quarantine_days
142
+ end
143
+
144
+ def quarantine_since(node)
145
+ path = File.join(project.root, node.file)
146
+ return nil unless File.exist?(path)
147
+
148
+ window = File.readlines(path, chomp: true)[[node.line - 4, 0].max, 4] || []
149
+ annotation = window.find { |line| line.include?('necropsy:quarantine') && line.match?(/\bsince=/) }
150
+ return nil unless annotation
151
+
152
+ Date.iso8601(annotation[/\bsince=([0-9-]+)/, 1])
153
+ rescue ArgumentError, SystemCallError
154
+ nil
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Necropsy
6
+ class Configuration
7
+ DEFAULT_FAIL_ON = :high
8
+ DEFAULT_BASELINE = '.necropsy_baseline.yml'
9
+
10
+ attr_reader :root, :path, :data
11
+
12
+ def self.load(root:, path: nil)
13
+ config_path = path ? File.expand_path(path, root) : File.join(root, '.necropsy.yml')
14
+ data = File.exist?(config_path) ? YAML.load_file(config_path) : {}
15
+ new(root: root, path: config_path, data: data || {})
16
+ end
17
+
18
+ def initialize(root:, path:, data:)
19
+ @root = File.expand_path(root)
20
+ @path = path
21
+ @data = stringify_keys(data)
22
+ end
23
+
24
+ def static_analyzers
25
+ Array(fetch('analyzers', 'static') || %w[name_resolution cha rta]).map(&:to_s)
26
+ end
27
+
28
+ def dynamic_config(name)
29
+ fetch('analyzers', 'dynamic', name.to_s) || {}
30
+ end
31
+
32
+ def custom_analyzers
33
+ Array(fetch('analyzers', 'custom')).compact
34
+ end
35
+
36
+ def entry_point_patterns
37
+ Array(fetch('entry_points', 'extra')).compact.map(&:to_s)
38
+ end
39
+
40
+ def frameworks
41
+ Array(data['frameworks']).map(&:to_s)
42
+ end
43
+
44
+ def rails_enabled?
45
+ return true if frameworks.include?('rails')
46
+
47
+ gemfiles = [File.join(root, 'Gemfile.lock'), File.join(root, 'Gemfile')]
48
+ gemfiles.any? { |file| File.exist?(file) && File.read(file).match?(/(?:^|\s)rails(?:\s|\z|,)/) }
49
+ end
50
+
51
+ def baseline_path
52
+ fetch('ci', 'baseline') || DEFAULT_BASELINE
53
+ end
54
+
55
+ def fail_on
56
+ (fetch('ci', 'fail_on') || DEFAULT_FAIL_ON).to_sym
57
+ end
58
+
59
+ def min_observation_days
60
+ coverage_days = fetch('analyzers', 'dynamic', 'coverage', 'min_observation_days')
61
+ coverband_days = fetch('analyzers', 'dynamic', 'coverband', 'min_observation_days')
62
+ (coverage_days || coverband_days || 30).to_i
63
+ end
64
+
65
+ def quarantine_days
66
+ (fetch('quarantine', 'days') || 30).to_i
67
+ end
68
+
69
+ def bench_precision_threshold
70
+ (fetch('bench', 'precision_threshold') || 0.85).to_f
71
+ end
72
+
73
+ def bench_recall_threshold
74
+ value = fetch('bench', 'recall_threshold')
75
+ value&.to_f
76
+ end
77
+
78
+ def cache_enabled?
79
+ value = fetch('cache', 'enabled')
80
+ value.nil? || value != false
81
+ end
82
+
83
+ def cache_path
84
+ fetch('cache', 'path') || '.necropsy_cache/scan.yml'
85
+ end
86
+
87
+ def scan_cache_key
88
+ data.except('cache')
89
+ end
90
+
91
+ def factory_methods
92
+ Array(fetch('rta', 'factory_methods') || %w[build create build_stubbed]).map(&:to_s)
93
+ end
94
+
95
+ private
96
+
97
+ def fetch(*keys)
98
+ keys.reduce(data) do |current, key|
99
+ break nil unless current.is_a?(Hash)
100
+
101
+ current[key.to_s]
102
+ end
103
+ end
104
+
105
+ def stringify_keys(value)
106
+ case value
107
+ when Hash
108
+ value.each_with_object({}) { |(key, child), memo| memo[key.to_s] = stringify_keys(child) }
109
+ when Array
110
+ value.map { |child| stringify_keys(child) }
111
+ else
112
+ value
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'necropsy/analyzers/dynamic/coverage_collector'
4
+
5
+ root = ENV.fetch('NECROPSY_COVERAGE_ROOT', nil)
6
+ output = ENV.fetch('NECROPSY_COVERAGE_OUTPUT', nil)
7
+ merge = ENV['NECROPSY_COVERAGE_MERGE'] == '1'
8
+ run_id = ENV.fetch('NECROPSY_COVERAGE_RUN_ID', nil)
9
+
10
+ if root && output
11
+ Necropsy::Analyzers::Dynamic::CoverageCollector.install_at_exit(root: root, output: output, merge: merge,
12
+ run_id: run_id)
13
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ module EntryPoints
5
+ class Plain
6
+ SCRIPT_PATTERNS = [
7
+ 'bin/*',
8
+ 'exe/*',
9
+ 'Rakefile',
10
+ '**/*.rake',
11
+ '*.gemspec'
12
+ ].freeze
13
+
14
+ def apply(graph, project)
15
+ graph.nodes.values.select { |node| node.kind == :block_entry && !node.test }.each do |node|
16
+ next unless SCRIPT_PATTERNS.any? { |pattern| File.fnmatch?(pattern, node.file, File::FNM_PATHNAME) }
17
+
18
+ graph.add_entry_point(node.id, :main_script)
19
+ end
20
+
21
+ project.config.entry_point_patterns.each do |pattern|
22
+ graph.nodes.each_key do |node_id|
23
+ graph.add_entry_point(node_id, :public_api_declared) if File.fnmatch?(pattern, node_id)
24
+ end
25
+ end
26
+
27
+ graph.method_nodes.each do |node|
28
+ next unless node.file == 'lib/necropsy.rb'
29
+ next unless node.kind == :singleton_method && node.owner == 'Necropsy'
30
+
31
+ graph.add_entry_point(node.id, :public_api_declared)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,335 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ module EntryPoints
5
+ class Rails
6
+ ROUTE_VERBS = %w[get post put patch delete match].freeze
7
+ RESTFUL_ACTIONS = %w[index show new create edit update destroy].freeze
8
+ SINGULAR_ACTIONS = %w[show new create edit update destroy].freeze
9
+ RouteContext = Struct.new(:modules, :resource, :controller, keyword_init: true)
10
+
11
+ def apply(graph, project)
12
+ return unless project.config.rails_enabled?
13
+
14
+ graph.method_nodes.each do |node|
15
+ case node.file
16
+ when %r{\Aapp/jobs/}
17
+ graph.add_entry_point(node.id, :job_perform) if node.name == 'perform'
18
+ when %r{\Aapp/mailers/}
19
+ graph.add_entry_point(node.id, :mailer_action) if node.kind == :instance_method
20
+ when %r{\Aapp/helpers/}
21
+ graph.add_entry_point(node.id, :rails_view_helper) if helper_referenced?(project, node.name)
22
+ when %r{\Aapp/components/}
23
+ graph.add_entry_point(node.id, :rails_component) if component_entrypoint?(node)
24
+ end
25
+ end
26
+
27
+ graph.nodes.values.select do |node|
28
+ node.kind == :block_entry && node.file.start_with?('config/initializers/')
29
+ end.each do |node|
30
+ graph.add_entry_point(node.id, :callback_registered)
31
+ end
32
+
33
+ route_entry_points(project).each do |node_id|
34
+ matching_route_nodes(graph, node_id).each do |matching_id|
35
+ graph.add_entry_point(matching_id, :rails_route)
36
+ end
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def route_entry_points(project)
43
+ routes = File.join(project.root, 'config/routes.rb')
44
+ parse_route_file(project.root, routes, seen: {}, concerns: {})
45
+ end
46
+
47
+ def parse_route_file(root, path, seen:, concerns:)
48
+ expanded = File.expand_path(path, root)
49
+ return [] unless File.exist?(expanded)
50
+ return [] if seen[expanded]
51
+
52
+ seen[expanded] = true
53
+ parse_routes(File.readlines(expanded), root: root, seen: seen, concerns: concerns)
54
+ end
55
+
56
+ def parse_routes(lines, root:, seen:, concerns:, initial_context: RouteContext.new(modules: [], resource: nil))
57
+ contexts = [initial_context]
58
+ capture = nil
59
+ targets = []
60
+
61
+ lines.each do |line|
62
+ stripped = strip_route_comment(line).strip
63
+ next if stripped.empty?
64
+
65
+ if capture
66
+ capture[:depth] += block_openings(stripped)
67
+ capture[:depth] -= 1 if stripped == 'end'
68
+
69
+ if capture[:depth].zero?
70
+ concerns[capture[:name]] = capture[:lines]
71
+ capture = nil
72
+ else
73
+ capture[:lines] << stripped
74
+ end
75
+ next
76
+ end
77
+
78
+ if (match = stripped.match(/\bconcern\s+:([a-zA-Z_]\w*)\s+do\b/))
79
+ capture = { name: match[1], depth: 1, lines: [] }
80
+ next
81
+ end
82
+
83
+ if stripped == 'end'
84
+ contexts.pop if contexts.length > 1
85
+ next
86
+ end
87
+
88
+ context = contexts.last
89
+ targets.concat(route_targets(stripped, context))
90
+ targets.concat(route_file_targets(stripped, root, seen, concerns))
91
+ targets.concat(concern_targets(stripped, context, root, seen, concerns))
92
+ push_context(contexts, stripped, context)
93
+ end
94
+
95
+ targets.compact.uniq
96
+ end
97
+
98
+ def route_targets(line, context)
99
+ if (match = line.match(/\broot\s+["']([^#"']+)#([^"']+)["']/))
100
+ return [controller_action_id(route_controller(context, match[1]), match[2])]
101
+ end
102
+
103
+ if (match = line.match(/\b(?:#{ROUTE_VERBS.join('|')}|root)\b.*\bto:\s*["']([^#"']+)#([^"']+)["']/))
104
+ return [controller_action_id(route_controller(context, match[1]), match[2])]
105
+ end
106
+
107
+ if (target = controller_action_target(line, context))
108
+ return [target]
109
+ end
110
+
111
+ if (match = line.match(/\b(?:#{ROUTE_VERBS.join('|')}|root)\b.*=>\s*["']([^#"']+)#([^"']+)["']/))
112
+ return [controller_action_id(route_controller(context, match[1]), match[2])]
113
+ end
114
+
115
+ if (match = line.match(/\bmount\s+([A-Z][\w:]+)\s*(?:=>|,\s*at:)/))
116
+ return mounted_engine_targets(match[1])
117
+ end
118
+
119
+ if (match = line.match(%r{\b(?:#{ROUTE_VERBS.join('|')})\s+["']/?([a-zA-Z_][\w/]*)/([a-zA-Z_]\w*)["']}))
120
+ return [controller_action_id(route_controller(context, match[1]), match[2])]
121
+ end
122
+
123
+ if (match = line.match(/\bresources\s+:([a-zA-Z_]\w*)/))
124
+ controller = resource_controller(line, match[1])
125
+ return resource_actions(line, singular: false).map do |action|
126
+ controller_action_id(route_controller(context, controller), action)
127
+ end
128
+ end
129
+
130
+ if (match = line.match(/\bresource\s+:([a-zA-Z_]\w*)/))
131
+ controller = resource_controller(line, pluralize(match[1]))
132
+ return resource_actions(line, singular: true).map do |action|
133
+ controller_action_id(route_controller(context, controller), action)
134
+ end
135
+ end
136
+
137
+ if context.resource && (match = line.match(/\b(?:#{ROUTE_VERBS.join('|')})\s+:([a-zA-Z_]\w*)/))
138
+ return [controller_action_id(route_controller(context, context.resource), match[1])]
139
+ end
140
+
141
+ if context.controller && (match = line.match(/\b(?:#{ROUTE_VERBS.join('|')})\s+:?["']?([a-zA-Z_]\w*)/))
142
+ return [controller_action_id(route_controller(context, context.controller), match[1])]
143
+ end
144
+
145
+ []
146
+ end
147
+
148
+ def push_context(contexts, line, context)
149
+ if (match = line.match(/\bnamespace\s+:([a-zA-Z_]\w*)/))
150
+ contexts << RouteContext.new(modules: context.modules + [match[1]], resource: context.resource,
151
+ controller: context.controller)
152
+ elsif (match = line.match(/\bscope\b.*\bmodule:\s+:?["']?([a-zA-Z_]\w*)/))
153
+ contexts << RouteContext.new(modules: context.modules + [match[1]], resource: context.resource,
154
+ controller: scoped_controller_option(line, context.controller))
155
+ elsif (match = line.match(%r{\bcontroller\s+:?["']?([a-zA-Z_][\w/]*)["']?\s+do\b}))
156
+ contexts << RouteContext.new(modules: context.modules, resource: context.resource, controller: match[1])
157
+ elsif line.match?(/\bscope\b.*\bcontroller:\s*.*do\b/)
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/))
161
+ contexts << RouteContext.new(modules: context.modules, resource: resource_controller(line, match[1]),
162
+ controller: context.controller)
163
+ elsif (match = line.match(/\bresource\s+:([a-zA-Z_]\w*).*do\b/))
164
+ contexts << RouteContext.new(modules: context.modules,
165
+ resource: resource_controller(line, pluralize(match[1])), controller: context.controller)
166
+ elsif line.match?(/\b(?:member|collection)\s+do\b/)
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/)
170
+ contexts << RouteContext.new(modules: context.modules, resource: context.resource,
171
+ controller: scoped_controller_option(line, context.controller))
172
+ end
173
+ end
174
+
175
+ def route_file_targets(line, root, seen, concerns)
176
+ return [] unless (match = line.match(/\bdraw\s+:?["']?([a-zA-Z_]\w*)/))
177
+
178
+ parse_route_file(root, File.join(root, "config/routes/#{match[1]}.rb"), seen: seen, concerns: concerns)
179
+ end
180
+
181
+ def concern_targets(line, context, root, seen, concerns)
182
+ target_context = concern_context(line, context)
183
+ concern_names(line).flat_map do |name|
184
+ parse_routes(concerns.fetch(name, []), root: root, seen: seen, concerns: concerns,
185
+ initial_context: target_context)
186
+ end
187
+ end
188
+
189
+ def concern_context(line, context)
190
+ if (match = line.match(/\bresources\s+:([a-zA-Z_]\w*)/))
191
+ return RouteContext.new(modules: context.modules, resource: resource_controller(line, match[1]),
192
+ controller: context.controller)
193
+ end
194
+
195
+ if (match = line.match(/\bresource\s+:([a-zA-Z_]\w*)/))
196
+ return RouteContext.new(modules: context.modules, resource: resource_controller(line, pluralize(match[1])),
197
+ controller: context.controller)
198
+ end
199
+
200
+ context
201
+ end
202
+
203
+ def concern_names(line)
204
+ names = line.scan(/\bconcerns?\s+:([a-zA-Z_]\w*)/).flatten
205
+ names.concat(line.scan(/\bconcerns?:\s+:([a-zA-Z_]\w*)/).flatten)
206
+ if (array_value = line[/\bconcerns?:\s*\[([^\]]+)\]/, 1])
207
+ names.concat(array_value.scan(/:([a-zA-Z_]\w*)/).flatten)
208
+ end
209
+ names.uniq
210
+ end
211
+
212
+ def resource_actions(line, singular:)
213
+ return action_option(line, 'only') if line.include?('only:')
214
+
215
+ excluded = action_option(line, 'except')
216
+ (singular ? SINGULAR_ACTIONS : RESTFUL_ACTIONS) - excluded
217
+ end
218
+
219
+ def action_option(line, name)
220
+ array_value = line[/\b#{name}:\s*(?:\[([^\]]+)\]|%i\[([^\]]+)\])/, 1] ||
221
+ line[/\b#{name}:\s*(?:\[([^\]]+)\]|%i\[([^\]]+)\])/, 2]
222
+ return array_value.scan(/:?["']?([a-zA-Z_]\w*)["']?/) if array_value
223
+
224
+ Array(line[/\b#{name}:\s+:?["']?([a-zA-Z_]\w*)/, 1])
225
+ end
226
+
227
+ def resource_controller(line, fallback)
228
+ line[/\bcontroller:\s*["']([^"']+)["']/, 1] || fallback
229
+ end
230
+
231
+ def scoped_controller_option(line, fallback)
232
+ line[%r{\bcontroller:\s+:?["']?([a-zA-Z_][\w/]*)}, 1] || fallback
233
+ end
234
+
235
+ def scoped_controller(context, controller)
236
+ [*context.modules, controller].join('/')
237
+ end
238
+
239
+ def route_controller(context, controller)
240
+ return controller.delete_prefix('/') if controller.start_with?('/')
241
+ return controller if controller.include?('/')
242
+
243
+ scoped_controller(context, controller)
244
+ end
245
+
246
+ def controller_action_target(line, context)
247
+ controller = line[%r{\bcontroller:\s+:?["']?([a-zA-Z_][\w/]*)}, 1]
248
+ action = line[/\baction:\s+:?["']?([a-zA-Z_]\w*)/, 1]
249
+ controller ||= context.controller
250
+ return nil unless controller && action
251
+
252
+ controller_action_id(route_controller(context, controller), action)
253
+ end
254
+
255
+ def mounted_engine_targets(engine_name)
256
+ ["#{engine_name}.call", "#{engine_name}#call"]
257
+ end
258
+
259
+ def pluralize(name)
260
+ return "#{name}es" if name.end_with?('s', 'x', 'z', 'ch', 'sh')
261
+ return "#{name.delete_suffix('y')}ies" if name.end_with?('y')
262
+
263
+ "#{name}s"
264
+ end
265
+
266
+ def controller_action_id(controller_path, action)
267
+ "#{camelize(controller_path)}Controller##{action}"
268
+ end
269
+
270
+ def matching_route_nodes(graph, node_id)
271
+ return [node_id] if graph.nodes.key?(node_id)
272
+
273
+ suffix = "::#{node_id}"
274
+ graph.nodes.keys.select { |candidate| candidate.end_with?(suffix) }
275
+ end
276
+
277
+ def helper_referenced?(project, method_name)
278
+ view_files(project).any? do |path|
279
+ view_source(path).match?(/\b#{Regexp.escape(method_name)}\b/)
280
+ end
281
+ rescue SystemCallError, EncodingError
282
+ false
283
+ end
284
+
285
+ def view_files(project)
286
+ Dir.glob(File.join(project.root, '{app/views,app/components}/**/*')).select { |path| File.file?(path) }
287
+ end
288
+
289
+ def view_source(path)
290
+ File.read(path)
291
+ .gsub(/<%#.*?%>/m, '')
292
+ .gsub(/<!--.*?-->/m, '')
293
+ .lines.reject { |line| line.strip.start_with?('-#') }.join
294
+ end
295
+
296
+ def component_entrypoint?(node)
297
+ node.kind == :instance_method && %w[call render? before_render].include?(node.name)
298
+ end
299
+
300
+ def camelize(path)
301
+ path.split('/').map { |part| part.split('_').map(&:capitalize).join }.join('::')
302
+ end
303
+
304
+ def block_openings(line)
305
+ line.scan(/\bdo\b/).length
306
+ end
307
+
308
+ def strip_route_comment(line)
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
332
+ end
333
+ end
334
+ end
335
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ module EntryPoints
5
+ class Test
6
+ def apply(graph, _project)
7
+ graph.nodes.values.select { |node| node.kind == :block_entry && node.test }.each do |node|
8
+ graph.add_entry_point(node.id, :test_suite)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end