necropsy 0.1.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 (131) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +33 -0
  3. data/CHANGELOG.md +44 -0
  4. data/MEASUREMENTS.md +112 -0
  5. data/README.md +233 -16
  6. data/Rakefile +3 -1
  7. data/bench/README.md +92 -0
  8. data/bench/audit.rb +113 -0
  9. data/bench/audits/0.2.1/audit.json +501 -0
  10. data/bench/audits/0.2.1/audit.md +55 -0
  11. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  12. data/bench/audits/0.2.1/config.yml +42 -0
  13. data/bench/audits/0.2.1/review.yml +56 -0
  14. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  15. data/bench/corpora/v1/README.md +30 -0
  16. data/bench/corpora/v1/labels.yml +37 -0
  17. data/bench/corpora/v1/manifest.yml +63 -0
  18. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  19. data/bench/corpora/v1/self.necropsy.yml +8 -0
  20. data/bench/corpora/v1/tools/debride.yml +42 -0
  21. data/bench/corpora/v1/tools/spoom.yml +12 -0
  22. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  23. data/bench/golden/v1/candidate_union.json +21918 -0
  24. data/bench/golden/v1/metadata.json +12 -0
  25. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  26. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  27. data/bench/golden/v1/reports/rails.json +176 -0
  28. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  29. data/bench/golden/v1/reports/self.json +3449 -0
  30. data/bench/review_queue.rb +35 -0
  31. data/bench/run.rb +31 -0
  32. data/bench/schema/candidate-union-v1.schema.json +70 -0
  33. data/docs/impv_implementation_matrix.md +179 -0
  34. data/docs/migrations/0.2.1.md +57 -0
  35. data/docs/migrations/0.3.0.md +207 -0
  36. data/docs/migrations/0.4.0.md +13 -0
  37. data/docs/necropsy_performance_adr.md +43 -0
  38. data/docs/necropsy_scope_decisions.md +58 -0
  39. data/docs/necropsy_type_facts_adr.md +22 -0
  40. data/gemfiles/prism_min.gemfile +9 -0
  41. data/gemfiles/prism_min.gemfile.lock +50 -0
  42. data/lib/necropsy/analyzer.rb +121 -2
  43. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +81 -19
  44. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
  45. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
  46. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  47. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  50. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  51. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  52. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  53. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +225 -20
  54. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  55. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  56. data/lib/necropsy/analyzers/static/cha.rb +33 -69
  57. data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
  58. data/lib/necropsy/analyzers/static/rta.rb +249 -25
  59. data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
  60. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  61. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  62. data/lib/necropsy/ast_scanner/dsl_macros.rb +532 -0
  63. data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
  64. data/lib/necropsy/ast_scanner/references.rb +115 -0
  65. data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
  66. data/lib/necropsy/ast_scanner/traversal.rb +347 -0
  67. data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
  68. data/lib/necropsy/ast_scanner.rb +89 -564
  69. data/lib/necropsy/bench/candidate_union.rb +555 -0
  70. data/lib/necropsy/bench/claim_gate.rb +112 -0
  71. data/lib/necropsy/bench/evaluator.rb +356 -24
  72. data/lib/necropsy/bench/finding_facts.rb +152 -0
  73. data/lib/necropsy/bench/precision_gate.rb +144 -0
  74. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  75. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  76. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  77. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  78. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  79. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  80. data/lib/necropsy/bench/release_audit.rb +360 -0
  81. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  82. data/lib/necropsy/bench/review_queue.rb +154 -0
  83. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  84. data/lib/necropsy/bench/seed_runner.rb +408 -0
  85. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  86. data/lib/necropsy/cache/scan_cache.rb +94 -18
  87. data/lib/necropsy/call_site_identity.rb +54 -0
  88. data/lib/necropsy/cli.rb +296 -53
  89. data/lib/necropsy/clock.rb +40 -0
  90. data/lib/necropsy/confidence/scorer.rb +181 -58
  91. data/lib/necropsy/configuration.rb +339 -18
  92. data/lib/necropsy/convention_rules.rb +138 -0
  93. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  94. data/lib/necropsy/definition_identity.rb +37 -0
  95. data/lib/necropsy/diagnostics.rb +342 -0
  96. data/lib/necropsy/embedded_ruby.rb +55 -0
  97. data/lib/necropsy/entry_points/plain.rb +125 -7
  98. data/lib/necropsy/entry_points/rails.rb +422 -105
  99. data/lib/necropsy/entry_points/test.rb +6 -1
  100. data/lib/necropsy/flow_interpreter.rb +460 -0
  101. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  102. data/lib/necropsy/graph/call_graph.rb +1234 -62
  103. data/lib/necropsy/graph/definition_index.rb +149 -0
  104. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  105. data/lib/necropsy/graph/evidence_store.rb +213 -0
  106. data/lib/necropsy/graph/resolution_store.rb +497 -0
  107. data/lib/necropsy/graph_self_check.rb +79 -0
  108. data/lib/necropsy/guardrail/baseline.rb +359 -13
  109. data/lib/necropsy/guardrail/diff.rb +5 -2
  110. data/lib/necropsy/guardrail/quarantine.rb +101 -10
  111. data/lib/necropsy/load_graph.rb +206 -0
  112. data/lib/necropsy/models.rb +894 -21
  113. data/lib/necropsy/performance_profiler.rb +108 -0
  114. data/lib/necropsy/project.rb +361 -13
  115. data/lib/necropsy/reachability/engine.rb +80 -15
  116. data/lib/necropsy/reference_barrier.rb +458 -0
  117. data/lib/necropsy/report.rb +153 -14
  118. data/lib/necropsy/reporter.rb +441 -19
  119. data/lib/necropsy/runner.rb +258 -14
  120. data/lib/necropsy/runtime_feedback.rb +136 -0
  121. data/lib/necropsy/semantics_matrix.rb +153 -0
  122. data/lib/necropsy/trace_point_runtime.rb +19 -0
  123. data/lib/necropsy/type_facts.rb +53 -0
  124. data/lib/necropsy/version.rb +1 -1
  125. data/lib/necropsy/why_not_explanation.rb +436 -0
  126. data/lib/necropsy/why_not_renderer.rb +197 -0
  127. data/lib/necropsy/world_policy.rb +90 -0
  128. data/lib/necropsy.rb +39 -2
  129. data/schema/necropsy-report-v2.schema.json +366 -0
  130. data/script/measure.rb +20 -0
  131. metadata +106 -2
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'json'
4
4
  require 'yaml'
