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
@@ -2,13 +2,44 @@
2
2
 
3
3
  module Necropsy
4
4
  class Reporter
5
- FORMATS = %i[human json yaml yml sarif github annotations].freeze
5
+ FORMATS = %i[human json ndjson yaml yml sarif github annotations].freeze
6
6
  DEFAULT_MIN_CONFIDENCE = :medium
7
+ DEFINITION_RESOLUTION_SAMPLE_LIMIT = 5
7
8
 
8
9
  def initialize(report)
9
10
  @report = report
10
11
  end
11
12
 
13
+ def self.render_baseline_review(review_report)
14
+ lines = [
15
+ 'Baseline migration requires review',
16
+ "Baseline: #{review_report.fetch('baseline_path')}",
17
+ "Schema: v#{review_report.fetch('baseline_schema_version')}",
18
+ "Ambiguous mappings: #{review_report.fetch('ambiguities').length}"
19
+ ]
20
+ review_report.fetch('ambiguities').each do |ambiguity|
21
+ baseline = ambiguity.fetch('baseline')
22
+ label = baseline['definition_id'] || baseline['symbol_id'] || baseline['node_id'] || baseline['fingerprint']
23
+ lines << " #{label} via #{ambiguity.fetch('strategy')} (#{ambiguity.fetch('reason')})"
24
+ ambiguity.fetch('candidates').each do |candidate|
25
+ lines << " #{candidate.fetch('symbol_id')} [#{candidate.fetch('definition_id')}] " \
26
+ "#{candidate.fetch('file')}:#{candidate.fetch('line')}"
27
+ end
28
+ end
29
+ lines << 'Regenerate the baseline after reviewing every ambiguous physical definition.'
30
+ lines.join("\n")
31
+ end
32
+
33
+ def self.render_analysis_health(analysis_health)
34
+ lines = ["Analysis health: #{analysis_health.status}"]
35
+ analysis_health.reasons.each do |reason|
36
+ location = [reason['file'], reason['line']].compact.join(':')
37
+ suffix = location.empty? ? '' : " at #{location}"
38
+ lines << " [#{reason.fetch('severity')}] #{reason.fetch('code')}#{suffix}: #{reason['message']}"
39
+ end
40
+ lines.join("\n")
41
+ end
42
+
12
43
  def render(format: :human, min_confidence: DEFAULT_MIN_CONFIDENCE, include_graph: false)
13
44
  normalized_format = format.to_sym
14
45
  raise Error, "Unknown report format: #{format}" unless FORMATS.include?(normalized_format)
@@ -16,6 +47,8 @@ module Necropsy
16
47
  case normalized_format
17
48
  when :json
18
49
  report.to_json(include_graph: include_graph)
50
+ when :ndjson
51
+ each_ndjson.to_a.join("\n")
19
52
  when :sarif
20
53
  render_sarif(min_confidence)
21
54
  when :github, :annotations
@@ -27,42 +60,275 @@ module Necropsy
27
60
  end
28
61
  end
29
62
 
63
+ def each_ndjson
64
+ return enum_for(__method__) unless block_given?
65
+
66
+ yield ndjson_record('report', report.to_h(include_graph: false))
67
+ graph = report.graph
68
+ {
69
+ 'node' => graph.nodes.values,
70
+ 'call_site' => graph.call_sites,
71
+ 'edge' => graph.edges,
72
+ 'edge_relation' => graph.edge_relations,
73
+ 'evidence' => graph.evidence_records,
74
+ 'entry_point' => graph.entry_points,
75
+ 'class_info' => graph.class_infos.values,
76
+ 'profile' => graph.profiles,
77
+ 'resolution' => graph.resolution_records,
78
+ 'blocker' => graph.blockers,
79
+ 'source_error' => graph.source_errors
80
+ }.each do |record_type, records|
81
+ records.each { |record| yield ndjson_record(record_type, record.to_h) }
82
+ end
83
+ yield ndjson_record('graph_metadata', {
84
+ 'edge_projection' => 'conservative',
85
+ 'instantiated_classes' => graph.instantiated_classes.to_a.sort,
86
+ 'file_statuses' => graph.file_statuses.transform_values(&:to_s),
87
+ 'source_domains' => graph.source_domains.transform_values(&:to_s),
88
+ 'scope_diagnostics' => graph.scope_diagnostics,
89
+ 'observation' => graph.observation
90
+ })
91
+ end
92
+
30
93
  private
