necropsy 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/MEASUREMENTS.md +27 -0
- data/README.md +182 -17
- data/bench/README.md +92 -0
- data/bench/audit.rb +113 -0
- data/bench/audits/0.2.1/audit.json +501 -0
- data/bench/audits/0.2.1/audit.md +55 -0
- data/bench/audits/0.2.1/baseline_performance.yml +16 -0
- data/bench/audits/0.2.1/config.yml +42 -0
- data/bench/audits/0.2.1/review.yml +56 -0
- data/bench/audits/0.2.1/review_queue.yml +3651 -0
- data/bench/corpora/v1/README.md +30 -0
- data/bench/corpora/v1/labels.yml +37 -0
- data/bench/corpora/v1/manifest.yml +63 -0
- data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
- data/bench/corpora/v1/self.necropsy.yml +8 -0
- data/bench/corpora/v1/tools/debride.yml +42 -0
- data/bench/corpora/v1/tools/spoom.yml +12 -0
- data/bench/corpora/v1/tools/type_aware.yml +11 -0
- data/bench/golden/v1/candidate_union.json +21918 -0
- data/bench/golden/v1/metadata.json +12 -0
- data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
- data/bench/golden/v1/reports/plain_ruby.json +135 -0
- data/bench/golden/v1/reports/rails.json +176 -0
- data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
- data/bench/golden/v1/reports/self.json +3449 -0
- data/bench/review_queue.rb +35 -0
- data/bench/run.rb +31 -0
- data/bench/schema/candidate-union-v1.schema.json +70 -0
- data/docs/impv_implementation_matrix.md +179 -0
- data/docs/migrations/0.2.1.md +57 -0
- data/docs/migrations/0.3.0.md +207 -0
- data/docs/migrations/0.4.0.md +13 -0
- data/docs/necropsy_performance_adr.md +43 -0
- data/docs/necropsy_scope_decisions.md +58 -0
- data/docs/necropsy_type_facts_adr.md +22 -0
- data/gemfiles/prism_min.gemfile +9 -0
- data/gemfiles/prism_min.gemfile.lock +50 -0
- data/lib/necropsy/analyzer.rb +121 -2
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
- data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
- data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
- data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
- data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
- data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
- data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
- data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
- data/lib/necropsy/analyzers/static/cha.rb +33 -73
- data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
- data/lib/necropsy/analyzers/static/rta.rb +237 -29
- data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
- data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
- data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
- data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
- data/lib/necropsy/ast_scanner/references.rb +43 -9
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
- data/lib/necropsy/ast_scanner/traversal.rb +234 -71
- data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
- data/lib/necropsy/ast_scanner.rb +67 -6
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +329 -16
- data/lib/necropsy/bench/finding_facts.rb +152 -0
- data/lib/necropsy/bench/precision_gate.rb +144 -0
- data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
- data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
- data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
- data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
- data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
- data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
- data/lib/necropsy/bench/release_audit.rb +360 -0
- data/lib/necropsy/bench/report_normalizer.rb +140 -0
- data/lib/necropsy/bench/review_queue.rb +154 -0
- data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
- data/lib/necropsy/bench/seed_runner.rb +408 -0
- data/lib/necropsy/bounded_canonicalizer.rb +218 -0
- data/lib/necropsy/cache/scan_cache.rb +85 -17
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +220 -33
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +103 -58
- data/lib/necropsy/configuration.rb +224 -21
- data/lib/necropsy/convention_rules.rb +138 -0
- data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
- data/lib/necropsy/definition_identity.rb +37 -0
- data/lib/necropsy/diagnostics.rb +176 -36
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +111 -10
- data/lib/necropsy/entry_points/rails.rb +322 -41
- data/lib/necropsy/entry_points/test.rb +6 -1
- data/lib/necropsy/flow_interpreter.rb +460 -0
- data/lib/necropsy/graph/blocker_matching.rb +338 -0
- data/lib/necropsy/graph/call_graph.rb +1099 -109
- data/lib/necropsy/graph/definition_index.rb +149 -0
- data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
- data/lib/necropsy/graph/evidence_store.rb +213 -0
- data/lib/necropsy/graph/resolution_store.rb +497 -0
- data/lib/necropsy/graph_self_check.rb +79 -0
- data/lib/necropsy/guardrail/baseline.rb +350 -13
- data/lib/necropsy/guardrail/quarantine.rb +94 -9
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +878 -13
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +327 -25
- data/lib/necropsy/reachability/engine.rb +54 -13
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +113 -4
- data/lib/necropsy/reporter.rb +431 -15
- data/lib/necropsy/runner.rb +233 -18
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/type_facts.rb +53 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy/why_not_explanation.rb +436 -0
- data/lib/necropsy/why_not_renderer.rb +197 -0
- data/lib/necropsy/world_policy.rb +90 -0
- data/lib/necropsy.rb +35 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- metadata +85 -1
data/lib/necropsy/diagnostics.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'shellwords'
|
|
4
5
|
|
|
5
6
|
module Necropsy
|
|
6
7
|
class Diagnostics
|
|
@@ -13,34 +14,57 @@ module Necropsy
|
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def why(node_id)
|
|
16
|
-
|
|
17
|
-
return
|
|
17
|
+
lookup = graph.nodes.lookup(node_id)
|
|
18
|
+
return with_source_incompleteness(ambiguous_payload(lookup)) if lookup.ambiguous?
|
|
19
|
+
return with_source_incompleteness(missing_payload(node_id)) if lookup.missing?
|
|
18
20
|
|
|
19
|
-
|
|
20
|
-
return alive_payload(node, runtime_path, :runtime) if runtime_path
|
|
21
|
+
node = lookup.node
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
23
|
+
graph_id = node.graph_id
|
|
24
|
+
runtime_path = reachability.witness(graph_id)
|
|
25
|
+
return with_source_incompleteness(alive_payload(node, runtime_path, :runtime)) if runtime_path
|
|
24
26
|
|
|
25
|
-
|
|
27
|
+
external_path = reachability.witness(graph_id, kind: :external)
|
|
28
|
+
return with_source_incompleteness(alive_payload(node, external_path, :external)) if external_path
|
|
29
|
+
|
|
30
|
+
finding = finding_for(graph_id)
|
|
31
|
+
return with_source_incompleteness(dead_payload(node, finding)) if finding&.classification == :blocked
|
|
32
|
+
|
|
33
|
+
test_path = reachability.witness(graph_id, kind: :test)
|
|
34
|
+
return with_source_incompleteness(alive_payload(node, test_path, :test)) if test_path
|
|
35
|
+
|
|
36
|
+
with_source_incompleteness(dead_payload(node, finding))
|
|
26
37
|
end
|
|
27
38
|
|
|
28
39
|
def explain(node_id)
|
|
29
|
-
|
|
30
|
-
return
|
|
40
|
+
lookup = graph.nodes.lookup(node_id)
|
|
41
|
+
return with_source_incompleteness(ambiguous_payload(lookup)) if lookup.ambiguous?
|
|
42
|
+
return with_source_incompleteness(missing_payload(node_id)) if lookup.missing?
|
|
31
43
|
|
|
32
|
-
|
|
33
|
-
return { 'status' => 'alive', 'node' => node.to_h } unless finding
|
|
44
|
+
node = lookup.node
|
|
34
45
|
|
|
35
|
-
|
|
46
|
+
finding = finding_for(node.graph_id)
|
|
47
|
+
return with_source_incompleteness({ 'status' => 'alive', 'node' => node.to_h }) unless finding
|
|
48
|
+
|
|
49
|
+
payload = {
|
|
36
50
|
'status' => 'finding',
|
|
37
51
|
'node' => node.to_h,
|
|
38
52
|
'classification' => finding.classification.to_s,
|
|
39
53
|
'confidence' => finding.confidence.to_s,
|
|
40
54
|
'score' => finding.score,
|
|
41
55
|
'components' => finding.score_components.map(&:to_h),
|
|
42
|
-
'reasons' => finding.reasons
|
|
56
|
+
'reasons' => finding.reasons,
|
|
57
|
+
'blockers' => finding.blockers.map(&:to_h)
|
|
43
58
|
}
|
|
59
|
+
with_source_incompleteness(payload)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def why_not(node_id)
|
|
63
|
+
lookup = graph.nodes.lookup(node_id)
|
|
64
|
+
return with_source_incompleteness(ambiguous_payload(lookup)) if lookup.ambiguous?
|
|
65
|
+
return with_source_incompleteness(missing_payload(node_id)) if lookup.missing?
|
|
66
|
+
|
|
67
|
+
WhyNotExplanation.new(report).call(lookup.node)
|
|
44
68
|
end
|
|
45
69
|
|
|
46
70
|
def render(payload, format: :human)
|
|
@@ -71,39 +95,53 @@ module Necropsy
|
|
|
71
95
|
'node' => node.to_h,
|
|
72
96
|
'path' => path.each_with_index.map do |node_id, index|
|
|
73
97
|
caller_id = index.positive? ? path[index - 1] : nil
|
|
74
|
-
path_step(node_id, caller_id)
|
|
98
|
+
path_step(node_id, caller_id, kind)
|
|
75
99
|
end
|
|
76
100
|
}
|
|
77
101
|
end
|
|
78
102
|
|
|
79
|
-
def path_step(node_id, caller_id)
|
|
103
|
+
def path_step(node_id, caller_id, root_domain)
|
|
80
104
|
step = { 'node' => graph.nodes.fetch(node_id).to_h }
|
|
81
|
-
|
|
82
|
-
|
|
105
|
+
roots = graph.entry_points.select do |candidate|
|
|
106
|
+
candidate.node_id == node_id && candidate.domain == root_domain
|
|
107
|
+
end.sort_by { |candidate| [candidate.reason.to_s, BoundedCanonicalizer.dump(candidate.evidence)] }
|
|
108
|
+
if roots.any?
|
|
109
|
+
step['entry_reason'] = roots.first.reason.to_s
|
|
110
|
+
step['root_domain'] = root_domain.to_s
|
|
111
|
+
step['root_provenance'] = roots.map(&:to_h)
|
|
112
|
+
end
|
|
83
113
|
return step unless caller_id
|
|
84
114
|
|
|
85
115
|
evidences = graph.edges_from(caller_id).fetch(node_id, [])
|
|
86
116
|
step['edge'] = {
|
|
87
117
|
'caller_id' => caller_id,
|
|
88
118
|
'callee_id' => node_id,
|
|
119
|
+
'caller' => graph.nodes.fetch(caller_id).to_h,
|
|
120
|
+
'callee' => graph.nodes.fetch(node_id).to_h,
|
|
89
121
|
'evidences' => evidences.map(&:to_h)
|
|
90
122
|
}
|
|
91
123
|
step
|
|
92
124
|
end
|
|
93
125
|
|
|
94
|
-
def dead_payload(node)
|
|
95
|
-
|
|
126
|
+
def dead_payload(node, finding = finding_for(node.graph_id))
|
|
127
|
+
blockers = finding ? finding.blockers : graph.matching_blockers(node)
|
|
96
128
|
{
|
|
97
|
-
'status' => 'dead',
|
|
129
|
+
'status' => finding&.classification == :blocked ? 'blocked' : 'dead',
|
|
98
130
|
'node' => node.to_h,
|
|
99
131
|
'classification' => finding&.classification&.to_s,
|
|
100
|
-
'nearest_alive' => nearest_alive(node.
|
|
101
|
-
'uncertainties' => graph.uncertainties(node.
|
|
132
|
+
'nearest_alive' => nearest_alive(node.graph_id),
|
|
133
|
+
'uncertainties' => graph.uncertainties(node.graph_id),
|
|
134
|
+
'blockers' => blockers.map(&:to_h)
|
|
102
135
|
}
|
|
103
136
|
end
|
|
104
137
|
|
|
138
|
+
def finding_for(node_id)
|
|
139
|
+
report.findings.find { |candidate| candidate.node.graph_id == node_id }
|
|
140
|
+
end
|
|
141
|
+
|
|
105
142
|
def nearest_alive(node_id)
|
|
106
143
|
kinds = reachability.runtime_alive.to_h { |id| [id, 'runtime'] }
|
|
144
|
+
reachability.external_alive.each { |id| kinds[id] ||= 'external' }
|
|
107
145
|
reachability.test_alive.each { |id| kinds[id] ||= 'test' }
|
|
108
146
|
visited = { node_id => 0 }
|
|
109
147
|
queue = [node_id]
|
|
@@ -114,7 +152,17 @@ module Necropsy
|
|
|
114
152
|
next if visited.key?(neighbor)
|
|
115
153
|
|
|
116
154
|
distance = visited.fetch(current) + 1
|
|
117
|
-
|
|
155
|
+
if kinds.key?(neighbor)
|
|
156
|
+
node = graph.nodes.fetch(neighbor)
|
|
157
|
+
return {
|
|
158
|
+
'node_id' => neighbor,
|
|
159
|
+
'symbol_id' => node.symbol_id,
|
|
160
|
+
'definition_id' => node.definition_id,
|
|
161
|
+
'node' => node.to_h,
|
|
162
|
+
'kind' => kinds.fetch(neighbor),
|
|
163
|
+
'distance' => distance
|
|
164
|
+
}
|
|
165
|
+
end
|
|
118
166
|
|
|
119
167
|
visited[neighbor] = distance
|
|
120
168
|
queue << neighbor
|
|
@@ -123,6 +171,30 @@ module Necropsy
|
|
|
123
171
|
nil
|
|
124
172
|
end
|
|
125
173
|
|
|
174
|
+
def ambiguous_payload(lookup)
|
|
175
|
+
{
|
|
176
|
+
'status' => 'ambiguous',
|
|
177
|
+
'node_id' => lookup.identifier,
|
|
178
|
+
'definitions' => lookup.definitions.map do |definition|
|
|
179
|
+
{
|
|
180
|
+
'symbol_id' => definition.symbol_id,
|
|
181
|
+
'definition_id' => definition.definition_id,
|
|
182
|
+
'file' => definition.file,
|
|
183
|
+
'line' => definition.line,
|
|
184
|
+
'commands' => {
|
|
185
|
+
'why' => diagnostic_command('why', definition.definition_id),
|
|
186
|
+
'why_not' => diagnostic_command('why-not', definition.definition_id),
|
|
187
|
+
'explain' => diagnostic_command('explain', definition.definition_id)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
end
|
|
191
|
+
}
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def diagnostic_command(command, definition_id)
|
|
195
|
+
"bundle exec necropsy #{command} #{Shellwords.escape(definition_id)} --root #{Shellwords.escape(report.root)}"
|
|
196
|
+
end
|
|
197
|
+
|
|
126
198
|
def neighbors(node_id)
|
|
127
199
|
outgoing = graph.edges_from(node_id).keys
|
|
128
200
|
incoming = graph.incoming_edges(node_id).map(&:caller_id)
|
|
@@ -131,27 +203,31 @@ module Necropsy
|
|
|
131
203
|
|
|
132
204
|
def missing_payload(node_id)
|
|
133
205
|
terms = [node_id, node_id.split(/[.#]/).last].compact.map(&:downcase).reject { |term| term.length < 2 }
|
|
134
|
-
suggestions = graph.nodes.
|
|
206
|
+
suggestions = graph.nodes.values.map(&:symbol_id).uniq.select do |candidate|
|
|
135
207
|
terms.any? { |term| candidate.downcase[term] }
|
|
136
208
|
end.sort.first(10)
|
|
137
209
|
{ 'status' => 'not_found', 'node_id' => node_id, 'suggestions' => suggestions }
|
|
138
210
|
end
|
|
139
211
|
|
|
140
212
|
def render_human(payload)
|
|
141
|
-
case payload.fetch('status')
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
213
|
+
rendered = case payload.fetch('status')
|
|
214
|
+
when 'alive' then render_alive(payload)
|
|
215
|
+
when 'dead', 'blocked' then render_dead(payload)
|
|
216
|
+
when 'finding' then render_explanation(payload)
|
|
217
|
+
when 'why_not' then WhyNotRenderer.new(payload).render
|
|
218
|
+
when 'ambiguous' then render_ambiguous(payload)
|
|
219
|
+
when 'not_found' then render_missing(payload)
|
|
220
|
+
else "#{node_reference(payload['node'])} is alive and has no dead-code finding."
|
|
221
|
+
end
|
|
222
|
+
append_source_incompleteness(rendered, payload)
|
|
148
223
|
end
|
|
149
224
|
|
|
150
225
|
def render_alive(payload)
|
|
151
|
-
lines = ["Alive (#{payload.fetch('kind')}): #{payload.
|
|
226
|
+
lines = ["Alive (#{payload.fetch('kind')}): #{node_reference(payload.fetch('node'))}"]
|
|
152
227
|
payload.fetch('path').each_with_index do |step, index|
|
|
153
228
|
suffix = step['entry_reason'] ? " entry=#{step['entry_reason']}" : ''
|
|
154
|
-
|
|
229
|
+
suffix += " domain=#{step['root_domain']}" if step['root_domain']
|
|
230
|
+
lines << " #{index}. #{node_reference(step.fetch('node'))}#{suffix}"
|
|
155
231
|
Array(step.dig('edge', 'evidences')).each { |evidence| lines << render_evidence(evidence) }
|
|
156
232
|
end
|
|
157
233
|
lines.join("\n")
|
|
@@ -165,22 +241,25 @@ module Necropsy
|
|
|
165
241
|
end
|
|
166
242
|
|
|
167
243
|
def render_dead(payload)
|
|
168
|
-
|
|
244
|
+
heading = payload['status'] == 'blocked' ? 'Blocked' : 'Dead'
|
|
245
|
+
lines = ["#{heading}: #{node_reference(payload.fetch('node'))}"]
|
|
169
246
|
lines << "Classification: #{payload['classification']}" if payload['classification']
|
|
170
247
|
nearest = payload['nearest_alive']
|
|
171
248
|
lines << if nearest
|
|
172
|
-
"Nearest alive: #{
|
|
249
|
+
"Nearest alive: #{node_reference(nearest['node'])} " \
|
|
250
|
+
"(#{nearest['kind']}, distance #{nearest['distance']})"
|
|
173
251
|
else
|
|
174
252
|
'Nearest alive: none'
|
|
175
253
|
end
|
|
176
254
|
lines << 'Uncertainties: none' if payload.fetch('uncertainties').empty?
|
|
177
255
|
payload.fetch('uncertainties').each { |message| lines << "Uncertainty: #{message}" }
|
|
256
|
+
append_blockers(lines, payload.fetch('blockers', []))
|
|
178
257
|
lines.join("\n")
|
|
179
258
|
end
|
|
180
259
|
|
|
181
260
|
def render_explanation(payload)
|
|
182
261
|
lines = [
|
|
183
|
-
"#{payload.
|
|
262
|
+
"#{node_reference(payload.fetch('node'))}: #{payload['classification']}",
|
|
184
263
|
"Confidence: #{payload['confidence']} (score #{format('%.2f', payload['score'])})"
|
|
185
264
|
]
|
|
186
265
|
payload.fetch('components').each do |component|
|
|
@@ -190,13 +269,74 @@ module Necropsy
|
|
|
190
269
|
)
|
|
191
270
|
end
|
|
192
271
|
lines << format(' %<name>-28s %<value>0.2f', name: 'total', value: payload['score'])
|
|
272
|
+
append_blockers(lines, payload.fetch('blockers', []))
|
|
193
273
|
lines.join("\n")
|
|
194
274
|
end
|
|
195
275
|
|
|
276
|
+
def render_ambiguous(payload)
|
|
277
|
+
definitions = payload.fetch('definitions')
|
|
278
|
+
lines = [
|
|
279
|
+
"Ambiguous symbol ID: #{payload['node_id']}",
|
|
280
|
+
"Matched #{definitions.length} physical definitions:"
|
|
281
|
+
]
|
|
282
|
+
definitions.each do |definition|
|
|
283
|
+
lines << " #{definition['symbol_id']} [#{definition['definition_id']}] " \
|
|
284
|
+
"#{definition['file']}:#{definition['line']}"
|
|
285
|
+
lines << " why: #{definition.dig('commands', 'why')}"
|
|
286
|
+
lines << " why-not: #{definition.dig('commands', 'why_not')}"
|
|
287
|
+
lines << " explain: #{definition.dig('commands', 'explain')}"
|
|
288
|
+
end
|
|
289
|
+
lines.join("\n")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def append_blockers(lines, blockers)
|
|
293
|
+
blockers.each do |blocker|
|
|
294
|
+
metadata = blocker.fetch('metadata', {})
|
|
295
|
+
location = [metadata['file'], metadata['line']].compact.join(':')
|
|
296
|
+
caller = metadata['caller_id'] ? " caller=#{metadata['caller_id']}" : ''
|
|
297
|
+
lines << "Blocker: #{blocker['kind']} at #{location}#{caller}"
|
|
298
|
+
lines << " Scope: #{blocker['scope_kind']}=#{blocker['scope_value'].inspect} message=#{metadata['message']}"
|
|
299
|
+
lines << " Reason: #{blocker['reason']}"
|
|
300
|
+
lines << " Match: #{metadata['snippet']}" if metadata['snippet']
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
196
304
|
def render_missing(payload)
|
|
197
305
|
lines = ["Node not found: #{payload['node_id']}"]
|
|
198
306
|
lines << "Suggestions: #{payload['suggestions'].join(', ')}" unless payload['suggestions'].empty?
|
|
199
307
|
lines.join("\n")
|
|
200
308
|
end
|
|
309
|
+
|
|
310
|
+
def node_reference(node)
|
|
311
|
+
return 'unknown definition' unless node
|
|
312
|
+
|
|
313
|
+
symbol_id = node['symbol_id'] || node['id']
|
|
314
|
+
definition_id = node['definition_id'] || node['id']
|
|
315
|
+
"#{symbol_id} [#{definition_id}]"
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def with_source_incompleteness(payload)
|
|
319
|
+
return payload if graph.incomplete_files.empty?
|
|
320
|
+
|
|
321
|
+
payload.merge('source_incompleteness' => graph.source_incompleteness)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def append_source_incompleteness(rendered, payload)
|
|
325
|
+
diagnostic = payload['source_incompleteness']
|
|
326
|
+
return rendered unless diagnostic
|
|
327
|
+
|
|
328
|
+
lines = [rendered, "Incomplete source files: #{diagnostic['incomplete_files']}"]
|
|
329
|
+
diagnostic.fetch('files').each do |file|
|
|
330
|
+
errors = file.fetch('errors')
|
|
331
|
+
if errors.empty?
|
|
332
|
+
lines << " #{file['file']}:1 [#{file['status']}]"
|
|
333
|
+
else
|
|
334
|
+
errors.each do |error|
|
|
335
|
+
lines << " #{error['file']}:#{error['line']} [#{error['type']}] #{error['message']}"
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
lines.join("\n")
|
|
340
|
+
end
|
|
201
341
|
end
|
|
202
342
|
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
5
|
+
module Necropsy
|
|
6
|
+
module EmbeddedRuby
|
|
7
|
+
TAG_PATTERN = /<%(?![%#])[-=]?(.*?)-?%>/m
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def extract(source)
|
|
12
|
+
cursor = 0
|
|
13
|
+
output = +''
|
|
14
|
+
source.to_enum(:scan, TAG_PATTERN).each do
|
|
15
|
+
match = Regexp.last_match
|
|
16
|
+
output << blank(source[cursor...match.begin(0)])
|
|
17
|
+
output << strip_ruby_comments(match[1])
|
|
18
|
+
cursor = match.end(0)
|
|
19
|
+
end
|
|
20
|
+
output << blank(source[cursor..])
|
|
21
|
+
output
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call_names(source)
|
|
25
|
+
result = Prism.parse(extract(source))
|
|
26
|
+
return Set.new if result.failure?
|
|
27
|
+
|
|
28
|
+
names = Set.new
|
|
29
|
+
pending = [result.value]
|
|
30
|
+
until pending.empty?
|
|
31
|
+
node = pending.pop
|
|
32
|
+
names << node.name.to_s if node.is_a?(Prism::CallNode) && node.name
|
|
33
|
+
pending.concat(node.child_nodes.compact)
|
|
34
|
+
end
|
|
35
|
+
names
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def blank(source)
|
|
39
|
+
source.to_s.gsub(/[^\n]/, ' ')
|
|
40
|
+
end
|
|
41
|
+
private_class_method :blank
|
|
42
|
+
|
|
43
|
+
def strip_ruby_comments(source)
|
|
44
|
+
bytes = source.bytes
|
|
45
|
+
Prism.parse(source).comments.each do |comment|
|
|
46
|
+
location = comment.location
|
|
47
|
+
(location.start_offset...location.end_offset).each do |offset|
|
|
48
|
+
bytes[offset] = 32 unless bytes[offset] == 10
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
bytes.pack('C*').force_encoding(source.encoding)
|
|
52
|
+
end
|
|
53
|
+
private_class_method :strip_ruby_comments
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'prism'
|
|
4
|
+
|
|
3
5
|
module Necropsy
|
|
4
6
|
module EntryPoints
|
|
5
7
|
class Plain
|
|
@@ -13,41 +15,140 @@ module Necropsy
|
|
|
13
15
|
|
|
14
16
|
def apply(graph, project)
|
|
15
17
|
graph.nodes.values.select { |node| node.kind == :block_entry && !node.test }.each do |node|
|
|
16
|
-
|
|
18
|
+
pattern = SCRIPT_PATTERNS.find { |candidate| File.fnmatch?(candidate, node.file, File::FNM_PATHNAME) }
|
|
19
|
+
next unless pattern
|
|
17
20
|
|
|
18
|
-
graph.add_entry_point(
|
|
21
|
+
graph.add_entry_point(
|
|
22
|
+
node.graph_id,
|
|
23
|
+
:main_script,
|
|
24
|
+
domain: :runtime,
|
|
25
|
+
evidence: { 'type' => 'path_policy', 'pattern' => pattern, 'file' => node.file }
|
|
26
|
+
)
|
|
19
27
|
end
|
|
20
28
|
|
|
21
29
|
project.config.entry_point_patterns.each do |pattern|
|
|
22
|
-
graph.nodes.
|
|
23
|
-
|
|
30
|
+
graph.nodes.each_value do |node|
|
|
31
|
+
next if node.test
|
|
32
|
+
next unless File.fnmatch?(pattern, node.symbol_id)
|
|
33
|
+
|
|
34
|
+
graph.add_entry_point(
|
|
35
|
+
node.graph_id,
|
|
36
|
+
:public_api_declared,
|
|
37
|
+
domain: project.config.library_world? ? :external : :runtime,
|
|
38
|
+
evidence: { 'type' => 'configured_pattern', 'pattern' => pattern }
|
|
39
|
+
)
|
|
24
40
|
end
|
|
25
41
|
end
|
|
26
42
|
|
|
43
|
+
return if project.config.library_world?
|
|
44
|
+
|
|
27
45
|
api_files = primary_library_files(project)
|
|
28
46
|
graph.method_nodes.each do |node|
|
|
29
47
|
next unless api_files.include?(node.file)
|
|
30
48
|
next unless node.kind == :singleton_method && node.visibility == :public
|
|
31
49
|
|
|
32
|
-
graph.add_entry_point(
|
|
50
|
+
graph.add_entry_point(
|
|
51
|
+
node.graph_id,
|
|
52
|
+
:public_api_declared,
|
|
53
|
+
domain: :runtime,
|
|
54
|
+
evidence: { 'type' => 'gem_entry_file', 'file' => node.file }
|
|
55
|
+
)
|
|
33
56
|
end
|
|
34
57
|
end
|
|
35
58
|
|
|
36
59
|
private
|
|
37
60
|
|
|
38
61
|
def primary_library_files(project)
|
|
39
|
-
|
|
40
|
-
|
|
62
|
+
gemspec_metadata(project).flat_map do |metadata|
|
|
63
|
+
metadata.fetch(:require_paths).flat_map do |require_path|
|
|
64
|
+
name = metadata.fetch(:name)
|
|
65
|
+
["#{require_path}/#{name.tr('-', '/')}.rb", "#{require_path}/#{name.tr('-', '_')}.rb"]
|
|
66
|
+
end
|
|
41
67
|
end.uniq.select { |relative| File.file?(File.join(project.root, relative)) }
|
|
42
68
|
end
|
|
43
69
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
70
|
+
def gemspec_metadata(project)
|
|
71
|
+
project.reference_files.filter_map do |path|
|
|
72
|
+
relative = project.relative_path(path)
|
|
73
|
+
next unless relative.match?(%r{\A[^/]+\.gemspec\z})
|
|
74
|
+
|
|
75
|
+
parse_gemspec(File.read(path))
|
|
47
76
|
rescue SystemCallError, EncodingError
|
|
48
77
|
nil
|
|
49
78
|
end
|
|
50
79
|
end
|
|
80
|
+
|
|
81
|
+
def parse_gemspec(source)
|
|
82
|
+
result = Prism.parse(source)
|
|
83
|
+
return if result.failure?
|
|
84
|
+
|
|
85
|
+
specification = find_specification_call(result.value)
|
|
86
|
+
return unless specification&.block
|
|
87
|
+
|
|
88
|
+
parameter = required_block_parameter(specification.block)
|
|
89
|
+
return unless parameter.respond_to?(:name)
|
|
90
|
+
|
|
91
|
+
assignments = specification_assignments(specification.block.body, parameter.name)
|
|
92
|
+
name_node = first_argument(assignments[:name])
|
|
93
|
+
name = name_node.unescaped.to_s if name_node.is_a?(Prism::StringNode)
|
|
94
|
+
return if name.to_s.empty?
|
|
95
|
+
|
|
96
|
+
paths_node = first_argument(assignments[:require_paths])
|
|
97
|
+
paths = literal_string_array(paths_node)
|
|
98
|
+
{ name: name, require_paths: paths.empty? ? ['lib'] : paths }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def required_block_parameter(block)
|
|
102
|
+
block_parameters = block.parameters
|
|
103
|
+
parameters = block_parameters.parameters if block_parameters
|
|
104
|
+
Array(parameters&.requireds).first
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def first_argument(call)
|
|
108
|
+
arguments = call.arguments if call
|
|
109
|
+
Array(arguments&.arguments).first
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def find_specification_call(root)
|
|
113
|
+
pending = [root]
|
|
114
|
+
until pending.empty?
|
|
115
|
+
node = pending.pop
|
|
116
|
+
return node if node.is_a?(Prism::CallNode) && node.name == :new &&
|
|
117
|
+
constant_name(node.receiver) == 'Gem::Specification'
|
|
118
|
+
|
|
119
|
+
pending.concat(node.child_nodes.compact)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def specification_assignments(root, parameter_name)
|
|
124
|
+
pending = [root]
|
|
125
|
+
assignments = {}
|
|
126
|
+
until pending.empty?
|
|
127
|
+
node = pending.pop
|
|
128
|
+
if node.is_a?(Prism::CallNode) && node.receiver.is_a?(Prism::LocalVariableReadNode) &&
|
|
129
|
+
node.receiver.name == parameter_name
|
|
130
|
+
assignments[:name] = node if node.name == :name=
|
|
131
|
+
assignments[:require_paths] = node if node.name == :require_paths=
|
|
132
|
+
end
|
|
133
|
+
pending.concat(node.child_nodes.compact)
|
|
134
|
+
end
|
|
135
|
+
assignments
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def literal_string_array(node)
|
|
139
|
+
return [] unless node.is_a?(Prism::ArrayNode) && !node.contains_splat?
|
|
140
|
+
return [] unless node.elements.all?(Prism::StringNode)
|
|
141
|
+
|
|
142
|
+
node.elements.map { |element| element.unescaped.to_s }.reject(&:empty?).uniq.sort
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def constant_name(node)
|
|
146
|
+
case node
|
|
147
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
148
|
+
when Prism::ConstantPathNode
|
|
149
|
+
[constant_name(node.parent), node.name.to_s].compact.join('::')
|
|
150
|
+
end
|
|
151
|
+
end
|
|
51
152
|
end
|
|
52
153
|
end
|
|
53
154
|
end
|