5
+ require_relative 'runtime_reference'
5
6
 
6
7
  module Necropsy
7
8
  module Analyzers
@@ -16,29 +17,64 @@ module Necropsy
16
17
  return AnalyzerResult.empty unless source
17
18
 
18
19
  payload = load_payload(File.expand_path(source, project.root))
19
- alive = Array(payload['executed'] || payload['nodes']).map do |node_id|
20
+ observation = ObservationPolicy.metadata(payload, expected_revision: config['expected_source_revision'])
21
+ malformed = []
22
+ alive = node_references(payload).map do |raw_reference|
23
+ node_id = normalized_or_raw(raw_reference, malformed, :node)
20
24
  AliveEvidence.new(
21
25
  node_id: node_id,
22
- evidence: evidence(kind: :alive, details: "Coverage marked #{node_id} as executed",
23
- metadata: payload['observation'] || {})
26
+ evidence: evidence(
27
+ kind: :alive,
28
+ details: "Coverage marked #{reference_label(node_id)} as executed",
29
+ metadata: observation.merge('node_reference' => node_id),
30
+ grade: :observed,
31
+ relation: :execution,
32
+ source: { 'type' => profile.name.to_s, 'node_reference' => node_id },
33
+ scope: ObservationPolicy.evidence_scope(observation).merge('node_reference' => node_id)
34
+ )
24
35
  )
25
36
  end
26
37
 
