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,61 +2,333 @@
2
2
 
3
3
  module Necropsy
4
4
  class Reporter
5
+ FORMATS = %i[human json ndjson yaml yml sarif github annotations].freeze
6
+ DEFAULT_MIN_CONFIDENCE = :medium
7
+ DEFINITION_RESOLUTION_SAMPLE_LIMIT = 5
8
+
5
9
  def initialize(report)
6
10
  @report = report
7
11
  end
8
12
 
9
- def render(format: :human, min_confidence: :low)
10
- case format.to_sym
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
+
43
+ def render(format: :human, min_confidence: DEFAULT_MIN_CONFIDENCE, include_graph: false)
44
+ normalized_format = format.to_sym
45
+ raise Error, "Unknown report format: #{format}" unless FORMATS.include?(normalized_format)
46
+
47
+ case normalized_format
11
48
  when :json
12
- report.to_json
49
+ report.to_json(include_graph: include_graph)
50
+ when :ndjson
51
+ each_ndjson.to_a.join("\n")
13
52
  when :sarif
14
53
  render_sarif(min_confidence)
15
54
  when :github, :annotations
16
55
  render_github_annotations(min_confidence)
17
56
  when :yaml, :yml
18
- report.to_yaml
19
- else
57
+ report.to_yaml(include_graph: include_graph)
58
+ when :human
20
59
  render_human(min_confidence)
21
60
  end
22
61
  end
23
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
+
24
93
  private
25
94
 
26
95
  attr_reader :report
27
96
 
97
+ def ndjson_record(record_type, data)
98
+ JSON.generate('schema' => 'necropsy.graph.ndjson.v1', 'record' => record_type, 'data' => data)
99
+ end
100
+
28
101
  def render_human(min_confidence)
29
- findings = report.dead_methods(min_confidence: min_confidence)
102
+ findings = (report.dead_methods(min_confidence: min_confidence) + report.blocked_methods).uniq
30
103
  lines = [
31
104
  'Necropsy report',
32
105
  "Root: #{report.root}",
33
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)",
34
109
  "Findings: #{findings.length}"
35
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)
36
119
 
37
120
  findings.group_by(&:classification).sort_by do |classification, _|
38
121
  classification.to_s
39
122
  end.each do |classification, group|
40
123
  lines << ''
41
124
  lines << "#{classification} (#{group.length})"
42
- group.sort_by { |finding| [finding.node.file, finding.node.line, finding.node.id] }.each do |finding|
43
- 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)
44
131
  end
45
132
  end
46
133
 
47
134
  lines.join("\n")
48
135
  end
49
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
+
50
314
  def render_github_annotations(min_confidence)
51
- report.dead_methods(min_confidence: min_confidence).map do |finding|
52
- message = "#{finding.classification} #{finding.node.id} confidence=#{finding.confidence}"
53
- escaped = message.gsub('%', '%25').gsub("\n", '%0A').gsub("\r", '%0D')
54
- "::warning file=#{finding.node.file},line=#{finding.node.line},title=Necropsy #{finding.confidence}::#{escaped}"
55
- 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")
56
327
  end
57
328
 
58
329
  def render_sarif(min_confidence)
59
330
  findings = report.dead_methods(min_confidence: min_confidence)
331
+ source_entries = source_diagnostic_entries
60
332
  {
61
333
  'version' => '2.1.0',
62
334
  '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json',
@@ -66,30 +338,47 @@ module Necropsy
66
338
  'driver' => {
67
339
  'name' => 'Necropsy',
68
340
  'informationUri' => 'https://github.com/ydah/necropsy',
69
- 'rules' => sarif_rules(findings)
341
+ 'rules' => sarif_rules(findings, source_entries)
70
342
  }
71
343
  },
72
- '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
+ }
73
349
  }
74
350
  ]
75
351
  }.to_json
76
352
  end
77
353
 
78
- def sarif_rules(findings)
79
- findings.map(&:classification).uniq.map do |classification|
354
+ def sarif_rules(findings, source_entries)
355
+ rules = findings.map(&:classification).uniq.map do |classification|
80
356
  {
81
357
  'id' => classification.to_s,
82
358
  'name' => classification.to_s,
83
359
  'shortDescription' => { 'text' => "Necropsy #{classification}" }
84
360
  }
85
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
+ }
86
369
  end
87
370
 
88
371
  def sarif_result(finding)
89
- {
372
+ result = {
90
373
  'ruleId' => finding.classification.to_s,
91
374
  'level' => sarif_level(finding),
92
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
+ },
93
382
  'locations' => [
94
383
  {
95
384
  'physicalLocation' => {
@@ -98,8 +387,90 @@ module Necropsy
98
387
  }
99
388
  }
100
389
  ],
101
- 'partialFingerprints' => { 'necropsy' => finding.fingerprint }
390
+ 'partialFingerprints' => {
391
+ 'necropsy' => finding.logical_fingerprint,
392
+ 'necropsyPhysicalDefinition' => finding.physical_fingerprint
393
+ }
102
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 }
466
+ }
467
+ end
468
+
469
+ def positive_line(value)
470
+ line = Integer(value)
471
+ line if line.positive?
472
+ rescue ArgumentError, TypeError
473
+ nil
103
474
  end
104
475
 
105
476
  def sarif_level(finding)
@@ -108,5 +479,56 @@ module Necropsy
108
479
 
109
480
  'note'
110
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
111
533
  end
112
534
  end