31
94
 
32
95
  attr_reader :report
33
96
 
97
+ def ndjson_record(record_type, data)
98
+ JSON.generate('schema' => 'necropsy.graph.ndjson.v1', 'record' => record_type, 'data' => data)
99
+ end
100
+
34
101
  def render_human(min_confidence)
35
- findings = report.dead_methods(min_confidence: min_confidence)
102
+ findings = (report.dead_methods(min_confidence: min_confidence) + report.blocked_methods).uniq
36
103
  lines = [
37
104
  'Necropsy report',
38
105
  "Root: #{report.root}",
39
106
  "Nodes: #{report.summary['nodes']}, Edges: #{report.summary['edges']}, Entry points: #{report.summary['entry_points']}",
107
+ "Incomplete source files: #{report.summary['incomplete_files']}",
108
+ "Analysis health: #{report.analysis_health.status} (#{report.analysis_health.reasons.length} issues)",
40
109
  "Findings: #{findings.length}"
41
110
  ]
111
+ report.analysis_health.reasons.each do |reason|
112
+ lines << " health #{reason.fetch('severity')}: #{reason.fetch('code')}"
113
+ end
114
+ append_dynamic_diagnostic(lines)
115
+ append_definition_resolution_diagnostic(lines)
116
+ append_source_diagnostic(lines)
117
+ append_analysis_scope_diagnostic(lines)
118
+ append_reference_barrier_diagnostic(lines)
42
119
 
43
120
  findings.group_by(&:classification).sort_by do |classification, _|
44
121
  classification.to_s
45
122
  end.each do |classification, group|
46
123
  lines << ''
47
124
  lines << "#{classification} (#{group.length})"
48
- group.sort_by { |finding| [finding.node.file, finding.node.line, finding.node.id] }.each do |finding|
49
- lines << " [#{finding.confidence}] #{finding.node.id} #{finding.node.file}:#{finding.node.line}"
125
+ group.sort_by do |finding|
126
+ [finding.node.file, finding.node.line, finding.node.id, finding.node.definition_id]
127
+ end.each do |finding|
128
+ lines << " [#{finding.confidence}] #{finding.node.symbol_id} [#{finding.node.definition_id}] " \
129
+ "#{finding.node.file}:#{finding.node.line}"
130
+ append_finding_blockers(lines, finding)
50
131
  end
51
132
  end
52
133
 
53
134
  lines.join("\n")
54
135
  end
55
136
 