27
- edge_evidences = Array(payload['edges']).map do |edge|
28
- caller_id = edge['caller_id'] || edge[:caller_id]
29
- callee_id = edge['callee_id'] || edge[:callee_id]
38
+ edge_evidences = edge_references(payload).filter_map do |edge|
39
+ unless edge.is_a?(Hash)
40
+ malformed << malformed_reference(:edge, edge)
41
+ next
42
+ end
43
+
44
+ caller = edge['caller_id'] || edge[:caller_id]
45
+ callee = edge['callee_id'] || edge[:callee_id]
46
+ caller_id = normalized_or_raw(caller, malformed, :edge_caller)
47
+ callee_id = normalized_or_raw(callee, malformed, :edge_callee)
30
48
  EdgeEvidence.new(
31
49
  caller_id: caller_id,
32
50
  callee_id: callee_id,
33
- evidence: evidence(kind: :call_edge, details: "Coverage observed #{caller_id} -> #{callee_id}")
51
+ evidence: evidence(
52
+ kind: :call_edge,
53
+ details: "Coverage observed #{reference_label(caller_id)} -> #{reference_label(callee_id)}",
54
+ metadata: { 'caller_reference' => caller_id, 'callee_reference' => callee_id },
55
+ grade: :observed,
56
+ relation: :observed_call,
57
+ source: {
58
+ 'type' => profile.name.to_s,
59
+ 'caller_reference' => caller_id,
60
+ 'callee_reference' => callee_id
61
+ },
62
+ scope: ObservationPolicy.evidence_scope(observation).merge(
63
+ 'caller_reference' => caller_id,
64
+ 'callee_reference' => callee_id
65
+ )
66
+ )
34
67
  )
35
68
  end
69
+ observation = observation.merge('malformed_references' => malformed) unless malformed.empty?
36
70
 
37
71
  AnalyzerResult.new(
38
72
  edge_evidences: edge_evidences,
39
73
  alive_evidences: alive,
40
74
  uncertainties: {},
41
- observation: { 'coverage' => payload['observation'] || {} }
75
+ observation: { 'coverage' => observation },
76
+ resolutions: [],
77
+ evidences: result_evidences(edge_evidences, alive)
42
78
  )
43
79
  end
44
80
 
@@ -47,7 +83,9 @@ module Necropsy
47
83
  name: :coverage,
48
84
  kind: :dynamic,
49
85
  soundness: :observational,
50
- description: 'Imports method execution and observed edges from Ruby Coverage output.'
86
+ description: 'Imports method execution and observed edges from Ruby Coverage output.',
87
+ version: Necropsy::VERSION,
88
+ assumptions: ['positive_observations_only']
51
89
  )
52
90
  end
53
91
 
@@ -55,13 +93,69 @@ module Necropsy
55
93
 
56
94
  attr_reader :config
57
95
 
96
+ def node_references(payload)
97
+ structured = payload['node_references'] if payload.key?('node_references')
98
+ RuntimeReference.preferred(structured: structured, legacy: payload['executed'] || payload['nodes'])
99
+ end
100
+
101
+ def edge_references(payload)
102
+ structured = payload['edge_references'] if payload.key?('edge_references')
103
+ return Array(payload['edges']) if structured.nil?
104
+
105
+ structured_edges = Array(structured)
106
+ structured_pairs = structured_edges.filter_map { |edge| edge_symbol_pair(edge) }.to_set
107
+ structured_edges + Array(payload['edges']).reject do |edge|
108
+ structured_pairs.include?(edge_symbol_pair(edge))
109
+ end
110
+ end
111
+
112
+ def edge_symbol_pair(edge)
113
+ return unless edge.is_a?(Hash)
114
+
115
+ caller = RuntimeReference.normalize(edge['caller_id'] || edge[:caller_id])
116
+ callee = RuntimeReference.normalize(edge['callee_id'] || edge[:callee_id])
117
+ return unless caller && callee
118
+
119
+ [reference_symbol(caller), reference_symbol(callee)]
120
+ end
121
+
122
+ def reference_symbol(reference)
123
+ reference.is_a?(Hash) ? reference['symbol_id'] : reference
124
+ end
125
+
126
+ def normalized_or_raw(reference, malformed, kind)
127
+ normalized = RuntimeReference.normalize(reference)
128
+ return normalized if normalized
129
+
130
+ malformed << malformed_reference(kind, reference)
131
+ reference
132
+ end
133
+
134
+ def malformed_reference(kind, reference)
135
+ { 'kind' => kind.to_s, 'reference' => reference }
136
+ end
137
+
138
+ def reference_label(reference)
139
+ return reference.to_s unless reference.is_a?(Hash)
140
+
141
+ symbol = reference['symbol_id'] || reference[:symbol_id] || '<missing symbol>'
142
+ file = reference['file'] || reference[:file]
143
+ line = reference['line'] || reference[:line]
144
+ location = [file, line].compact.join(':')
145
+ location.empty? ? symbol.to_s : "#{symbol} at #{location}"
146
+ end
147
+
58
148
  def load_payload(path)
