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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/MEASUREMENTS.md +27 -0
  4. data/README.md +182 -17
  5. data/bench/README.md +92 -0
  6. data/bench/audit.rb +113 -0
  7. data/bench/audits/0.2.1/audit.json +501 -0
  8. data/bench/audits/0.2.1/audit.md +55 -0
  9. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  10. data/bench/audits/0.2.1/config.yml +42 -0
  11. data/bench/audits/0.2.1/review.yml +56 -0
  12. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  13. data/bench/corpora/v1/README.md +30 -0
  14. data/bench/corpora/v1/labels.yml +37 -0
  15. data/bench/corpora/v1/manifest.yml +63 -0
  16. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  17. data/bench/corpora/v1/self.necropsy.yml +8 -0
  18. data/bench/corpora/v1/tools/debride.yml +42 -0
  19. data/bench/corpora/v1/tools/spoom.yml +12 -0
  20. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  21. data/bench/golden/v1/candidate_union.json +21918 -0
  22. data/bench/golden/v1/metadata.json +12 -0
  23. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  24. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  25. data/bench/golden/v1/reports/rails.json +176 -0
  26. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  27. data/bench/golden/v1/reports/self.json +3449 -0
  28. data/bench/review_queue.rb +35 -0
  29. data/bench/run.rb +31 -0
  30. data/bench/schema/candidate-union-v1.schema.json +70 -0
  31. data/docs/impv_implementation_matrix.md +179 -0
  32. data/docs/migrations/0.2.1.md +57 -0
  33. data/docs/migrations/0.3.0.md +207 -0
  34. data/docs/migrations/0.4.0.md +13 -0
  35. data/docs/necropsy_performance_adr.md +43 -0
  36. data/docs/necropsy_scope_decisions.md +58 -0
  37. data/docs/necropsy_type_facts_adr.md +22 -0
  38. data/gemfiles/prism_min.gemfile +9 -0
  39. data/gemfiles/prism_min.gemfile.lock +50 -0
  40. data/lib/necropsy/analyzer.rb +121 -2
  41. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
  42. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
  43. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
  44. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  45. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  46. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  47. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  50. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  51. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
  52. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  53. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  54. data/lib/necropsy/analyzers/static/cha.rb +33 -73
  55. data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
  56. data/lib/necropsy/analyzers/static/rta.rb +237 -29
  57. data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
  58. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  59. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  60. data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
  61. data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
  62. data/lib/necropsy/ast_scanner/references.rb +43 -9
  63. data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
  64. data/lib/necropsy/ast_scanner/traversal.rb +234 -71
  65. data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
  66. data/lib/necropsy/ast_scanner.rb +67 -6
  67. data/lib/necropsy/bench/candidate_union.rb +555 -0
  68. data/lib/necropsy/bench/claim_gate.rb +112 -0
  69. data/lib/necropsy/bench/evaluator.rb +329 -16
  70. data/lib/necropsy/bench/finding_facts.rb +152 -0
  71. data/lib/necropsy/bench/precision_gate.rb +144 -0
  72. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  73. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  74. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  75. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  76. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  77. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  78. data/lib/necropsy/bench/release_audit.rb +360 -0
  79. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  80. data/lib/necropsy/bench/review_queue.rb +154 -0
  81. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  82. data/lib/necropsy/bench/seed_runner.rb +408 -0
  83. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  84. data/lib/necropsy/cache/scan_cache.rb +85 -17
  85. data/lib/necropsy/call_site_identity.rb +54 -0
  86. data/lib/necropsy/cli.rb +220 -33
  87. data/lib/necropsy/clock.rb +40 -0
  88. data/lib/necropsy/confidence/scorer.rb +103 -58
  89. data/lib/necropsy/configuration.rb +224 -21
  90. data/lib/necropsy/convention_rules.rb +138 -0
  91. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  92. data/lib/necropsy/definition_identity.rb +37 -0
  93. data/lib/necropsy/diagnostics.rb +176 -36
  94. data/lib/necropsy/embedded_ruby.rb +55 -0
  95. data/lib/necropsy/entry_points/plain.rb +111 -10
  96. data/lib/necropsy/entry_points/rails.rb +322 -41
  97. data/lib/necropsy/entry_points/test.rb +6 -1
  98. data/lib/necropsy/flow_interpreter.rb +460 -0
  99. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  100. data/lib/necropsy/graph/call_graph.rb +1099 -109
  101. data/lib/necropsy/graph/definition_index.rb +149 -0
  102. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  103. data/lib/necropsy/graph/evidence_store.rb +213 -0
  104. data/lib/necropsy/graph/resolution_store.rb +497 -0
  105. data/lib/necropsy/graph_self_check.rb +79 -0
  106. data/lib/necropsy/guardrail/baseline.rb +350 -13
  107. data/lib/necropsy/guardrail/quarantine.rb +94 -9
  108. data/lib/necropsy/load_graph.rb +206 -0
  109. data/lib/necropsy/models.rb +878 -13
  110. data/lib/necropsy/performance_profiler.rb +108 -0
  111. data/lib/necropsy/project.rb +327 -25
  112. data/lib/necropsy/reachability/engine.rb +54 -13
  113. data/lib/necropsy/reference_barrier.rb +458 -0
  114. data/lib/necropsy/report.rb +113 -4
  115. data/lib/necropsy/reporter.rb +431 -15
  116. data/lib/necropsy/runner.rb +233 -18
  117. data/lib/necropsy/runtime_feedback.rb +136 -0
  118. data/lib/necropsy/semantics_matrix.rb +153 -0
  119. data/lib/necropsy/type_facts.rb +53 -0
  120. data/lib/necropsy/version.rb +1 -1
  121. data/lib/necropsy/why_not_explanation.rb +436 -0
  122. data/lib/necropsy/why_not_renderer.rb +197 -0
  123. data/lib/necropsy/world_policy.rb +90 -0
  124. data/lib/necropsy.rb +35 -2
  125. data/schema/necropsy-report-v2.schema.json +366 -0
  126. metadata +85 -1
