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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +125 -0
- data/Rakefile +8 -0
- data/exe/necropsy +6 -0
- data/lib/necropsy/analyzer.rb +19 -0
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +196 -0
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +69 -0
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +376 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +18 -0
- data/lib/necropsy/analyzers/static/cha.rb +106 -0
- data/lib/necropsy/analyzers/static/name_resolution.rb +53 -0
- data/lib/necropsy/analyzers/static/rta.rb +79 -0
- data/lib/necropsy/ast_scanner.rb +616 -0
- data/lib/necropsy/bench/evaluator.rb +127 -0
- data/lib/necropsy/cache/scan_cache.rb +158 -0
- data/lib/necropsy/cli.rb +287 -0
- data/lib/necropsy/confidence/scorer.rb +158 -0
- data/lib/necropsy/configuration.rb +116 -0
- data/lib/necropsy/coverage_runtime.rb +13 -0
- data/lib/necropsy/entry_points/plain.rb +36 -0
- data/lib/necropsy/entry_points/rails.rb +335 -0
- data/lib/necropsy/entry_points/test.rb +13 -0
- data/lib/necropsy/graph/call_graph.rb +199 -0
- data/lib/necropsy/guardrail/baseline.rb +47 -0
- data/lib/necropsy/guardrail/diff.rb +16 -0
- data/lib/necropsy/guardrail/quarantine.rb +46 -0
- data/lib/necropsy/models.rb +164 -0
- data/lib/necropsy/project.rb +65 -0
- data/lib/necropsy/reachability/engine.rb +42 -0
- data/lib/necropsy/report.rb +50 -0
- data/lib/necropsy/reporter.rb +112 -0
- data/lib/necropsy/runner.rb +74 -0
- data/lib/necropsy/version.rb +5 -0
- data/lib/necropsy.rb +46 -0
- 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
|