149
+ raise Error, "Coverage source does not exist: #{path}" unless File.file?(path)
150
+
59
151
  case File.extname(path)
60
152
  when '.json'
61
153
  JSON.parse(File.read(path))
62
154
  else
63
- YAML.load_file(path) || {}
155
+ YAML.safe_load_file(path, aliases: false) || {}
64
156
  end
157
+ rescue JSON::ParserError, Psych::Exception => e
158
+ raise Error, "Could not parse coverage source #{path}: #{e.message}"
65
159
  end
66
160
  end
67
161
  end
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'json'
4
- require 'openssl'
5
- require 'socket'
6
- require 'uri'
7
4
  require 'yaml'
8
5
 
6
+ require_relative 'redis_payload_loader'
7
+ require_relative 'runtime_reference'
8
+
9
9
  module Necropsy
10
10
  module Analyzers
11
11
  module Dynamic
@@ -19,11 +19,23 @@ module Necropsy
19
19
  return AnalyzerResult.empty unless source
20
20
 
21
21
  payload = load_payload(source, project.root)
22
- alive = alive_from_payload(graph, payload).map do |node|
22
+ observation = ObservationPolicy.metadata(payload, expected_revision: config['expected_source_revision'])
23
+ node_ids, line_diagnostics = alive_ids_from_payload(graph, payload)
24
+ observation = observation.merge(line_diagnostics) unless line_diagnostics.empty?
25
+ alive = node_ids.map do |node_id|
26
+ node = graph.nodes.lookup(node_id).node
27
+ details = node ? "Coverband executed #{node.file}:#{node.line}" : "Coverband marked #{node_id} as executed"
23
28
  AliveEvidence.new(
24
- node_id: node.id,
25
- evidence: evidence(kind: :alive, details: "Coverband executed #{node.file}:#{node.line}",
26
- metadata: payload['observation'] || {})
29
+ node_id: node_id,
30
+ evidence: evidence(
31
+ kind: :alive,
32
+ details: details,
33
+ metadata: observation.merge('node_reference' => node_id),
34
+ grade: :observed,
35
+ relation: :execution,
36
+ source: { 'type' => 'coverband', 'node_reference' => node_id },
37
+ scope: ObservationPolicy.evidence_scope(observation).merge('node_reference' => node_id)
38
+ )
27
39
  )
28
40
  end
29
41
 
@@ -31,7 +43,9 @@ module Necropsy
31
43
  edge_evidences: [],
32
44
  alive_evidences: alive,
33
45
  uncertainties: {},
34
- observation: { 'coverband' => payload['observation'] || {} }
46
+ observation: { 'coverband' => observation },
47
+ resolutions: [],
48
+ evidences: result_evidences(alive)
35
49
  )
36
50
  end
37
51
 
@@ -40,7 +54,9 @@ module Necropsy
40
54
  name: :coverband,
41
55
  kind: :dynamic,
42
56
  soundness: :observational,
