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
@@ -4,34 +4,107 @@ module Necropsy
4
4
  class AstScanner
5
5
  private
6
6
 
7
- def build_call_site(node, context)
7
+ def build_call_sites(node, context)
8
+ messages = finite_dynamic_messages(node, context)
9
+ return [build_call_site(node, context)] if messages.empty?
10
+
11
+ messages.map { |message| build_call_site(node, context, message_override: message) }
12
+ end
13
+
14
+ def build_call_site(node, context, message_override: nil)
8
15
  message = node.name&.to_s
9
16
  dynamic = false
10
17
  receiver = classify_receiver(node.receiver, context)
11
18
  metadata = { 'original_message' => message, 'receiver_candidates' => receiver[:candidates] }
19
+ metadata['arguments'] = call_arguments(node, offset: DYNAMIC_SENDS.include?(node.name) ? 1 : 0)
20
+ metadata['block_kind'] = call_block_kind(node)
21
+ receiver_fact = context.flow_result&.fact_for(node.receiver)
22
+ compact_receiver_fact = compact_receiver_fact(receiver_fact)
23
+ metadata['receiver_value_fact'] = compact_receiver_fact if compact_receiver_fact
24
+ argument_facts = arguments(node).map do |argument|
25
+ compact_receiver_fact(context.flow_result&.fact_for(argument))
26
+ end
27
+ metadata['argument_value_facts'] = argument_facts if argument_facts.any?
28
+ load_reference = load_reference_metadata(node)
29
+ metadata['load_reference'] = load_reference if load_reference
12
30
 
13
31
  if DYNAMIC_SENDS.include?(node.name)
14
- literal = first_symbol_argument(node) || first_string_argument(node)
15
- dynamic = literal.nil?
16
- message = literal
32
+ literal = literal_argument(node, index: 0)
33
+ message = message_override || literal
34
+ dynamic = message.nil?
17
35
  metadata['dynamic_dispatch'] = true
36
+ metadata['finite_dynamic_dispatch'] = true if message_override
18
37
  end
19
38
 
20
39
  return nil unless message
21
40
 
