necropsy 0.1.0 → 0.2.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +33 -0
  3. data/CHANGELOG.md +18 -0
  4. data/MEASUREMENTS.md +85 -0
  5. data/README.md +60 -8
  6. data/Rakefile +3 -1
  7. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +12 -2
  8. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +5 -1
  9. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +68 -14
  10. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +107 -12
  11. data/lib/necropsy/analyzers/static/cha.rb +10 -6
  12. data/lib/necropsy/analyzers/static/name_resolution.rb +29 -20
  13. data/lib/necropsy/analyzers/static/rta.rb +19 -3
  14. data/lib/necropsy/ast_scanner/call_recording.rb +156 -0
  15. data/lib/necropsy/ast_scanner/dsl_macros.rb +142 -0
  16. data/lib/necropsy/ast_scanner/method_definitions.rb +176 -0
  17. data/lib/necropsy/ast_scanner/references.rb +81 -0
  18. data/lib/necropsy/ast_scanner/ruby_semantics.rb +177 -0
  19. data/lib/necropsy/ast_scanner/traversal.rb +184 -0
  20. data/lib/necropsy/ast_scanner/value_definitions.rb +68 -0
  21. data/lib/necropsy/ast_scanner.rb +24 -560
  22. data/lib/necropsy/bench/evaluator.rb +29 -10
  23. data/lib/necropsy/cache/scan_cache.rb +20 -12
  24. data/lib/necropsy/cli.rb +88 -32
  25. data/lib/necropsy/confidence/scorer.rb +97 -19
  26. data/lib/necropsy/configuration.rb +125 -7
  27. data/lib/necropsy/diagnostics.rb +202 -0
  28. data/lib/necropsy/entry_points/plain.rb +19 -2
  29. data/lib/necropsy/entry_points/rails.rb +121 -85
  30. data/lib/necropsy/graph/call_graph.rb +203 -21
  31. data/lib/necropsy/guardrail/baseline.rb +14 -5
  32. data/lib/necropsy/guardrail/diff.rb +5 -2
  33. data/lib/necropsy/guardrail/quarantine.rb +9 -3
  34. data/lib/necropsy/models.rb +19 -11
  35. data/lib/necropsy/project.rb +49 -3
  36. data/lib/necropsy/reachability/engine.rb +31 -7
  37. data/lib/necropsy/report.rb +44 -14
  38. data/lib/necropsy/reporter.rb +11 -5
  39. data/lib/necropsy/runner.rb +33 -4
  40. data/lib/necropsy/trace_point_runtime.rb +19 -0
  41. data/lib/necropsy/version.rb +1 -1
  42. data/lib/necropsy.rb +4 -0
  43. data/script/measure.rb +20 -0
  44. metadata +22 -2
@@ -6,19 +6,41 @@ module Necropsy
6
6
  class Configuration
7
7
  DEFAULT_FAIL_ON = :high
8
8
  DEFAULT_BASELINE = '.necropsy_baseline.yml'
9
+ TOP_LEVEL_KEYS = %w[
10
+ analyzers frameworks entry_points ci quarantine bench cache rta resolution paths report logging implicit_callers
11
+ ].freeze
12
+ NESTED_KEYS = {
13
+ 'analyzers' => %w[static dynamic custom],
14
+ 'analyzers.dynamic' => %w[coverage coverband trace_point],
15
+ 'entry_points' => %w[extra],
16
+ 'ci' => %w[baseline fail_on],
17
+ 'quarantine' => %w[days],
18
+ 'bench' => %w[precision_threshold recall_threshold],
19
+ 'cache' => %w[enabled path],
20
+ 'rta' => %w[factory_methods],
21
+ 'resolution' => %w[ambiguity_limit],
22
+ 'paths' => %w[include exclude],
23
+ 'report' => %w[include exclude],
24
+ 'logging' => %w[verbose]
25
+ }.freeze
26
+ DYNAMIC_KEYS = %w[source min_observation_days keys key pattern connect_timeout read_timeout].freeze
27
+ IMPLICIT_CALLER_KEYS = %w[name_pattern owner_ancestors reason].freeze
9
28
 