43
- description: 'Imports file and line execution data compatible with coverband-style exports.'
57
+ description: 'Imports file and line execution data compatible with coverband-style exports.',
58
+ version: Necropsy::VERSION,
59
+ assumptions: %w[line_execution_mapping positive_observations_only]
44
60
  )
45
61
  end
46
62
 
@@ -52,29 +68,41 @@ module Necropsy
52
68
  return load_redis_payload(source) if source.start_with?('redis://', 'rediss://')
53
69
 
54
70
  path = File.expand_path(source, root)
71
+ raise Error, "Coverband source does not exist: #{path}" unless File.file?(path)
72
+
55
73
  case File.extname(path)
56
74
  when '.json'
57
75
  JSON.parse(File.read(path))
58
76
  else
59
- YAML.load_file(path) || {}
77
+ YAML.safe_load_file(path, aliases: true) || {}
60
78
  end
79
+ rescue JSON::ParserError, Psych::Exception => e
80
+ raise Error, "Could not parse Coverband source #{path}: #{e.message}"
61
81
  end
62
82
 
63
83
  def load_redis_payload(source)
64
84
  RedisPayloadLoader.new(source: source, config: config).load
65
85
  end
66
86
 
67
- def alive_from_payload(graph, payload)
68
- executed_nodes = Array(payload['executed'] || payload['nodes'])
69
- by_id = executed_nodes.filter_map { |id| graph.nodes[id] }
87
+ def alive_ids_from_payload(graph, payload)
88
+ structured = payload['node_references'] if payload.key?('node_references')
89
+ malformed = []
90
+ executed_nodes = RuntimeReference.preferred(
91
+ structured: structured,
92
+ legacy: payload['executed'] || payload['nodes']
93
+ ).map do |reference|
94
+ normalized = RuntimeReference.normalize(reference)
95
+ next normalized if normalized
70
96
 
71
- files = coverage_files(payload)
72
- by_line = graph.method_nodes.select do |node|
73
- executed_lines = lines_for(files, node.file)
74
- executed_lines.any? { |line| line.between?(node.line, node.end_line) }
97
+ malformed << { 'kind' => 'node', 'reference' => reference }
98
+ reference
75
99
  end
76
100
 
77
- (by_id + by_line).uniq(&:id)
101
+ files = coverage_files(payload)
102
+ by_line, diagnostics = line_references(graph, files)
103
+ diagnostics['malformed_references'] = malformed unless malformed.empty?
104
+
105
+ [(executed_nodes + by_line).uniq, diagnostics]
78
106
  end
79
107
 
80
108
  def coverage_files(payload)
@@ -131,244 +159,40 @@ module Necropsy
131
159
  left.merge(right) { |_file, left_lines, right_lines| (left_lines + right_lines).uniq }
132
160
  end
133
161
 