22
- CallSite.new(
23
- caller_id: context.current_caller_id,
41
+ scanned_call_site(
42
+ source_node: node,
43
+ context: context,
44
+ role: :call,
24
45
  message: message,
25
46
  receiver_kind: receiver.fetch(:kind),
26
47
  receiver_name: receiver[:name],
27
- file: context.relative_file,
28
- line: node.location.start_line,
29
- test: context.test,
30
48
  dynamic: dynamic,
31
49
  metadata: metadata
32
50
  )
33
51
  end
34
52
 
53
+ def finite_dynamic_messages(node, context)
54
+ return [] unless DYNAMIC_SENDS.include?(node.name)
55
+ return [] if literal_argument(node, index: 0)
56
+
57
+ argument = Array(node.arguments&.arguments).first
58
+ fact = context.flow_result&.fact_for(argument)
59
+ return [] unless fact&.exact && %i[symbol_set string_set].include?(fact.kind)
60
+
61
+ Array(fact.values).map(&:to_s).uniq.sort.first(FlowInterpreter::MAX_ATOMS)
62
+ end
63
+
64
+ def load_reference_metadata(node)
65
+ argument_index = case node.name
66
+ when :require, :require_relative then 0
67
+ when :autoload then 1
68
+ else return
69
+ end
70
+ return unless load_primitive_receiver?(node)
71
+
72
+ path = literal_argument(node, index: argument_index)
73
+ { 'kind' => node.name.to_s, 'literal' => !path.nil?, 'path' => path }.compact
74
+ end
75
+
76
+ def load_primitive_receiver?(node)
77
+ return true unless node.receiver
78
+ return true if node.receiver.is_a?(Prism::SelfNode)
79
+
80
+ receiver = constant_name(node.receiver)
81
+ node.name == :autoload ? !receiver.nil? : receiver == 'Kernel'
82
+ end
83
+
84
+ def compact_receiver_fact(fact)
85
+ return unless fact&.exact
86
+ return unless %i[instance_types callable_set container].include?(fact.kind)
87
+
88
+ fact.to_h.merge('summary' => fact.kind == :container ? fact.summary : nil)
89
+ end
90
+
91
+ def call_block_kind(node)
92
+ case node.block
93
+ when nil
94
+ 'none'
95
+ when Prism::BlockNode
96
+ 'literal'
97
+ when Prism::BlockArgumentNode
98
+ case node.block.expression
99
+ when Prism::NilNode then 'nil'
100
+ when Prism::SymbolNode then 'symbol_to_proc'
101
+ else 'dynamic'
102
+ end
103
+ else
104
+ 'dynamic'
105
+ end
106
+ end
107
+
35
108
  def record_instantiation(node, context)
36
109
  return record_factory_instantiation(node, context) unless %i[new []].include?(node.name)
37
110
 
@@ -57,17 +130,16 @@ module Necropsy
57
130
  def record_initialize_call(node, context, receiver)
58
131
  return unless node.name == :new
59
132
 
60
- call_sites << CallSite.new(
61
- caller_id: context.current_caller_id,
133
+ add_scanned_call_site(
134
+ source_node: node,
135
+ context: context,
136
+ role: :initialize,
62
137
  message: 'initialize',
63
138
  receiver_kind: :instance,
64
139
  receiver_name: receiver[:name],
65
- file: context.relative_file,
66
- line: node.location.start_line,
67
- test: context.test,
68
140
  dynamic: false,
69
141
  metadata: { 'original_message' => 'new', 'receiver_candidates' => receiver[:candidates],
70
- 'implicit_from' => 'new' }
142
+ 'implicit_from' => 'new', 'arguments' => call_arguments(node) }
71
143
  )
72
144
  end
73
145
 
@@ -91,15 +163,32 @@ module Necropsy
91
163
  end
92
164
 
93
165
  def unresolved_dynamic_dispatch?(node)
94
- DYNAMIC_SENDS.include?(node.name) && first_symbol_argument(node).nil? && first_string_argument(node).nil?
166
+ DYNAMIC_SENDS.include?(node.name) && literal_argument(node, index: 0).nil?
95
167
  end
96
168
 
97
- def record_parse_errors(root_id, result)
169
+ def record_parse_errors(root_id, relative, result)
98
170
  result.errors.each do |error|
99
171
  uncertainties[root_id] << "Parse warning at line #{error.location.start_line}: #{error.message}"
172
+ source_errors << SourceError.new(
173
+ file: relative,
174
+ line: error.location.start_line,
175
+ message: error.message,
176
+ type: error.type
177
+ )
100
178
  end
101
179
  end
102
180
 
181
+ def record_source_failure(root_id, relative, error)
182
+ file_statuses[relative] = :failed
183
+ uncertainties[root_id] << "Could not parse #{relative}: #{error.message}"
184
+ source_errors << SourceError.new(
185
+ file: relative,
186
+ line: 1,
187
+ message: error.message,
188
+ type: error.class.name.to_sym
189
+ )
190
+ end
191
+
103
192
  def definition_owner(node, context)
104
193
  return context.owner unless node.receiver
105
194
  return context.owner if node.receiver.is_a?(Prism::SelfNode)
@@ -125,30 +214,60 @@ module Necropsy
125
214
  context.singleton_scope ? [:singleton_method, '.'] : [:instance_method, '#']
126
215
  end
127
216
 
128
- def update_method_visibility(context, name, visibility, singleton: false)
217
+ def update_method_visibility(context, name, visibility, source_node, singleton: false)
129
218
  separator = singleton ? '.' : method_kind_and_separator(context).last
130
219
  id = "#{context.owner}#{separator}#{name}"
131
- index = nodes.rindex { |candidate| candidate.id == id }
220
+ candidates = nodes.each_index.select { |index| nodes[index].symbol_id == id }
221
+ local = candidates.select do |index|
222
+ definition = nodes[index]
223
+ definition.file == context.relative_file && definition.line <= source_node.location.start_line
224
+ end
225
+ index = local.max_by { |candidate| [nodes[candidate].line, nodes[candidate].ordinal] }
132
226
  nodes[index] = nodes[index].with(visibility: visibility) if index
227
+ return if index && candidates == [index]
228
+
229
+ semantic_blockers << Blocker.new(
230
+ kind: :visibility_activation,
231
+ scope_kind: :message,
232
+ scope_value: name,
233
+ source: 'ast_scanner',
234
+ reason: "visibility activation for #{id} depends on repository load order",
235
+ suggested_action: :review_load_order,
236
+ metadata: {
237
+ 'caller_domain' => context.test ? 'test' : 'runtime',
238
+ 'caller_id' => context.current_caller_id,
239
+ 'owner_scope' => [context.owner],
240
+ 'file' => context.relative_file,
241
+ 'line' => source_node.location.start_line,
242
+ 'message' => name,
243
+ 'reason_code' => 'visibility_activation'
244
+ }
245
+ )
133
246
  end
134
247
 
135
- def promote_module_function(context, name, location)
248
+ def defer_module_function(context, name, source_node)
136
249
  instance_id = "#{context.owner}##{name}"
137
- index = nodes.rindex { |candidate| candidate.id == instance_id }
138
- return unless index
250
+ privatize_current_module_function_source(instance_id, context.relative_file)
251
+ copy = add_module_function_definition(context, name, source_node)
252
+ deferred_module_functions[copy.graph_id] = instance_id
253
+ end
254
+
255
+ def privatize_current_module_function_source(instance_id, relative_file)
256
+ index = nodes.rindex do |definition|
257
+ definition.kind == :instance_method && definition.symbol_id == instance_id && definition.file == relative_file
258
+ end
259
+ nodes[index] = nodes[index].with(visibility: :private) if index
260
+ end
139
261
 
140
- instance_node = nodes[index]
141
- nodes[index] = instance_node.with(visibility: :private)
142
- nodes << Node.new(
143
- id: "#{context.owner}.#{name}",
262
+ def add_module_function_definition(context, name, source_node)
263
+ add_definition(
264
+ symbol_id: "#{context.owner}.#{name}",
144
265
  kind: :singleton_method,
145
- file: context.relative_file,
146
- line: location.start_line,
147
- end_line: location.end_line,
266
+ source_node: source_node,
267
+ context: context,
148
268
  defined_via: :module_function,
149
269
  owner: context.owner,
150
270
  name: name,
151
- test: context.test,
152
271
  visibility: :public
153
272
  )
154
273
  end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class AstScanner
5
+ private
6
+
7
+ def scanned_call_site(source_node:, context:, role:, message:, receiver_kind:, receiver_name: nil,
8
+ caller_id: context.current_caller_id, dynamic: false, metadata: {})
9
+ structural_digest = DefinitionIdentity.body_digest(source_node)
10
+ ordinal_key = [caller_id, context.relative_file, role.to_sym, message.to_s, structural_digest]
11
+ ordinal = call_site_ordinals[ordinal_key] += 1
12
+ CallSite.new(
13
+ call_site_id: CallSiteIdentity.source_id(
14
+ caller_definition_id: caller_id,
15
+ relative_path: context.relative_file,
16
+ role: role,
17
+ message: message,
18
+ structural_digest: structural_digest,
19
+ ordinal: ordinal
20
+ ),
21
+ caller_id: caller_id,
22
+ message: message.to_s,
23
+ receiver_kind: receiver_kind,
24
+ receiver_name: receiver_name,
25
+ file: context.relative_file,
26
+ line: source_node.location.start_line,
27
+ test: context.test,
28
+ dynamic: dynamic,
29
+ metadata: metadata
30
+ )
31
+ end
32
+
33
+ def add_scanned_call_site(**attributes)
34
+ scanned_call_site(**attributes).tap { |site| call_sites << site }
35
+ end
36
+
37
+ def derived_call_site(site, derivation:, caller_id: site.caller_id, message: site.message, metadata: {})
38
+ site.with(
39
+ call_site_id: CallSiteIdentity.derived_id(
40
+ parent_call_site_id: site.call_site_id,
41
+ derivation: derivation,
42
+ caller_definition_id: caller_id,
43
+ message: message
44
+ ),
45
+ caller_id: caller_id,
46
+ message: message.to_s,
47
+ metadata: site.metadata.merge(
48
+ 'derived_from_call_site_id' => site.call_site_id,
49
+ 'derived_via' => derivation.to_s
50
+ ).merge(metadata)
51
+ )
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Necropsy
4
+ class AstScanner
5
+ private
6
+
7
+ def add_definition(symbol_id:, kind:, source_node:, context:, defined_via:, owner:, name:,
8
+ visibility: context.visibility)
9
+ body_digest = DefinitionIdentity.body_digest(source_node)
10
+ ordinal_key = [kind, symbol_id, context.relative_file, body_digest]
11
+ ordinal = definition_ordinals[ordinal_key] += 1
12
+ definition_id = if defined_via == :file
13
+ DefinitionIdentity.file_root_id(relative_path: context.relative_file)
14
+ else
15
+ DefinitionIdentity.definition_id(
16
+ kind: kind,
17
+ symbol_id: symbol_id,
18
+ relative_path: context.relative_file,
19
+ body_digest: body_digest,
20
+ ordinal: ordinal
21
+ )
22
+ end
23
+ definition = Node.new(
24
+ id: symbol_id,
25
+ symbol_id: symbol_id,
26
+ definition_id: definition_id,
27
+ body_digest: body_digest,
28
+ ordinal: ordinal,
29
+ kind: kind,
30
+ file: context.relative_file,
31
+ line: source_node.location.start_line,
32
+ end_line: source_node.location.end_line,
33
+ defined_via: defined_via,
34
+ owner: owner,
35
+ name: name.to_s,
36
+ test: context.test,
37
+ visibility: visibility
38
+ )
39
+ nodes << definition
40
+ definition
41
+ end
42
+ end
43
+ end