10
29
  attr_reader :root, :path, :data
11
30
 
12
31
  def self.load(root:, path: nil)
13
32
  config_path = path ? File.expand_path(path, root) : File.join(root, '.necropsy.yml')
14
- data = File.exist?(config_path) ? YAML.load_file(config_path) : {}
33
+ data = File.exist?(config_path) ? YAML.safe_load_file(config_path, permitted_classes: [Symbol], aliases: true) : {}
15
34
  new(root: root, path: config_path, data: data || {})
35
+ rescue Psych::Exception => e
36
+ raise Error, "Could not parse configuration #{config_path}: #{e.message}"
16
37
  end
17
38
 
18
39
  def initialize(root:, path:, data:)
19
40
  @root = File.expand_path(root)
20
41
  @path = path
21
42
  @data = stringify_keys(data)
43
+ validate!
22
44
  end
23
45
 
24
46
  def static_analyzers
@@ -53,7 +75,10 @@ module Necropsy
53
75
  end
54
76
 
55
77
  def fail_on
56
- (fetch('ci', 'fail_on') || DEFAULT_FAIL_ON).to_sym
78
+ level = (fetch('ci', 'fail_on') || DEFAULT_FAIL_ON).to_sym
79
+ return level if CONFIDENCE_LEVELS.key?(level)
80
+
81
+ raise Error, "Invalid ci.fail_on confidence level: #{level}"
57
82
  end
58
83
 
59
84
  def min_observation_days
@@ -81,25 +106,70 @@ module Necropsy
81
106
  end
82
107
 
83
108
  def cache_path
84
- fetch('cache', 'path') || '.necropsy_cache/scan.yml'
109
+ fetch('cache', 'path') || '.necropsy_cache/scan.json'
85
110
  end
86
111
 
87
112
  def scan_cache_key
88
- data.except('cache')
113
+ data.except('cache', 'report')
89
114
  end
90
115
 
91
116
  def factory_methods
92
117
  Array(fetch('rta', 'factory_methods') || %w[build create build_stubbed]).map(&:to_s)
93
118
  end
94
119
 
120
+ def ambiguity_limit
121
+ value = fetch('resolution', 'ambiguity_limit')
122
+ return 4 if value.nil?
123
+ return Float::INFINITY if value.to_s == 'unlimited'
124
+
125
+ limit = Integer(value)
126
+ return limit if limit.positive?
127
+
128
+ raise Error, 'resolution.ambiguity_limit must be a positive integer or unlimited'
129
+ rescue ArgumentError, TypeError
130
+ raise Error, 'resolution.ambiguity_limit must be a positive integer or unlimited'
131
+ end
132
+
133
+ def include_paths
134
+ Array(fetch('paths', 'include')).map(&:to_s)
135
+ end
136
+
137
+ def exclude_paths
138
+ Array(fetch('paths', 'exclude')).map(&:to_s)
139
+ end
140
+
141
+ def report_include_paths
142
+ Array(fetch('report', 'include')).map(&:to_s)
143
+ end
144
+
145
+ def report_exclude_paths
146
+ Array(fetch('report', 'exclude')).map(&:to_s)
147
+ end
148
+
149
+ def implicit_callers
150
+ @implicit_callers ||= Array(data['implicit_callers']).map do |entry|
151
+ {
152
+ name_pattern: Regexp.new(entry.fetch('name_pattern')),
153
+ owner_ancestors: Array(entry['owner_ancestors']).map(&:to_s),
154
+ reason: entry['reason']&.to_s
155
+ }
156
+ end
157
+ end
158
+
159
+ def verbose?
160
+ fetch('logging', 'verbose') == true
161
+ end
162
+
95
163
  private
96
164
 
97
165
  def fetch(*keys)
98
- keys.reduce(data) do |current, key|
99
- break nil unless current.is_a?(Hash)
166
+ current = data
167
+ keys.each do |key|
168
+ return nil unless current.is_a?(Hash)
100
169
 