137
+ def append_finding_blockers(lines, finding)
138
+ finding.blockers.each do |blocker|
139
+ metadata = blocker.metadata
140
+ location = [metadata['file'] || metadata[:file], metadata['line'] || metadata[:line]].compact.join(':')
141
+ caller = metadata['caller_id'] || metadata[:caller_id]
142
+ message = metadata['message'] || metadata[:message] || blocker.message
143
+ lines << " blocker #{blocker.kind} at #{location} caller=#{caller}"
144
+ lines << " scope #{blocker.scope_kind}=#{blocker.scope_value.inspect} message=#{message}"
145
+ lines << " reason #{blocker.reason}"
146
+ lines << " match #{metadata['snippet']}" if metadata['snippet']
147
+ end
148
+ end
149
+
150
+ def append_dynamic_diagnostic(lines)
151
+ diagnostic = report.diagnostics['dynamic_evidence']
152
+ return unless diagnostic
153
+
154
+ attempted = diagnostic.fetch('attempted')
155
+ matched = diagnostic.fetch('matched')
156
+ partially_matched = diagnostic.fetch('partially_matched')
157
+ unmatched = diagnostic.fetch('unmatched')
158
+ samples = diagnostic.fetch('unmatched_samples').values.flatten
159
+ lines << "Dynamic evidence (positive-only): nodes attempted=#{attempted['nodes']} matched=#{matched['nodes']} " \
160
+ "partial=#{partially_matched['nodes']} unmatched=#{unmatched['nodes']}; " \
161
+ "edges attempted=#{attempted['edges']} matched=#{matched['edges']} " \
162
+ "partial=#{partially_matched['edges']} unmatched=#{unmatched['edges']}"
163
+ lines << "Unmatched dynamic evidence: #{samples.join(', ')}" unless samples.empty?
164
+ append_dynamic_resolution_samples(lines, diagnostic)
165
+ end
166
+
167
+ def append_dynamic_resolution_samples(lines, diagnostic)
168
+ resolution_samples = diagnostic['resolution_samples'] || diagnostic[:resolution_samples]
169
+ return unless resolution_samples.is_a?(Hash)
170
+
171
+ samples = %w[nodes edge_endpoints].flat_map do |kind|
172
+ Array(resolution_samples[kind] || resolution_samples[kind.to_sym])
173
+ end.select { |sample| (sample['status'] || sample[:status]).to_s == 'ambiguous' }
174
+ resolution = diagnostic['resolution'] || diagnostic[:resolution]
175
+ count = dynamic_ambiguous_resolution_count(resolution, samples)
176
+ return unless count.positive?
177
+
178
+ lines << "Ambiguous runtime references: #{count}"
179
+ rendered_count = [count, DEFINITION_RESOLUTION_SAMPLE_LIMIT].min
180
+ samples.first(rendered_count).each do |sample|
181
+ endpoint = sample['endpoint'] || sample[:endpoint]
182
+ prefix = endpoint ? "#{endpoint} " : ''
183
+ ids = Array(sample['definition_ids'] || sample[:definition_ids])
184
+ lines << " #{prefix}#{definition_resolution_identifier(sample)} -> #{ids.join(', ')}"
185
+ end
186
+ omitted = count - [samples.length, rendered_count].min
187
+ lines << " ... #{omitted} more" if omitted.positive?
188
+ end
189
+
190
+ def dynamic_ambiguous_resolution_count(resolution, samples)
191
+ return samples.length unless resolution.is_a?(Hash)
192
+
193
+ %w[nodes edge_endpoints].sum do |kind|
194
+ counts = resolution[kind] || resolution[kind.to_sym]
195
+ counts.is_a?(Hash) ? Integer(counts['ambiguous'] || counts[:ambiguous] || 0) : 0
196
+ end
197
+ rescue ArgumentError, TypeError
198
+ samples.length
199
+ end
200
+
201
+ def append_source_diagnostic(lines)
202
+ diagnostic = report.diagnostics['source_incompleteness']
203
+ return unless diagnostic
204
+
205
+ diagnostic.fetch('files').each do |file|
206
+ errors = file.fetch('errors')
207
+ if errors.empty?
208
+ lines << "Incomplete source: #{file['file']}:1 [#{file['status']}]"
209
+ next
210
+ end
211
+
212
+ errors.each do |error|
213
+ lines << "Incomplete source: #{error['file']}:#{error['line']} [#{error['type']}] #{error['message']}"
214
+ end
215
+ end
216
+ end
217
+
218
+ def append_analysis_scope_diagnostic(lines)
219
+ diagnostic = report.diagnostics['analysis_scope']
220
+ return unless diagnostic
221
+
222
+ reference_only = Array(diagnostic['reference_only_ruby_files'])
223
+ excluded_callers = diagnostic['potential_callers_outside_reference'] || {}
224
+ lines << "Analysis scope: analyzed Ruby=#{diagnostic.fetch('analyze_file_count')}, " \
225
+ "reference files=#{diagnostic.fetch('reference_file_count')}, " \
226
+ "reference-only Ruby=#{reference_only.length}"
227
+ if excluded_callers.fetch('count', 0).positive?
228
+ lines << "Potential callers outside reference: #{excluded_callers.fetch('count')} " \
229
+ "(runtime=#{excluded_callers.fetch('runtime_count', 0)}): " \
230
+ "#{Array(excluded_callers['samples']).join(', ')}"
231
+ end
232
+ Array(diagnostic['potential_entry_points_outside_analyze']).each do |entry|
233
+ lines << "Potential entry point outside analysis: #{entry.fetch('file')} [#{entry.fetch('reference_status')}]"
234
+ end
235
+ Array(diagnostic['ignored_symlinks']).each do |file|
236
+ lines << "Ignored symlink: #{file}"
237
+ end
238
+ end
239
+
240
+ def append_reference_barrier_diagnostic(lines)
241
+ diagnostic = report.diagnostics['non_ruby_reference_barrier']
242
+ return unless diagnostic
243
+
244
+ lines << "Non-Ruby reference barrier: scanned=#{diagnostic.fetch('files_scanned')}/" \
245
+ "#{diagnostic.fetch('files_considered')}, matches=#{diagnostic.fetch('matches')}, " \
246
+ "blocked definitions=#{diagnostic.fetch('matched_definitions')}"
247
+ skipped = diagnostic.fetch('skipped_counts')
248
+ lines << "Skipped non-Ruby references: #{skipped.map { |reason, count| "#{reason}=#{count}" }.join(', ')}" \
249
+ unless skipped.empty?
250
+ end
251
+
252
+ def append_definition_resolution_diagnostic(lines)
253
+ diagnostic = report.diagnostics['definition_resolution']
254
+ return unless diagnostic
255
+
256
+ entries = definition_resolution_entries(diagnostic)
257
+ count = definition_resolution_count(diagnostic, entries)
258
+ lines << "Ambiguous definition inputs: #{count}"
259
+ rendered_count = [count, DEFINITION_RESOLUTION_SAMPLE_LIMIT].min
260
+ entries.first(rendered_count).each do |entry|
261
+ if entry.is_a?(Hash)
262
+ kind = entry['kind'] || entry[:kind] || entry['status'] || entry[:status] || 'unknown'
263
+ identifier = definition_resolution_identifier(entry)
264
+ ids = Array(entry['definition_ids'] || entry[:definition_ids])
265
+ lines << " #{kind} #{identifier} -> #{ids.join(', ')}"
266
+ else
267
+ lines << " #{entry}"
268
+ end
269
+ end
270
+ omitted = count - [entries.length, rendered_count].min
271
+ lines << " ... #{omitted} more" if omitted.positive?
272
+ end
273
+
274
+ def definition_resolution_entries(diagnostic)
275
+ return diagnostic if diagnostic.is_a?(Array)
276
+ return [] unless diagnostic.is_a?(Hash)
277
+
278
+ %w[ambiguous_inputs ambiguities samples].each do |key|
279
+ value = diagnostic[key] || diagnostic[key.to_sym]
280
+ return value if value.is_a?(Array)
281
+ end
282
+ []
283
+ end
284
+
285
+ def definition_resolution_identifier(entry)
286
+ direct = entry['identifier'] || entry[:identifier]
287
+ return direct if direct
288
+
289
+ reference = entry['reference'] || entry[:reference]
290
+ return reference unless reference.is_a?(Hash)
291
+
292
+ identifier = reference['identifier'] || reference[:identifier] ||
293
+ reference['definition_id'] || reference[:definition_id] ||
294
+ reference['symbol_id'] || reference[:symbol_id] || 'unknown'
295
+ file = reference['file'] || reference[:file]
296
+ line = reference['line'] || reference[:line]
297
+ location = [file, line].compact.join(':')
298
+ location.empty? ? identifier : "#{identifier} @ #{location}"
299
+ end
300
+
301
+ def definition_resolution_count(diagnostic, entries)
302
+ return entries.length unless diagnostic.is_a?(Hash)
303
+
304
+ counts = diagnostic['counts'] || diagnostic[:counts]
305
+ nested_count = counts['ambiguous'] || counts[:ambiguous] if counts.is_a?(Hash)
306
+ value = diagnostic['ambiguous_input_count'] || diagnostic[:ambiguous_input_count] ||
307
+ diagnostic['ambiguous_count'] || diagnostic[:ambiguous_count] ||
308
+ diagnostic['count'] || diagnostic[:count] || nested_count
309
+ Integer(value || entries.length)
310
+ rescue ArgumentError, TypeError
311
+ entries.length
312
+ end
313
+
56
314
  def render_github_annotations(min_confidence)