134
- def lines_for(files, relative_file)
135
- exact = files[relative_file] || files[File.join('.', relative_file)]
136
- return exact if exact
137
-
138
- files.each do |file, lines|
139
- return lines if file.end_with?("/#{relative_file}")
140
- end
141
-
142
- []
143
- end
144
- end
145
-
146
- class RedisPayloadLoader
147
- DEFAULT_PATTERN = 'coverband*'
148
-
149
- def initialize(source:, config:)
150
- @uri = URI(source)
151
- @config = config
152
- @socket = nil
153
- end
154
-
155
- def load
156
- connect
157
- authenticate
158
- select_database
159
- merge_payloads(keys.filter_map { |key| payload_for_key(key) })
160
- ensure
161
- socket&.close
162
- end
163
-
164
- private
165
-
166
- attr_reader :uri, :config, :socket
167
-
168
- def connect
169
- tcp_socket = TCPSocket.new(uri.host, uri.port || 6379)
170
- @socket = uri.scheme == 'rediss' ? tls_socket(tcp_socket) : tcp_socket
171
- end
172
-
173
- def tls_socket(tcp_socket)
174
- context = OpenSSL::SSL::SSLContext.new
175
- socket = OpenSSL::SSL::SSLSocket.new(tcp_socket, context)
176
- socket.hostname = uri.host if socket.respond_to?(:hostname=)
177
- socket.connect
178
- socket
179
- end
180
-
181
- def authenticate
182
- return unless uri.password
183
-
184
- command('AUTH', uri.password)
185
- end
186
-
187
- def select_database
188
- db = uri.path.to_s.delete_prefix('/')
189
- command('SELECT', db) unless db.empty?
190
- end
191
-
192
- def keys
193
- configured = Array(config['keys'] || config['key'] || query['key']).compact
194
- return configured unless configured.empty?
195
-
196
- scan(pattern: config['pattern'] || query['pattern'] || DEFAULT_PATTERN)
197
- end
198
-
199
- def query
200
- @query ||= URI.decode_www_form(uri.query.to_s).to_h
201
- end
202
-
203
- def scan(pattern:)
204
- cursor = '0'
205
- found = []
206
- loop do
207
- cursor, keys = command('SCAN', cursor, 'MATCH', pattern)
208
- found.concat(keys)
209
- break if cursor == '0'
210
- end
211
- found
212
- end
213
-
214
- def command(*parts)
215
- socket.write(redis_command(parts))
216
- read_response
217
- end
218
-
219
- def payload_for_key(key)
220
- parse_payload(command('GET', key))
221
- rescue Error => e
222
- raise unless e.message.include?('WRONGTYPE')
223
-
224
- parse_hash_payload(command('HGETALL', key))
225
- end
226
-
227
- def redis_command(parts)
228
- ["*#{parts.length}", *parts.flat_map { |part| ["$#{part.to_s.bytesize}", part.to_s] }].join("\r\n") + "\r\n"
229
- end
230
-
231
- def read_response
232
- prefix = socket.read(1)
233
- case prefix
234
- when '+'
235
- socket.gets("\r\n").delete_suffix("\r\n")
236
- when '-'
237
- raise Error, socket.gets("\r\n").delete_suffix("\r\n")
238
- when ':'
239
- socket.gets("\r\n").to_i
240
- when '$'
241
- read_bulk_string
242
- when '*'
243
- Array.new(socket.gets("\r\n").to_i) { read_response }
244
- else
245
- raise Error, "Unsupported Redis response prefix #{prefix.inspect}"
246
- end
247
- end
248
-
249
- def read_bulk_string
250
- length = socket.gets("\r\n").to_i
251
- return nil if length.negative?
252
-
253
- value = socket.read(length)
254
- socket.read(2)
255
- value
256
- end
257
-
258
- def parse_payload(value)
259
- return nil if value.nil? || value.empty?
260
-
261
- JSON.parse(value)
262
- rescue JSON::ParserError
263
- YAML.load(value)
264
- rescue Psych::SyntaxError
265
- Marshal.load(value)
266
- end
267
-
268
- def parse_hash_payload(entries)
269
- hash = Array(entries).each_slice(2).to_h
270
- return coverband_hash_payload(hash) if coverband_hash_payload?(hash)
271
-
272
- payloads = []
273
- files = {}
274
- hash.each do |field, value|
275
- parsed = parse_payload(value)
276
- if payload_shape?(parsed)
277
- payloads << parsed
278
- else
279
- files[field.to_s] = parsed
280
- end
281
- end
282
-
283
- payloads << { 'files' => files } unless files.empty?
284
- merge_payloads(payloads)
285
- end
286
-
287
- def coverband_hash_payload?(hash)
288
- hash.key?('file') && hash.key?('file_length')
289
- end
290
-
291
- def coverband_hash_payload(hash)
292
- {
293
- 'files' => { hash.fetch('file') => coverband_line_counts(hash) },
294
- 'observation' => coverband_observation(hash)
295
- }
296
- end
297
-
298
- def coverband_line_counts(hash)
299
- length = hash.fetch('file_length').to_i
300
- Array.new(length) do |index|
301
- count = hash[index.to_s]
302
- count&.to_i
303
- end
304
- end
305
-
306
- def coverband_observation(hash)
307
- %w[first_updated_at last_updated_at file_hash].each_with_object({}) do |key, observation|
308
- observation[key] = hash[key] if hash[key]
309
- end
310
- end
311
-
312
- def payload_shape?(value)
313
- value.is_a?(Hash) && value.keys.any? do |key|
314
- %w[files coverage runtime production nodes executed observation].include?(key.to_s)
315
- end
316
- end
317
-
318
- def merge_payloads(payloads)
319
- payloads.compact.reduce({}) { |merged, payload| merge_payload(merged, payload) }
320
- end
321
-
322
- def merge_payload(merged, payload)
323
- merged.merge(payload) do |key, left, right|
324
- case key
325
- when 'files', 'coverage', 'runtime', 'production'
326
- merge_files(left, right)
327
- when 'nodes', 'executed'
328
- (Array(left) + Array(right)).uniq
329
- when 'edges'
330
- Array(left) + Array(right)
331
- when 'observation'
332
- left.merge(right)
333
- else
334
- right
162
+ def line_references(graph, files)
163
+ definition_ids = []
164
+ line_ambiguities = []
165
+ path_ambiguities = []
166
+ graph.method_nodes.group_by(&:file).sort.each do |relative_file, definitions|
167
+ paths = matching_coverage_paths(files, relative_file)
168
+ next if paths.empty?
169
+
170
+ path_ambiguities << { 'file' => relative_file, 'coverage_paths' => paths } if paths.length > 1
171
+ paths.flat_map { |path| files.fetch(path) }.uniq.sort.each do |line|
172
+ matches = definitions.select { |definition| line.between?(definition.line, definition.end_line) }
173
+ definition_ids.concat(matches.map(&:graph_id))
174
+ next unless matches.length > 1
175
+
176
+ line_ambiguities << {
177
+ 'file' => relative_file,
178
+ 'line' => line,
179
+ 'definition_ids' => matches.map(&:graph_id).sort
180
+ }
335
181
  end