101
- current[key.to_s]
170
+ current = current[key.to_s]
102
171
  end
172
+ current
103
173
  end
104
174
 
105
175
  def stringify_keys(value)
@@ -112,5 +182,53 @@ module Necropsy
112
182
  value
113
183
  end
114
184
  end
185
+
186
+ def validate!
187
+ validate_hash_keys(data, TOP_LEVEL_KEYS, 'configuration')
188
+ NESTED_KEYS.each do |path, allowed|
189
+ value = fetch(*path.split('.'))
190
+ validate_hash_keys(value, allowed, path) if value
191
+ end
192
+ %w[coverage coverband trace_point].each do |name|
193
+ value = fetch('analyzers', 'dynamic', name)
194
+ validate_hash_keys(value, DYNAMIC_KEYS, "analyzers.dynamic.#{name}") if value
195
+ end
196
+ validate_custom_analyzers!
197
+ validate_implicit_callers!
198
+ ambiguity_limit
199
+ end
200
+
201
+ def validate_hash_keys(value, allowed, location)
202
+ raise Error, "#{location} must be a mapping" unless value.is_a?(Hash)
203
+
204
+ unknown = value.keys - allowed
205
+ return if unknown.empty?
206
+
207
+ raise Error, "Unknown #{location} option#{'s' if unknown.length > 1}: #{unknown.join(', ')}"
208
+ end
209
+
210
+ def validate_custom_analyzers!
211
+ custom_analyzers.each do |entry|
212
+ next if entry.is_a?(String)
213
+ raise Error, 'Each custom analyzer must be a class name or a mapping with class and require' unless entry.is_a?(Hash)
214
+
215
+ validate_hash_keys(entry, %w[class require], 'custom analyzer')
216
+ raise Error, 'Custom analyzer mapping requires class' unless entry['class']
217
+ end
218
+ end
219
+
220
+ def validate_implicit_callers!
221
+ Array(data['implicit_callers']).each do |entry|
222
+ raise Error, 'Each implicit caller must be a mapping' unless entry.is_a?(Hash)
223
+
224
+ validate_hash_keys(entry, IMPLICIT_CALLER_KEYS, 'implicit caller')
225
+ pattern = entry['name_pattern']
226
+ raise Error, 'Implicit caller requires name_pattern' unless pattern.is_a?(String)
227
+
228
+ Regexp.new(pattern)
229
+ rescue RegexpError => e
230
+ raise Error, "Invalid implicit caller name_pattern: #{e.message}"
231
+ end
232
+ end
115
233
  end
116
234
  end