57
- report.dead_methods(min_confidence: min_confidence).map do |finding|
58
- message = "#{finding.classification} #{finding.node.id} confidence=#{finding.confidence}"
59
- escaped = message.gsub('%', '%25').gsub("\n", '%0A').gsub("\r", '%0D')
60
- "::warning file=#{finding.node.file},line=#{finding.node.line},title=Necropsy #{finding.confidence}::#{escaped}"
61
- end.join("\n")
315
+ finding_annotations = report.dead_methods(min_confidence: min_confidence).map do |finding|
316
+ message = "#{finding.classification} #{finding.node.symbol_id} definition_id=#{finding.node.definition_id} " \
317
+ "confidence=#{finding.confidence}"
318
+ "::warning file=#{finding.node.file},line=#{finding.node.line},title=Necropsy #{finding.confidence}::" \
319
+ "#{escape_annotation(message)}"
320
+ end
321
+ source_annotations = source_diagnostic_entries.map do |entry|
322
+ message = "Incomplete source (#{entry['status']}, #{entry['type']}): #{entry['message']}"
323
+ "::warning file=#{entry['file']},line=#{entry['line']},title=Necropsy incomplete source::" \
324
+ "#{escape_annotation(message)}"
325
+ end
326
+ (finding_annotations + source_annotations + health_annotations).join("\n")
62
327
  end