@@ -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
- node = graph.nodes[node_id]
17
- return missing_payload(node_id) unless node
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
- runtime_path = reachability.witness(node_id)
20
- return alive_payload(node, runtime_path, :runtime) if runtime_path
21
+ node = lookup.node
21
22
 
22
- test_path = reachability.witness(node_id, kind: :test)
23
- return alive_payload(node, test_path, :test) if test_path
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
- dead_payload(node)
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
- node = graph.nodes[node_id]
30
- return missing_payload(node_id) unless node
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
- finding = report.findings.find { |candidate| candidate.node.id == node_id }
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
- entry = graph.entry_points.find { |candidate| candidate.node_id == node_id }
82
- step['entry_reason'] = entry.reason.to_s if entry
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
- finding = report.findings.find { |candidate| candidate.node.id == node.id }
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.id),
101
- 'uncertainties' => graph.uncertainties(node.id)
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
- return { 'node_id' => neighbor, 'kind' => kinds.fetch(neighbor), 'distance' => distance } if kinds.key?(neighbor)
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.keys.select do |candidate|
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
- 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
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.dig('node', 'id')}"]
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
- lines << " #{index}. #{step.dig('node', 'id')}#{suffix}"
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
- lines = ["Dead: #{payload.dig('node', 'id')}"]
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: #{nearest['node_id']} (#{nearest['kind']}, distance #{nearest['distance']})"
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.dig('node', 'id')}: #{payload['classification']}",
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
- next unless SCRIPT_PATTERNS.any? { |pattern| File.fnmatch?(pattern, node.file, File::FNM_PATHNAME) }
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(node.id, :main_script)
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.each_key do |node_id|
23
- graph.add_entry_point(node_id, :public_api_declared) if File.fnmatch?(pattern, node_id)
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(node.id, :public_api_declared)
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
- gem_names(project).flat_map do |name|
40
- ["lib/#{name.tr('-', '/')}.rb", "lib/#{name.tr('-', '_')}.rb"]
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 gem_names(project)
45
- Dir.glob(File.join(project.root, '*.gemspec')).filter_map do |path|
46
- File.read(path)[/\.name\s*=\s*["']([^"']+)["']/, 1]
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