@@ -0,0 +1,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Necropsy
6
+ class Diagnostics
7
+ FORMATS = %i[human json].freeze
8
+
9
+ def initialize(report)
10
+ raise Error, 'Reachability witnesses are unavailable for this report' unless report.reachability
11
+
12
+ @report = report
13
+ end
14
+
15
+ def why(node_id)
16
+ node = graph.nodes[node_id]
17
+ return missing_payload(node_id) unless node
18
+
19
+ runtime_path = reachability.witness(node_id)
20
+ return alive_payload(node, runtime_path, :runtime) if runtime_path
21
+
22
+ test_path = reachability.witness(node_id, kind: :test)
23
+ return alive_payload(node, test_path, :test) if test_path
24
+
25
+ dead_payload(node)
26
+ end
27
+
28
+ def explain(node_id)
29
+ node = graph.nodes[node_id]
30
+ return missing_payload(node_id) unless node
31
+
32
+ finding = report.findings.find { |candidate| candidate.node.id == node_id }
33
+ return { 'status' => 'alive', 'node' => node.to_h } unless finding
34
+
35
+ {
36
+ 'status' => 'finding',
37
+ 'node' => node.to_h,
38
+ 'classification' => finding.classification.to_s,
39
+ 'confidence' => finding.confidence.to_s,
40
+ 'score' => finding.score,
41
+ 'components' => finding.score_components.map(&:to_h),
42
+ 'reasons' => finding.reasons
43
+ }
44
+ end
45
+
46
+ def render(payload, format: :human)
47
+ normalized = format.to_sym
48
+ raise Error, "Diagnostics support only human or json output, not #{format}" unless FORMATS.include?(normalized)
49
+
50
+ return JSON.pretty_generate(payload) if normalized == :json
51
+
52
+ render_human(payload)
53
+ end
54
+
55
+ private
56
+
57
+ attr_reader :report
58
+
59
+ def graph
60
+ report.graph
61
+ end
62
+
63
+ def reachability
64
+ report.reachability
65
+ end
66
+
67
+ def alive_payload(node, path, kind)
68
+ {
69
+ 'status' => 'alive',
70
+ 'kind' => kind.to_s,
71
+ 'node' => node.to_h,
72
+ 'path' => path.each_with_index.map do |node_id, index|
73
+ caller_id = index.positive? ? path[index - 1] : nil
74
+ path_step(node_id, caller_id)
75
+ end
76
+ }
77
+ end
78
+
79
+ def path_step(node_id, caller_id)
80
+ step = { 'node' => graph.nodes.fetch(node_id).to_h }
81
+ entry = graph.entry_points.find { |candidate| candidate.node_id == node_id }
82
+ step['entry_reason'] = entry.reason.to_s if entry
83
+ return step unless caller_id
84
+
85
+ evidences = graph.edges_from(caller_id).fetch(node_id, [])
86
+ step['edge'] = {
87
+ 'caller_id' => caller_id,
88
+ 'callee_id' => node_id,
89
+ 'evidences' => evidences.map(&:to_h)
90
+ }
91
+ step
92
+ end
93
+
94
+ def dead_payload(node)
95
+ finding = report.findings.find { |candidate| candidate.node.id == node.id }
96
+ {
97
+ 'status' => 'dead',
98
+ 'node' => node.to_h,
99
+ 'classification' => finding&.classification&.to_s,
100
+ 'nearest_alive' => nearest_alive(node.id),
101
+ 'uncertainties' => graph.uncertainties(node.id)
102
+ }
103
+ end
104
+
105
+ def nearest_alive(node_id)
106
+ kinds = reachability.runtime_alive.to_h { |id| [id, 'runtime'] }
107
+ reachability.test_alive.each { |id| kinds[id] ||= 'test' }
108
+ visited = { node_id => 0 }
109
+ queue = [node_id]
110
+
111
+ until queue.empty?
112
+ current = queue.shift
113
+ neighbors(current).each do |neighbor|
114
+ next if visited.key?(neighbor)
115
+
116
+ distance = visited.fetch(current) + 1
117
+ return { 'node_id' => neighbor, 'kind' => kinds.fetch(neighbor), 'distance' => distance } if kinds.key?(neighbor)
118
+
119
+ visited[neighbor] = distance
120
+ queue << neighbor
121
+ end
122
+ end
123
+ nil
124
+ end
125
+
126
+ def neighbors(node_id)
127
+ outgoing = graph.edges_from(node_id).keys
128
+ incoming = graph.incoming_edges(node_id).map(&:caller_id)
129
+ (outgoing + incoming).uniq.sort
130
+ end
131
+
132
+ def missing_payload(node_id)
133
+ terms = [node_id, node_id.split(/[.#]/).last].compact.map(&:downcase).reject { |term| term.length < 2 }
134
+ suggestions = graph.nodes.keys.select do |candidate|
135
+ terms.any? { |term| candidate.downcase[term] }
136
+ end.sort.first(10)
137
+ { 'status' => 'not_found', 'node_id' => node_id, 'suggestions' => suggestions }
138
+ end
139
+
140
+ def render_human(payload)
141
+ case payload.fetch('status')
142
+ when 'alive' then render_alive(payload)
143
+ when 'dead' then render_dead(payload)
144
+ when 'finding' then render_explanation(payload)
145
+ when 'not_found' then render_missing(payload)
146
+ else "#{payload.dig('node', 'id')} is alive and has no dead-code finding."
147
+ end
148
+ end
149
+
150
+ def render_alive(payload)
151
+ lines = ["Alive (#{payload.fetch('kind')}): #{payload.dig('node', 'id')}"]
152
+ payload.fetch('path').each_with_index do |step, index|
153
+ suffix = step['entry_reason'] ? " entry=#{step['entry_reason']}" : ''
154
+ lines << " #{index}. #{step.dig('node', 'id')}#{suffix}"
155
+ Array(step.dig('edge', 'evidences')).each { |evidence| lines << render_evidence(evidence) }
156
+ end
157
+ lines.join("\n")
158
+ end
159
+
160
+ def render_evidence(evidence)
161
+ metadata = evidence.fetch('metadata', {})
162
+ location = [metadata['file'], metadata['line']].compact.join(':')
163
+ suffix = location.empty? ? '' : " at #{location}"
164
+ " via #{evidence['analyzer']} weight=#{evidence['weight']}#{suffix}: #{evidence['details']}"
165
+ end
166
+
167
+ def render_dead(payload)
168
+ lines = ["Dead: #{payload.dig('node', 'id')}"]
169
+ lines << "Classification: #{payload['classification']}" if payload['classification']
170
+ nearest = payload['nearest_alive']
171
+ lines << if nearest
172
+ "Nearest alive: #{nearest['node_id']} (#{nearest['kind']}, distance #{nearest['distance']})"
173
+ else
174
+ 'Nearest alive: none'
175
+ end
176
+ lines << 'Uncertainties: none' if payload.fetch('uncertainties').empty?
177
+ payload.fetch('uncertainties').each { |message| lines << "Uncertainty: #{message}" }
178
+ lines.join("\n")
179
+ end
180
+
181
+ def render_explanation(payload)
182
+ lines = [
183
+ "#{payload.dig('node', 'id')}: #{payload['classification']}",
184
+ "Confidence: #{payload['confidence']} (score #{format('%.2f', payload['score'])})"
185
+ ]
186
+ payload.fetch('components').each do |component|
187
+ lines << format(
188
+ ' %<name>-28s %<value>+0.2f %<details>s',
189
+ name: component['name'], value: component['value'], details: component['details']
190
+ )
191
+ end
192
+ lines << format(' %<name>-28s %<value>0.2f', name: 'total', value: payload['score'])
193
+ lines.join("\n")
194
+ end
195
+
196
+ def render_missing(payload)
197
+ lines = ["Node not found: #{payload['node_id']}"]
198
+ lines << "Suggestions: #{payload['suggestions'].join(', ')}" unless payload['suggestions'].empty?
199
+ lines.join("\n")
200
+ end
201
+ end
202
+ end
@@ -24,13 +24,30 @@ module Necropsy
24
24
  end
25
25
  end
26
26
 
27
+ api_files = primary_library_files(project)
27
28
  graph.method_nodes.each do |node|
28
- next unless node.file == 'lib/necropsy.rb'
29
- next unless node.kind == :singleton_method && node.owner == 'Necropsy'
29
+ next unless api_files.include?(node.file)
30
+ next unless node.kind == :singleton_method && node.visibility == :public
30
31
 
31
32
  graph.add_entry_point(node.id, :public_api_declared)
32
33
  end
33
34
  end
35
+
36
+ private
37
+
38
+ def primary_library_files(project)
39
+ gem_names(project).flat_map do |name|
40
+ ["lib/#{name.tr('-', '/')}.rb", "lib/#{name.tr('-', '_')}.rb"]
41
+ end.uniq.select { |relative| File.file?(File.join(project.root, relative)) }
42
+ end
43
+
44
+ def gem_names(project)
45
+ Dir.glob(File.join(project.root, '*.gemspec')).filter_map do |path|
46
+ File.read(path)[/\.name\s*=\s*["']([^"']+)["']/, 1]
47
+ rescue SystemCallError, EncodingError
48
+ nil
49
+ end
50
+ end
34
51
  end
35
52
  end
36
53
  end