63
328
 
64
329
  def render_sarif(min_confidence)
65
330
  findings = report.dead_methods(min_confidence: min_confidence)
331
+ source_entries = source_diagnostic_entries
66
332
  {
67
333
  'version' => '2.1.0',
68
334
  '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json',
@@ -72,30 +338,47 @@ module Necropsy
72
338
  'driver' => {
73
339
  'name' => 'Necropsy',
74
340
  'informationUri' => 'https://github.com/ydah/necropsy',
75
- 'rules' => sarif_rules(findings)
341
+ 'rules' => sarif_rules(findings, source_entries)
76
342
  }
77
343
  },
78
- 'results' => findings.map { |finding| sarif_result(finding) }
344
+ 'results' => findings.map { |finding| sarif_result(finding) } + source_entries.map { |entry| sarif_source_result(entry) },
345
+ 'properties' => {
346
+ 'necropsyFingerprintCompatibility' => Report::FINGERPRINT_COMPATIBILITY,
347
+ 'analysisHealth' => report.analysis_health.to_h
348
+ }
79
349
  }
80
350
  ]
81
351
  }.to_json
82
352
  end
83
353
 
84
- def sarif_rules(findings)
85
- findings.map(&:classification).uniq.map do |classification|
354
+ def sarif_rules(findings, source_entries)
355
+ rules = findings.map(&:classification).uniq.map do |classification|
86
356
  {
87
357
  'id' => classification.to_s,
88
358
  'name' => classification.to_s,
89
359
  'shortDescription' => { 'text' => "Necropsy #{classification}" }
90
360
  }
91
361
  end
362
+ return rules if source_entries.empty?
363
+
364
+ rules << {
365
+ 'id' => 'parse_incomplete',
366
+ 'name' => 'parse_incomplete',
367
+ 'shortDescription' => { 'text' => 'Necropsy incomplete source' }
368
+ }
92
369
  end
93
370
 
94
371
  def sarif_result(finding)
