necropsy 0.1.0 → 0.2.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 +18 -0
- data/MEASUREMENTS.md +85 -0
- data/README.md +60 -8
- data/Rakefile +3 -1
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +12 -2
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +5 -1
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +68 -14
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +107 -12
- data/lib/necropsy/analyzers/static/cha.rb +10 -6
- data/lib/necropsy/analyzers/static/name_resolution.rb +29 -20
- data/lib/necropsy/analyzers/static/rta.rb +19 -3
- data/lib/necropsy/ast_scanner/call_recording.rb +156 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +142 -0
- data/lib/necropsy/ast_scanner/method_definitions.rb +176 -0
- data/lib/necropsy/ast_scanner/references.rb +81 -0
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +177 -0
- data/lib/necropsy/ast_scanner/traversal.rb +184 -0
- data/lib/necropsy/ast_scanner/value_definitions.rb +68 -0
- data/lib/necropsy/ast_scanner.rb +24 -560
- data/lib/necropsy/bench/evaluator.rb +29 -10
- data/lib/necropsy/cache/scan_cache.rb +20 -12
- data/lib/necropsy/cli.rb +88 -32
- data/lib/necropsy/confidence/scorer.rb +97 -19
- data/lib/necropsy/configuration.rb +125 -7
- data/lib/necropsy/diagnostics.rb +202 -0
- data/lib/necropsy/entry_points/plain.rb +19 -2
- data/lib/necropsy/entry_points/rails.rb +121 -85
- data/lib/necropsy/graph/call_graph.rb +203 -21
- data/lib/necropsy/guardrail/baseline.rb +14 -5
- data/lib/necropsy/guardrail/diff.rb +5 -2
- data/lib/necropsy/guardrail/quarantine.rb +9 -3
- data/lib/necropsy/models.rb +19 -11
- data/lib/necropsy/project.rb +49 -3
- data/lib/necropsy/reachability/engine.rb +31 -7
- data/lib/necropsy/report.rb +44 -14
- data/lib/necropsy/reporter.rb +11 -5
- data/lib/necropsy/runner.rb +33 -4
- data/lib/necropsy/trace_point_runtime.rb +19 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy.rb +4 -0
- data/script/measure.rb +20 -0
- metadata +22 -2
|
@@ -6,19 +6,41 @@ module Necropsy
|
|
|
6
6
|
class Configuration
|
|
7
7
|
DEFAULT_FAIL_ON = :high
|
|
8
8
|
DEFAULT_BASELINE = '.necropsy_baseline.yml'
|
|
9
|
+
TOP_LEVEL_KEYS = %w[
|
|
10
|
+
analyzers frameworks entry_points ci quarantine bench cache rta resolution paths report logging implicit_callers
|
|
11
|
+
].freeze
|
|
12
|
+
NESTED_KEYS = {
|
|
13
|
+
'analyzers' => %w[static dynamic custom],
|
|
14
|
+
'analyzers.dynamic' => %w[coverage coverband trace_point],
|
|
15
|
+
'entry_points' => %w[extra],
|
|
16
|
+
'ci' => %w[baseline fail_on],
|
|
17
|
+
'quarantine' => %w[days],
|
|
18
|
+
'bench' => %w[precision_threshold recall_threshold],
|
|
19
|
+
'cache' => %w[enabled path],
|
|
20
|
+
'rta' => %w[factory_methods],
|
|
21
|
+
'resolution' => %w[ambiguity_limit],
|
|
22
|
+
'paths' => %w[include exclude],
|
|
23
|
+
'report' => %w[include exclude],
|
|
24
|
+
'logging' => %w[verbose]
|
|
25
|
+
}.freeze
|
|
26
|
+
DYNAMIC_KEYS = %w[source min_observation_days keys key pattern connect_timeout read_timeout].freeze
|
|
27
|
+
IMPLICIT_CALLER_KEYS = %w[name_pattern owner_ancestors reason].freeze
|
|
9
28
|
|
|
10
29
|
attr_reader :root, :path, :data
|
|
11
30
|
|
|
12
31
|
def self.load(root:, path: nil)
|
|
13
32
|
config_path = path ? File.expand_path(path, root) : File.join(root, '.necropsy.yml')
|
|
14
|
-
data = File.exist?(config_path) ? YAML.
|
|
33
|
+
data = File.exist?(config_path) ? YAML.safe_load_file(config_path, permitted_classes: [Symbol], aliases: true) : {}
|
|
15
34
|
new(root: root, path: config_path, data: data || {})
|
|
35
|
+
rescue Psych::Exception => e
|
|
36
|
+
raise Error, "Could not parse configuration #{config_path}: #{e.message}"
|
|
16
37
|
end
|
|
17
38
|
|
|
18
39
|
def initialize(root:, path:, data:)
|
|
19
40
|
@root = File.expand_path(root)
|
|
20
41
|
@path = path
|
|
21
42
|
@data = stringify_keys(data)
|
|
43
|
+
validate!
|
|
22
44
|
end
|
|
23
45
|
|
|
24
46
|
def static_analyzers
|
|
@@ -53,7 +75,10 @@ module Necropsy
|
|
|
53
75
|
end
|
|
54
76
|
|
|
55
77
|
def fail_on
|
|
56
|
-
(fetch('ci', 'fail_on') || DEFAULT_FAIL_ON).to_sym
|
|
78
|
+
level = (fetch('ci', 'fail_on') || DEFAULT_FAIL_ON).to_sym
|
|
79
|
+
return level if CONFIDENCE_LEVELS.key?(level)
|
|
80
|
+
|
|
81
|
+
raise Error, "Invalid ci.fail_on confidence level: #{level}"
|
|
57
82
|
end
|
|
58
83
|
|
|
59
84
|
def min_observation_days
|
|
@@ -81,25 +106,70 @@ module Necropsy
|
|
|
81
106
|
end
|
|
82
107
|
|
|
83
108
|
def cache_path
|
|
84
|
-
fetch('cache', 'path') || '.necropsy_cache/scan.
|
|
109
|
+
fetch('cache', 'path') || '.necropsy_cache/scan.json'
|
|
85
110
|
end
|
|
86
111
|
|
|
87
112
|
def scan_cache_key
|
|
88
|
-
data.except('cache')
|
|
113
|
+
data.except('cache', 'report')
|
|
89
114
|
end
|
|
90
115
|
|
|
91
116
|
def factory_methods
|
|
92
117
|
Array(fetch('rta', 'factory_methods') || %w[build create build_stubbed]).map(&:to_s)
|
|
93
118
|
end
|
|
94
119
|
|
|
120
|
+
def ambiguity_limit
|
|
121
|
+
value = fetch('resolution', 'ambiguity_limit')
|
|
122
|
+
return 4 if value.nil?
|
|
123
|
+
return Float::INFINITY if value.to_s == 'unlimited'
|
|
124
|
+
|
|
125
|
+
limit = Integer(value)
|
|
126
|
+
return limit if limit.positive?
|
|
127
|
+
|
|
128
|
+
raise Error, 'resolution.ambiguity_limit must be a positive integer or unlimited'
|
|
129
|
+
rescue ArgumentError, TypeError
|
|
130
|
+
raise Error, 'resolution.ambiguity_limit must be a positive integer or unlimited'
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def include_paths
|
|
134
|
+
Array(fetch('paths', 'include')).map(&:to_s)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def exclude_paths
|
|
138
|
+
Array(fetch('paths', 'exclude')).map(&:to_s)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def report_include_paths
|
|
142
|
+
Array(fetch('report', 'include')).map(&:to_s)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def report_exclude_paths
|
|
146
|
+
Array(fetch('report', 'exclude')).map(&:to_s)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def implicit_callers
|
|
150
|
+
@implicit_callers ||= Array(data['implicit_callers']).map do |entry|
|
|
151
|
+
{
|
|
152
|
+
name_pattern: Regexp.new(entry.fetch('name_pattern')),
|
|
153
|
+
owner_ancestors: Array(entry['owner_ancestors']).map(&:to_s),
|
|
154
|
+
reason: entry['reason']&.to_s
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def verbose?
|
|
160
|
+
fetch('logging', 'verbose') == true
|
|
161
|
+
end
|
|
162
|
+
|
|
95
163
|
private
|
|
96
164
|
|
|
97
165
|
def fetch(*keys)
|
|
98
|
-
|
|
99
|
-
|
|
166
|
+
current = data
|
|
167
|
+
keys.each do |key|
|
|
168
|
+
return nil unless current.is_a?(Hash)
|
|
100
169
|
|
|
101
|
-
current[key.to_s]
|
|
170
|
+
current = current[key.to_s]
|
|
102
171
|
end
|
|
172
|
+
current
|
|
103
173
|
end
|
|
104
174
|
|
|
105
175
|
def stringify_keys(value)
|
|
@@ -112,5 +182,53 @@ module Necropsy
|
|
|
112
182
|
value
|
|
113
183
|
end
|
|
114
184
|
end
|
|
185
|
+
|
|
186
|
+
def validate!
|
|
187
|
+
validate_hash_keys(data, TOP_LEVEL_KEYS, 'configuration')
|
|
188
|
+
NESTED_KEYS.each do |path, allowed|
|
|
189
|
+
value = fetch(*path.split('.'))
|
|
190
|
+
validate_hash_keys(value, allowed, path) if value
|
|
191
|
+
end
|
|
192
|
+
%w[coverage coverband trace_point].each do |name|
|
|
193
|
+
value = fetch('analyzers', 'dynamic', name)
|
|
194
|
+
validate_hash_keys(value, DYNAMIC_KEYS, "analyzers.dynamic.#{name}") if value
|
|
195
|
+
end
|
|
196
|
+
validate_custom_analyzers!
|
|
197
|
+
validate_implicit_callers!
|
|
198
|
+
ambiguity_limit
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def validate_hash_keys(value, allowed, location)
|
|
202
|
+
raise Error, "#{location} must be a mapping" unless value.is_a?(Hash)
|
|
203
|
+
|
|
204
|
+
unknown = value.keys - allowed
|
|
205
|
+
return if unknown.empty?
|
|
206
|
+
|
|
207
|
+
raise Error, "Unknown #{location} option#{'s' if unknown.length > 1}: #{unknown.join(', ')}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def validate_custom_analyzers!
|
|
211
|
+
custom_analyzers.each do |entry|
|
|
212
|
+
next if entry.is_a?(String)
|
|
213
|
+
raise Error, 'Each custom analyzer must be a class name or a mapping with class and require' unless entry.is_a?(Hash)
|
|
214
|
+
|
|
215
|
+
validate_hash_keys(entry, %w[class require], 'custom analyzer')
|
|
216
|
+
raise Error, 'Custom analyzer mapping requires class' unless entry['class']
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def validate_implicit_callers!
|
|
221
|
+
Array(data['implicit_callers']).each do |entry|
|
|
222
|
+
raise Error, 'Each implicit caller must be a mapping' unless entry.is_a?(Hash)
|
|
223
|
+
|
|
224
|
+
validate_hash_keys(entry, IMPLICIT_CALLER_KEYS, 'implicit caller')
|
|
225
|
+
pattern = entry['name_pattern']
|
|
226
|
+
raise Error, 'Implicit caller requires name_pattern' unless pattern.is_a?(String)
|
|
227
|
+
|
|
228
|
+
Regexp.new(pattern)
|
|
229
|
+
rescue RegexpError => e
|
|
230
|
+
raise Error, "Invalid implicit caller name_pattern: #{e.message}"
|
|
231
|
+
end
|
|
232
|
+
end
|
|
115
233
|
end
|
|
116
234
|
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Necropsy
|
|
6
|
+
class Diagnostics
|
|
7
|
+
FORMATS = %i[human json].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(report)
|
|
10
|
+
raise Error, 'Reachability witnesses are unavailable for this report' unless report.reachability
|
|
11
|
+
|
|
12
|
+
@report = report
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def why(node_id)
|
|
16
|
+
node = graph.nodes[node_id]
|
|
17
|
+
return missing_payload(node_id) unless node
|
|
18
|
+
|
|
19
|
+
runtime_path = reachability.witness(node_id)
|
|
20
|
+
return alive_payload(node, runtime_path, :runtime) if runtime_path
|
|
21
|
+
|
|
22
|
+
test_path = reachability.witness(node_id, kind: :test)
|
|
23
|
+
return alive_payload(node, test_path, :test) if test_path
|
|
24
|
+
|
|
25
|
+
dead_payload(node)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def explain(node_id)
|
|
29
|
+
node = graph.nodes[node_id]
|
|
30
|
+
return missing_payload(node_id) unless node
|
|
31
|
+
|
|
32
|
+
finding = report.findings.find { |candidate| candidate.node.id == node_id }
|
|
33
|
+
return { 'status' => 'alive', 'node' => node.to_h } unless finding
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
'status' => 'finding',
|
|
37
|
+
'node' => node.to_h,
|
|
38
|
+
'classification' => finding.classification.to_s,
|
|
39
|
+
'confidence' => finding.confidence.to_s,
|
|
40
|
+
'score' => finding.score,
|
|
41
|
+
'components' => finding.score_components.map(&:to_h),
|
|
42
|
+
'reasons' => finding.reasons
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def render(payload, format: :human)
|
|
47
|
+
normalized = format.to_sym
|
|
48
|
+
raise Error, "Diagnostics support only human or json output, not #{format}" unless FORMATS.include?(normalized)
|
|
49
|
+
|
|
50
|
+
return JSON.pretty_generate(payload) if normalized == :json
|
|
51
|
+
|
|
52
|
+
render_human(payload)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
attr_reader :report
|
|
58
|
+
|
|
59
|
+
def graph
|
|
60
|
+
report.graph
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def reachability
|
|
64
|
+
report.reachability
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def alive_payload(node, path, kind)
|
|
68
|
+
{
|
|
69
|
+
'status' => 'alive',
|
|
70
|
+
'kind' => kind.to_s,
|
|
71
|
+
'node' => node.to_h,
|
|
72
|
+
'path' => path.each_with_index.map do |node_id, index|
|
|
73
|
+
caller_id = index.positive? ? path[index - 1] : nil
|
|
74
|
+
path_step(node_id, caller_id)
|
|
75
|
+
end
|
|
76
|
+
}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def path_step(node_id, caller_id)
|
|
80
|
+
step = { 'node' => graph.nodes.fetch(node_id).to_h }
|
|
81
|
+
entry = graph.entry_points.find { |candidate| candidate.node_id == node_id }
|
|
82
|
+
step['entry_reason'] = entry.reason.to_s if entry
|
|
83
|
+
return step unless caller_id
|
|
84
|
+
|
|
85
|
+
evidences = graph.edges_from(caller_id).fetch(node_id, [])
|
|
86
|
+
step['edge'] = {
|
|
87
|
+
'caller_id' => caller_id,
|
|
88
|
+
'callee_id' => node_id,
|
|
89
|
+
'evidences' => evidences.map(&:to_h)
|
|
90
|
+
}
|
|
91
|
+
step
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def dead_payload(node)
|
|
95
|
+
finding = report.findings.find { |candidate| candidate.node.id == node.id }
|
|
96
|
+
{
|
|
97
|
+
'status' => 'dead',
|
|
98
|
+
'node' => node.to_h,
|
|
99
|
+
'classification' => finding&.classification&.to_s,
|
|
100
|
+
'nearest_alive' => nearest_alive(node.id),
|
|
101
|
+
'uncertainties' => graph.uncertainties(node.id)
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def nearest_alive(node_id)
|
|
106
|
+
kinds = reachability.runtime_alive.to_h { |id| [id, 'runtime'] }
|
|
107
|
+
reachability.test_alive.each { |id| kinds[id] ||= 'test' }
|
|
108
|
+
visited = { node_id => 0 }
|
|
109
|
+
queue = [node_id]
|
|
110
|
+
|
|
111
|
+
until queue.empty?
|
|
112
|
+
current = queue.shift
|
|
113
|
+
neighbors(current).each do |neighbor|
|
|
114
|
+
next if visited.key?(neighbor)
|
|
115
|
+
|
|
116
|
+
distance = visited.fetch(current) + 1
|
|
117
|
+
return { 'node_id' => neighbor, 'kind' => kinds.fetch(neighbor), 'distance' => distance } if kinds.key?(neighbor)
|
|
118
|
+
|
|
119
|
+
visited[neighbor] = distance
|
|
120
|
+
queue << neighbor
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def neighbors(node_id)
|
|
127
|
+
outgoing = graph.edges_from(node_id).keys
|
|
128
|
+
incoming = graph.incoming_edges(node_id).map(&:caller_id)
|
|
129
|
+
(outgoing + incoming).uniq.sort
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def missing_payload(node_id)
|
|
133
|
+
terms = [node_id, node_id.split(/[.#]/).last].compact.map(&:downcase).reject { |term| term.length < 2 }
|
|
134
|
+
suggestions = graph.nodes.keys.select do |candidate|
|
|
135
|
+
terms.any? { |term| candidate.downcase[term] }
|
|
136
|
+
end.sort.first(10)
|
|
137
|
+
{ 'status' => 'not_found', 'node_id' => node_id, 'suggestions' => suggestions }
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def render_human(payload)
|
|
141
|
+
case payload.fetch('status')
|
|
142
|
+
when 'alive' then render_alive(payload)
|
|
143
|
+
when 'dead' then render_dead(payload)
|
|
144
|
+
when 'finding' then render_explanation(payload)
|
|
145
|
+
when 'not_found' then render_missing(payload)
|
|
146
|
+
else "#{payload.dig('node', 'id')} is alive and has no dead-code finding."
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def render_alive(payload)
|
|
151
|
+
lines = ["Alive (#{payload.fetch('kind')}): #{payload.dig('node', 'id')}"]
|
|
152
|
+
payload.fetch('path').each_with_index do |step, index|
|
|
153
|
+
suffix = step['entry_reason'] ? " entry=#{step['entry_reason']}" : ''
|
|
154
|
+
lines << " #{index}. #{step.dig('node', 'id')}#{suffix}"
|
|
155
|
+
Array(step.dig('edge', 'evidences')).each { |evidence| lines << render_evidence(evidence) }
|
|
156
|
+
end
|
|
157
|
+
lines.join("\n")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def render_evidence(evidence)
|
|
161
|
+
metadata = evidence.fetch('metadata', {})
|
|
162
|
+
location = [metadata['file'], metadata['line']].compact.join(':')
|
|
163
|
+
suffix = location.empty? ? '' : " at #{location}"
|
|
164
|
+
" via #{evidence['analyzer']} weight=#{evidence['weight']}#{suffix}: #{evidence['details']}"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def render_dead(payload)
|
|
168
|
+
lines = ["Dead: #{payload.dig('node', 'id')}"]
|
|
169
|
+
lines << "Classification: #{payload['classification']}" if payload['classification']
|
|
170
|
+
nearest = payload['nearest_alive']
|
|
171
|
+
lines << if nearest
|
|
172
|
+
"Nearest alive: #{nearest['node_id']} (#{nearest['kind']}, distance #{nearest['distance']})"
|
|
173
|
+
else
|
|
174
|
+
'Nearest alive: none'
|
|
175
|
+
end
|
|
176
|
+
lines << 'Uncertainties: none' if payload.fetch('uncertainties').empty?
|
|
177
|
+
payload.fetch('uncertainties').each { |message| lines << "Uncertainty: #{message}" }
|
|
178
|
+
lines.join("\n")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def render_explanation(payload)
|
|
182
|
+
lines = [
|
|
183
|
+
"#{payload.dig('node', 'id')}: #{payload['classification']}",
|
|
184
|
+
"Confidence: #{payload['confidence']} (score #{format('%.2f', payload['score'])})"
|
|
185
|
+
]
|
|
186
|
+
payload.fetch('components').each do |component|
|
|
187
|
+
lines << format(
|
|
188
|
+
' %<name>-28s %<value>+0.2f %<details>s',
|
|
189
|
+
name: component['name'], value: component['value'], details: component['details']
|
|
190
|
+
)
|
|
191
|
+
end
|
|
192
|
+
lines << format(' %<name>-28s %<value>0.2f', name: 'total', value: payload['score'])
|
|
193
|
+
lines.join("\n")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def render_missing(payload)
|
|
197
|
+
lines = ["Node not found: #{payload['node_id']}"]
|
|
198
|
+
lines << "Suggestions: #{payload['suggestions'].join(', ')}" unless payload['suggestions'].empty?
|
|
199
|
+
lines.join("\n")
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
@@ -24,13 +24,30 @@ module Necropsy
|
|
|
24
24
|
end
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
api_files = primary_library_files(project)
|
|
27
28
|
graph.method_nodes.each do |node|
|
|
28
|
-
next unless node.file
|
|
29
|
-
next unless node.kind == :singleton_method && node.
|
|
29
|
+
next unless api_files.include?(node.file)
|
|
30
|
+
next unless node.kind == :singleton_method && node.visibility == :public
|
|
30
31
|
|
|
31
32
|
graph.add_entry_point(node.id, :public_api_declared)
|
|
32
33
|
end
|
|
33
34
|
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def primary_library_files(project)
|
|
39
|
+
gem_names(project).flat_map do |name|
|
|
40
|
+
["lib/#{name.tr('-', '/')}.rb", "lib/#{name.tr('-', '_')}.rb"]
|
|
41
|
+
end.uniq.select { |relative| File.file?(File.join(project.root, relative)) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def gem_names(project)
|
|
45
|
+
Dir.glob(File.join(project.root, '*.gemspec')).filter_map do |path|
|
|
46
|
+
File.read(path)[/\.name\s*=\s*["']([^"']+)["']/, 1]
|
|
47
|
+
rescue SystemCallError, EncodingError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
34
51
|
end
|
|
35
52
|
end
|
|
36
53
|
end
|