336
182
  end
337
- end
338
183
 
339
- def merge_files(left, right)
340
- return right unless left.is_a?(Hash)
341
- return left unless right.is_a?(Hash)
342
-
343
- left.merge(right) do |_file, left_lines, right_lines|
344
- merge_file_coverage(left_lines, right_lines)
345
- end
346
- end
347
-
348
- def merge_file_coverage(left, right)
349
- return merge_count_hashes(left, right) if left.is_a?(Hash) && right.is_a?(Hash)
350
- return merge_count_arrays(left, right) if count_array?(left) || count_array?(right)
351
-
352
- (Array(left) + Array(right)).uniq
184
+ diagnostics = {}
185
+ diagnostics['path_ambiguities'] = path_ambiguities unless path_ambiguities.empty?
186
+ diagnostics['line_ambiguities'] = line_ambiguities unless line_ambiguities.empty?
187
+ [definition_ids.uniq.sort, diagnostics]
353
188
  end
354
189
 
355
- def merge_count_hashes(left, right)
356
- left.merge(right) { |_line, left_count, right_count| left_count.to_i + right_count.to_i }
357
- end
358
-
359
- def merge_count_arrays(left, right)
360
- length = [Array(left).length, Array(right).length].max
361
- Array.new(length) do |index|
362
- left_value = Array(left)[index]
363
- right_value = Array(right)[index]
364
- next if left_value.nil? && right_value.nil?
365
-
366
- left_value.to_i + right_value.to_i
367
- end
368
- end
190
+ def matching_coverage_paths(files, relative_file)
191
+ exact = [relative_file, File.join('.', relative_file)].select { |path| files.key?(path) }
192
+ return exact.sort unless exact.empty?
369
193
 
370
- def count_array?(value)
371
- value.is_a?(Array) && value.any? { |item| item.nil? || item.to_i.zero? }
194
+ suffix = "/#{relative_file}"
195
+ files.keys.select { |path| path.end_with?(suffix) }.sort
372
196
  end
373
197
  end
374
198
  end