95
- {
372
+ result = {
96
373
  'ruleId' => finding.classification.to_s,
97
374
  'level' => sarif_level(finding),
98
375
  'message' => { 'text' => "#{finding.node.id} is #{finding.classification} (#{finding.confidence})" },
376
+ 'properties' => {
377
+ 'symbolId' => finding.node.symbol_id,
378
+ 'definitionId' => finding.node.definition_id,
379
+ 'logicalFingerprint' => finding.logical_fingerprint,
380
+ 'physicalFingerprint' => finding.physical_fingerprint
381
+ },
99
382
  'locations' => [
100
383
  {
101
384
  'physicalLocation' => {
@@ -104,15 +387,148 @@ module Necropsy
104
387
  }
105
388
  }
106
389
  ],
107
- 'partialFingerprints' => { 'necropsy' => finding.fingerprint }
390
+ 'partialFingerprints' => {
391
+ 'necropsy' => finding.logical_fingerprint,
392
+ 'necropsyPhysicalDefinition' => finding.physical_fingerprint
393
+ }
394
+ }
395
+ related_locations = sarif_related_locations(finding)
396
+ result['relatedLocations'] = related_locations unless related_locations.empty?
397
+ code_flows = sarif_code_flows(finding)
398
+ result['codeFlows'] = code_flows unless code_flows.empty?
399
+ result
400
+ end
401
+
402
+ def sarif_related_locations(finding)
403
+ finding.blockers.filter_map do |blocker|
404
+ metadata = blocker.metadata
405
+ file = metadata['file'] || metadata[:file]
406
+ line = positive_line(metadata['line'] || metadata[:line])
407
+ next if file.to_s.empty? || line.nil?
408
+
409
+ {
410
+ 'physicalLocation' => sarif_physical_location(file, line),
411
+ 'message' => { 'text' => "#{blocker.kind}: #{blocker.reason}" },
412
+ 'properties' => {
413
+ 'blockerKind' => blocker.kind.to_s,
414
+ 'blockerSource' => blocker.source.respond_to?(:to_h) ? blocker.source.to_h : blocker.source.to_s
415
+ }
416
+ }
417
+ end.uniq do |location|
418
+ [
419
+ location.dig('physicalLocation', 'artifactLocation', 'uri'),
420
+ location.dig('physicalLocation', 'region', 'startLine'),
421
+ location.dig('properties', 'blockerKind')
422
+ ]
423
+ end
424
+ end
425
+
426
+ def sarif_code_flows(finding)
427
+ witness = sarif_witness(finding.node.graph_id)
428
+ return [] unless witness
429
+
430
+ domain, path = witness
431
+ locations = path.each_with_index.filter_map do |definition_id, index|
432
+ node = report.graph.nodes[definition_id]
433
+ next unless node
434
+
435
+ {
436
+ 'location' => {
437
+ 'physicalLocation' => sarif_physical_location(node.file, node.line),
438
+ 'message' => { 'text' => node.symbol_id }
439
+ },
440
+ 'executionOrder' => index + 1
441
+ }
442
+ end
443
+ return [] if locations.empty?
444
+
445
+ [{
446
+ 'message' => { 'text' => "#{domain} reachability witness" },
447
+ 'threadFlows' => [{ 'locations' => locations }],
448
+ 'properties' => { 'domain' => domain.to_s }
449
+ }]
450
+ end
451
+
452
+ def sarif_witness(definition_id)
453
+ return unless report.reachability
454
+
455
+ %i[runtime external test].each do |domain|
456
+ path = report.reachability.witness(definition_id, kind: domain)
457
+ return [domain, path] if path
458
+ end
459
+ nil
460
+ end
461
+
462
+ def sarif_physical_location(file, line)
463
+ {
464
+ 'artifactLocation' => { 'uri' => file.to_s },
465
+ 'region' => { 'startLine' => line }
108
466
  }
109
467
  end
110
468
 
469
+ def positive_line(value)
470
+ line = Integer(value)
471
+ line if line.positive?
472
+ rescue ArgumentError, TypeError
473
+ nil
474
+ end
475
+
111
476
  def sarif_level(finding)
112
477
  return 'error' if %i[certain high].include?(finding.confidence)
113
478
  return 'warning' if finding.confidence == :medium
114
479
 
115
480
  'note'
116
481
  end
482
+
483
+ def sarif_source_result(entry)
484
+ {
485
+ 'ruleId' => 'parse_incomplete',
486
+ 'level' => 'warning',
487
+ 'message' => {
488
+ 'text' => "Incomplete source (#{entry['status']}, #{entry['type']}): #{entry['message']}"
489
+ },
490
+ 'locations' => [
491
+ {
492
+ 'physicalLocation' => {
493
+ 'artifactLocation' => { 'uri' => entry['file'] },
494
+ 'region' => { 'startLine' => entry['line'] }
495
+ }
496
+ }
497
+ ]
498
+ }
499
+ end
500
+
501
+ def source_diagnostic_entries
502
+ diagnostic = report.diagnostics['source_incompleteness']
503
+ return [] unless diagnostic
504
+
505
+ diagnostic.fetch('files').flat_map do |file|
506
+ errors = file.fetch('errors')
507
+ if errors.empty?
508
+ next [{ 'file' => file['file'], 'line' => 1, 'type' => file['status'],
509
+ 'message' => 'No source diagnostic was available', 'status' => file['status'] }]
510
+ end
511
+
512
+ errors.map { |error| error.merge('status' => file['status']) }
513
+ end
514
+ end
515
+
516
+ def health_annotations
517
+ report.analysis_health.reasons.map do |reason|
518
+ level = reason.fetch('severity') == 'invalid' ? 'error' : 'warning'
519
+ title = "Necropsy analysis #{report.analysis_health.status}"
520
+ message = "#{reason.fetch('code')}: #{reason['message']}"
521
+ location = if reason['file']
522
+ " file=#{reason['file']},line=#{positive_line(reason['line']) || 1},"
523
+ else
524
+ ' '
525
+ end
526
+ "::#{level}#{location}title=#{title}::#{escape_annotation(message)}"
527
+ end
528
+ end
529
+
530
+ def escape_annotation(message)
531
+ message.gsub('%', '%25').gsub("\n", '%0A').gsub("\r", '%0D')
532
+ end
117
533
  end
118
534
  end