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
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Necropsy
6
+ # Small, dependency-free phase profiler used by benchmark and local analysis.
7
+ # It is opt-in so ordinary reports remain byte-for-byte compatible.
8
+ class PerformanceProfiler
9
+ SCHEMA_VERSION = 1
10
+
11
+ def initialize(clock: Process.method(:clock_gettime), rss_reader: nil, allocation_reader: nil)
12
+ @clock = clock
13
+ @rss_reader = rss_reader || method(:process_rss_kb)
14
+ @allocation_reader = allocation_reader || method(:allocated_objects)
15
+ @phases = []
16
+ @peak_rss_kb = nil
17
+ end
18
+
19
+ attr_reader :phases
20
+
21
+ def measure(name)
22
+ started = snapshot
23
+ value = yield
24
+ finished = snapshot
25
+ @phases << phase_payload(name, started, finished)
26
+ value
27
+ end
28
+
29
+ def report(counts: {}, report_index_size_bytes: nil)
30
+ total_time = @phases.sum { |phase| phase.fetch('wall_time_seconds') }
31
+ total_allocations = @phases.sum { |phase| phase.fetch('allocated_objects') }
32
+ {
33
+ 'schema_version' => SCHEMA_VERSION,
34
+ 'phases' => @phases.sort_by { |phase| phase.fetch('name') },
35
+ 'totals' => {
36
+ 'wall_time_seconds' => total_time.round(6),
37
+ 'allocated_objects' => total_allocations
38
+ },
39
+ 'memory' => {
40
+ 'peak_rss_kb' => @peak_rss_kb,
41
+ 'rss_status' => @peak_rss_kb ? 'available' : 'unavailable'
42
+ },
43
+ 'counts' => normalize_counts(counts),
44
+ 'report_index_size_bytes' => report_index_size_bytes
45
+ }.compact
46
+ end
47
+
48
+ private
49
+
50
+ attr_reader :clock, :rss_reader, :allocation_reader
51
+
52
+ def snapshot
53
+ {
54
+ 'time' => clock.call(Process::CLOCK_MONOTONIC),
55
+ 'rss_kb' => rss_reader.call,
56
+ 'allocations' => allocation_reader.call
57
+ }
58
+ rescue StandardError
59
+ {
60
+ 'time' => clock.call(Process::CLOCK_MONOTONIC),
61
+ 'rss_kb' => nil,
62
+ 'allocations' => nil
63
+ }
64
+ end
65
+
66
+ def phase_payload(name, started, finished)
67
+ elapsed = [finished.fetch('time') - started.fetch('time'), 0].max
68
+ allocated = if started['allocations'] && finished['allocations']
69
+ [finished['allocations'] - started['allocations'], 0].max
70
+ else
71
+ 0
72
+ end
73
+ rss = [started['rss_kb'], finished['rss_kb']].compact.max
74
+ @peak_rss_kb = [@peak_rss_kb, rss].compact.max
75
+ {
76
+ 'name' => name.to_s,
77
+ 'wall_time_seconds' => elapsed.round(6),
78
+ 'allocated_objects' => allocated,
79
+ 'rss_kb' => rss
80
+ }.compact
81
+ end
82
+
83
+ def normalize_counts(counts)
84
+ Hash(counts).to_h { |key, value| [key.to_s, Integer(value)] }.sort.to_h
85
+ rescue ArgumentError, TypeError
86
+ {}
87
+ end
88
+
89
+ def allocated_objects
90
+ GC.stat.fetch(:total_allocated_objects, 0)
91
+ rescue StandardError
92
+ 0
93
+ end
94
+
95
+ def process_rss_kb
96
+ status_path = '/proc/self/status'
97
+ if File.file?(status_path)
98
+ match = File.read(status_path).match(/^VmRSS:\s+(\d+)\s+kB$/)
99
+ return match[1].to_i if match
100
+ end
101
+
102
+ output = IO.popen(['ps', '-o', 'rss=', '-p', Process.pid.to_s], &:read)
103
+ Integer(output.strip, exception: false)
104
+ rescue StandardError
105
+ nil
106
+ end
107
+ end
108
+ end
@@ -1,12 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'pathname'
4
+ require 'digest'
5
+ require 'find'
4
6
 
5
7
  module Necropsy
6
8
  class Project
9
+ SOURCE_SNAPSHOT_MAX_BYTES = 268_435_456
10
+ SOURCE_SNAPSHOT_MAX_FILES = 100_000
11
+ SOURCE_DIGEST_CHUNK_BYTES = 65_536
7
12
  EXCLUDED_DIRECTORIES = %w[
8
13
  .bundle
9
14
  .git
15
+ .necropsy_cache
16
+ .ruby-lsp
10
17
  coverage
11
18
  doc
12
19
  node_modules
@@ -14,6 +21,7 @@ module Necropsy
14
21
  tmp
15
22
  vendor
16
23
  ].freeze
24
+ NESTED_EXCLUDED_DIRECTORIES = EXCLUDED_DIRECTORIES.freeze
17
25
 
18
26
  attr_reader :root, :config
19
27
 
@@ -23,30 +31,156 @@ module Necropsy
23
31
  end
24
32
 
25
33
  def scan_result
26
- @scan_result ||= Cache::ScanCache.new(project: self).fetch(ruby_files) do
27
- AstScanner.new(project: self, files: ruby_files).scan
34
+ @scan_result ||= Cache::ScanCache.new(project: self).fetch(cache_files) do
35
+ AstScanner.new(
36
+ project: self,
37
+ files: scan_files,
38
+ source_domains: source_domains,
39
+ scope_diagnostics: scope_diagnostics
40
+ ).scan
28
41
  end
29
42
  end
30
43
 
31
44
  def ruby_files
32
45
  @ruby_files ||= begin
33
- globbed = Dir.glob(File.join(root, '**', '*.rb'))
34
- special = %w[Rakefile].map { |name| File.join(root, name) }.select { |file| File.file?(file) }
35
- rake = Dir.glob(File.join(root, '**', '*.rake'))
36
- executables = Dir.glob(File.join(root, '{bin,exe}', '*')).select { |file| File.file?(file) }
37
- gemspecs = Dir.glob(File.join(root, '*.gemspec'))
38
- candidates = (globbed + special + rake + executables + gemspecs).uniq
39
- candidates.select! { |file| analyzable_file?(file) }
40
- warn_excluded_entry_points(candidates) if config.include_paths.any?
41
- candidates.select! { |file| included_path?(relative_path(file)) } if config.include_paths.any?
46
+ candidates = if config.analyze_paths.any?
47
+ ruby_candidates.dup
48
+ else
49
+ default_analyze_candidates.dup
50
+ end
51
+ warn_excluded_entry_points(candidates) if config.analyze_paths.any? || config.exclude_paths.any?
52
+ candidates.select! { |file| analyzed_path?(relative_path(file)) } if config.analyze_paths.any?
42
53
  candidates.reject! { |file| excluded_path?(relative_path(file)) }
43
54
  candidates.sort
44
55
  end
45
56
  end
46
57
 
58
+ def reference_files
59
+ @reference_files ||= repository_files.select do |file|
60
+ config.reference_paths.any? { |pattern| path_matches?(pattern, relative_path(file)) }
61
+ end.sort
62
+ end
63
+
64
+ def reference_ruby_files
65
+ @reference_ruby_files ||= reference_files.select { |file| ruby_source?(file) }
66
+ end
67
+
68
+ def non_ruby_reference_files
69
+ @non_ruby_reference_files ||= (reference_files.to_set - reference_ruby_files.to_set).to_a.sort
70
+ end
71
+
72
+ def reference_file?(file)
73
+ reference_file_set.include?(File.expand_path(file, root))
74
+ rescue ArgumentError
75
+ false
76
+ end
77
+
78
+ def scan_files
79
+ @scan_files ||= (ruby_files + reference_ruby_files).uniq
80
+ end
81
+
82
+ def cache_files
83
+ @cache_files ||= (scan_files + reference_files).uniq
84
+ end
85
+
86
+ def scan_inventory_key
87
+ {
88
+ 'ignored_symlinks' => ignored_symlinks.sort,
89
+ 'source_discovery_issues' => source_discovery_issues,
90
+ 'ruby_files_outside_scopes' => ruby_files_outside_scopes.map { |file| relative_path(file) }
91
+ }
92
+ end
93
+
94
+ def source_snapshot
95
+ @source_snapshot ||= build_source_snapshot
96
+ end
97
+
98
+ def fresh_source_snapshot
99
+ self.class.new(root: root, config: config).source_snapshot
100
+ end
101
+
102
+ def source_domains
103
+ @source_domains ||= begin
104
+ analyzed = ruby_files.to_set
105
+ scan_files.to_h do |file|
106
+ [relative_path(file), analyzed.include?(file) ? :analyze : :reference]
107
+ end
108
+ end
109
+ end
110
+
111
+ def scope_diagnostics
112
+ @scope_diagnostics ||= begin
113
+ analyzed = ruby_files.to_set
114
+ references = reference_ruby_files.to_set
115
+ reference_only = (references - analyzed).map { |file| relative_path(file) }.sort
116
+ excluded_callers = ruby_files_outside_scopes.map { |file| relative_path(file) }
117
+ runtime_excluded_callers = excluded_callers.reject { |file| test_source_path?(file) }
118
+ potential = ruby_candidates.filter_map do |file|
119
+ next if analyzed.include?(file)
120
+ next unless potential_entry_point_path?(relative_path(file))
121
+
122
+ {
123
+ 'file' => relative_path(file),
124
+ 'reference_status' => references.include?(file) ? 'reference_only' : 'excluded'
125
+ }
126
+ end.sort_by { |entry| entry.fetch('file') }
127
+ {
128
+ 'analyze_file_count' => ruby_files.length,
129
+ 'reference_file_count' => reference_files.length,
130
+ 'reference_only_ruby_files' => reference_only,
131
+ 'potential_callers_outside_reference' => {
132
+ 'count' => excluded_callers.length,
133
+ 'runtime_count' => runtime_excluded_callers.length,
134
+ 'samples' => excluded_callers.first(20)
135
+ },
136
+ 'potential_entry_points_outside_analyze' => potential,
137
+ 'ignored_symlinks' => ignored_symlinks.sort,
138
+ 'source_discovery_issues' => source_discovery_issues
139
+ }.tap { warn_excluded_callers(runtime_excluded_callers) }
140
+ end
141
+ end
142
+
143
+ def scope_blockers
144
+ excluded = scope_diagnostics.fetch('potential_callers_outside_reference')
145
+ blockers = []
146
+ if excluded.fetch('runtime_count').positive?
147
+ blockers << Blocker.new(
148
+ kind: :reference_scope_incomplete,
149
+ scope_kind: :global,
150
+ scope_value: '*',
151
+ source: :source_discovery,
152
+ reason: "paths.reference excludes #{excluded.fetch('runtime_count')} non-test Ruby caller candidates",
153
+ suggested_action: :expand_reference_scope,
154
+ metadata: {
155
+ 'caller_domain' => 'runtime',
156
+ 'excluded_file_count' => excluded.fetch('count'),
157
+ 'excluded_runtime_file_count' => excluded.fetch('runtime_count'),
158
+ 'files' => excluded.fetch('samples')
159
+ }
160
+ )
161
+ end
162
+
163
+ runtime_issues = source_discovery_issues.reject { |issue| issue.fetch('domain') == 'test' }
164
+ if runtime_issues.any?
165
+ blockers << Blocker.new(
166
+ kind: :source_discovery_incomplete,
167
+ scope_kind: :global,
168
+ scope_value: '*',
169
+ source: :source_discovery,
170
+ reason: "#{runtime_issues.length} runtime source paths could not be inspected safely",
171
+ suggested_action: :review_source_discovery,
172
+ metadata: {
173
+ 'caller_domain' => 'runtime',
174
+ 'issue_count' => runtime_issues.length,
175
+ 'files' => runtime_issues.first(50)
176
+ }
177
+ )
178
+ end
179
+ blockers
180
+ end
181
+
47
182
  def test_file?(file)
48
- relative = relative_path(file)
49
- relative.start_with?('spec/', 'test/')
183
+ test_source_path?(relative_path(file))
50
184
  end
51
185
 
52
186
  def relative_path(file)
@@ -59,25 +193,170 @@ module Necropsy
59
193
 
60
194
  private
61
195
 
62
- def analyzable_file?(file)
196
+ def build_source_snapshot
197
+ files = cache_files.sort_by { |file| relative_path(file) }
198
+ return unavailable_source_snapshot('file_limit', files: files.length) if files.length > SOURCE_SNAPSHOT_MAX_FILES
199
+
200
+ total_bytes = files.sum { |file| File.size(file) }
201
+ return unavailable_source_snapshot('byte_limit', files: files.length, bytes: total_bytes) if total_bytes > SOURCE_SNAPSHOT_MAX_BYTES
202
+
203
+ digest = Digest::SHA256.new
204
+ files.each { |file| digest_source_file(digest, file) }
205
+ { 'status' => 'complete', 'sha256' => digest.hexdigest, 'files' => files.length, 'bytes' => total_bytes }
206
+ rescue SystemCallError, IOError, ArgumentError => e
207
+ unavailable_source_snapshot('read_error', error: e.class.name)
208
+ end
209
+
210
+ def digest_source_file(digest, file)
211
+ relative = relative_path(file).b
212
+ expected_bytes = File.size(file)
213
+ digest << [relative.bytesize].pack('Q>') << relative << [expected_bytes].pack('Q>')
214
+ actual_bytes = 0
215
+ File.open(file, 'rb') do |io|
216
+ while (chunk = io.read(SOURCE_DIGEST_CHUNK_BYTES))
217
+ actual_bytes += chunk.bytesize
218
+ digest << chunk
219
+ end
220
+ end
221
+ raise IOError, "source changed while reading #{relative}" unless actual_bytes == expected_bytes
222
+ end
223
+
224
+ def unavailable_source_snapshot(reason, details = {})
225
+ { 'status' => 'unavailable', 'sha256' => 'unavailable', 'reason' => reason }.merge(details)
226
+ end
227
+
228
+ def test_source_path?(relative)
229
+ config.test_paths.any? { |pattern| path_matches?(pattern, relative) }
230
+ end
231
+
232
+ def repository_files
233
+ @repository_files ||= begin
234
+ @ignored_symlinks = Set.new
235
+ @source_discovery_issues = []
236
+ files = []
237
+ begin
238
+ Find.find(root) do |file|
239
+ next if file == root
240
+
241
+ if excluded_repository_path?(file)
242
+ Find.prune if File.directory?(file) && !File.symlink?(file)
243
+ next
244
+ end
245
+ next if cache_output_path?(file)
246
+
247
+ if symlink_path?(file) || !real_path_within_root?(file)
248
+ if File.symlink?(file) || File.file?(file)
249
+ relative = relative_path(file)
250
+ @ignored_symlinks << relative
251
+ record_source_discovery_issue(relative, :symlink)
252
+ end
253
+ next
254
+ end
255
+
256
+ files << file if File.file?(file)
257
+ rescue ArgumentError, SystemCallError => e
258
+ record_source_discovery_issue(safe_relative_path(file), :inspection_error, error: e.class.name)
259
+ end
260
+ rescue ArgumentError, SystemCallError => e
261
+ record_source_discovery_issue('.', :enumeration_error, error: e.class.name)
262
+ end
263
+ files.sort
264
+ end
265
+ end
266
+
267
+ def ruby_candidates
268
+ @ruby_candidates ||= repository_files.select { |file| ruby_source?(file) }
269
+ end
270
+
271
+ def reference_file_set
272
+ @reference_file_set ||= reference_files.to_set
273
+ end
274
+
275
+ def ruby_files_outside_scopes
276
+ @ruby_files_outside_scopes ||= (ruby_candidates.to_set - scan_files.to_set).to_a.sort
277
+ end
278
+
279
+ def default_analyze_candidates
280
+ @default_analyze_candidates ||= ruby_candidates.select do |file|
281
+ relative = relative_path(file)
282
+ next false if relative.split(File::SEPARATOR).any? { |part| part.start_with?('.') }
283
+
284
+ relative.end_with?('.rb', '.rake') || relative == 'Rakefile' ||
285
+ relative.match?(%r{\A(?:bin|exe)/[^/]+\z}) || relative.match?(%r{\A[^/]+\.gemspec\z})
286
+ end
287
+ end
288
+
289
+ def ignored_symlinks
290
+ repository_files unless defined?(@ignored_symlinks)
291
+ @ignored_symlinks
292
+ end
293
+
294
+ def source_discovery_issues
295
+ repository_files unless defined?(@source_discovery_issues)
296
+ @source_discovery_issues.sort_by { |issue| [issue.fetch('file'), issue.fetch('reason')] }
297
+ end
298
+
299
+ def record_source_discovery_issue(file, reason, error: nil)
300
+ @source_discovery_issues ||= []
301
+ issue = {
302
+ 'file' => file,
303
+ 'reason' => reason.to_s,
304
+ 'domain' => test_source_path?(file) ? 'test' : 'runtime'
305
+ }
306
+ issue['error'] = error if error
307
+ @source_discovery_issues << issue unless @source_discovery_issues.include?(issue)
308
+ end
309
+
310
+ def safe_relative_path(file)
311
+ relative_path(file)
312
+ rescue ArgumentError, SystemCallError
313
+ file.to_s
314
+ end
315
+
316
+ def excluded_repository_path?(file)
63
317
  relative_parts = relative_path(file).split(File::SEPARATOR)
64
- return false if EXCLUDED_DIRECTORIES.include?(relative_parts.first)
318
+ EXCLUDED_DIRECTORIES.include?(relative_parts.first) ||
319
+ relative_parts.drop(1).intersect?(NESTED_EXCLUDED_DIRECTORIES)
320
+ rescue ArgumentError
321
+ true
322
+ end
65
323
 
66
- ruby_source?(file)
324
+ def cache_output_path?(file)
325
+ File.expand_path(file) == File.expand_path(config.cache_path, root)
67
326
  rescue ArgumentError
68
327
  false
69
328
  end
70
329
 
330
+ def symlink_path?(file)
331
+ current = file
332
+ until current == root
333
+ return true if File.symlink?(current)
334
+
335
+ parent = File.dirname(current)
336
+ return true if parent == current
337
+
338
+ current = parent
339
+ end
340
+ false
341
+ end
342
+
343
+ def real_path_within_root?(file)
344
+ real_root = @real_root ||= File.realpath(root)
345
+ real_file = File.realpath(file)
346
+ real_file == real_root || real_file.start_with?("#{real_root}#{File::SEPARATOR}")
347
+ end
348
+
71
349
  def ruby_source?(file)
72
350
  return true if file.end_with?('.rb', '.rake', '.gemspec') || File.basename(file) == 'Rakefile'
73
351
 
74
- File.open(file, &:readline).match?(/\A#!.*\bruby\b/)
75
- rescue EOFError, SystemCallError, EncodingError
352
+ File.binread(file, 256)&.match?(/\A\#![^\r\n]*\bruby\b/n) == true
353
+ rescue SystemCallError => e
354
+ record_source_discovery_issue(safe_relative_path(file), :read_error, error: e.class.name)
76
355
  false
77
356
  end
78
357
 
79
- def included_path?(relative)
80
- config.include_paths.any? { |pattern| path_matches?(pattern, relative) }
358
+ def analyzed_path?(relative)
359
+ config.analyze_paths.any? { |pattern| path_matches?(pattern, relative) }
81
360
  end
82
361
 
83
362
  def excluded_path?(relative)
@@ -85,26 +364,49 @@ module Necropsy
85
364
  end
86
365
 
87
366
  def path_matches?(pattern, relative)
88
- File.fnmatch?(pattern, relative, File::FNM_PATHNAME | File::FNM_EXTGLOB) ||
89
- File.fnmatch?(File.join(pattern, '**', '*'), relative, File::FNM_PATHNAME | File::FNM_EXTGLOB)
367
+ flags = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
368
+ File.fnmatch?(pattern, relative, flags) || File.fnmatch?(File.join(pattern, '**', '*'), relative, flags)
90
369
  end
91
370
 
92
371
  def warn_excluded_entry_points(candidates)
93
372
  excluded = candidates.filter_map do |file|
94
373
  relative = relative_path(file)
95
- relative if potential_entry_point_path?(relative) && !included_path?(relative)
374
+ relative if potential_entry_point_path?(relative) && !ruby_files_include_candidate?(relative)
96
375
  end
97
376
  return if excluded.empty?
98
377
 
99
378
  sample = excluded.sort.first(5)
100
379
  suffix = excluded.length > sample.length ? " and #{excluded.length - sample.length} more" : ''
101
- warn "Necropsy paths.include excludes potential entry points: #{sample.join(', ')}#{suffix}. " \
380
+ scope = excluded_scope_label
381
+ warn "Necropsy #{scope} excludes potential entry points: #{sample.join(', ')}#{suffix}. " \
102
382
  'Use report.include to filter findings without narrowing analysis.'
103
383
  end
104
384
 
385
+ def warn_excluded_callers(files)
386
+ return if files.empty?
387
+
388
+ sample = files.first(5)
389
+ suffix = files.length > sample.length ? " and #{files.length - sample.length} more" : ''
390
+ warn 'Necropsy paths.reference excludes Ruby files that may contain runtime callers: ' \
391
+ "#{sample.join(', ')}#{suffix}. Findings are blocked until the reference scope is expanded."
392
+ end
393
+
394
+ def excluded_scope_label
395
+ return 'paths.include' if config.legacy_include_paths?
396
+ return 'paths.analyze/paths.exclude' if config.analyze_paths.any? && config.exclude_paths.any?
397
+ return 'paths.exclude' if config.exclude_paths.any?
398
+
399
+ 'paths.analyze'
400
+ end
401
+
402
+ def ruby_files_include_candidate?(relative)
403
+ included = config.analyze_paths.none? || analyzed_path?(relative)
404
+ included && !excluded_path?(relative)
405
+ end
406
+
105
407
  def potential_entry_point_path?(relative)
106
408
  relative == 'Rakefile' || relative == 'config/routes.rb' ||
107
- relative.start_with?('bin/', 'exe/', 'spec/', 'test/') ||
409
+ relative.start_with?('bin/', 'exe/') || test_source_path?(relative) ||
108
410
  relative.end_with?('.rake', '.gemspec')
109
411
  end
110
412
  end
@@ -2,7 +2,24 @@
2
2
 
3
3
  module Necropsy
4
4
  module Reachability
5
- Result = Data.define(:runtime_paths, :test_paths) do
5
+ Result = Data.define(:runtime_paths, :test_paths, :external_paths) do
6
+ class << self
7
+ alias_method :data_new, :new
8
+
9
+ def new(*values, **attributes)
10
+ return data_new(runtime_paths: values[0], test_paths: values[1], external_paths: {}) if values.length == 2 && attributes.empty?
11
+
12
+ data_new(*values, **attributes)
13
+ end
14
+ alias_method :[], :new
15
+
16
+ private :data_new
17
+ end
18
+
19
+ def initialize(runtime_paths:, test_paths:, external_paths: {})
20
+ super
21
+ end
22
+
6
23
  def runtime_alive
7
24
  runtime_paths.keys
8
25
  end
@@ -11,8 +28,12 @@ module Necropsy
11
28
  test_paths.keys
12
29
  end
13
30
 
31
+ def external_alive
32
+ external_paths.keys
33
+ end
34
+
14
35
  def witness(node_id, kind: :runtime)
15
- paths = kind == :runtime ? runtime_paths : test_paths
36
+ paths = paths_for(kind)
16
37
  return unless paths.key?(node_id)
17
38
 
18
39
  chain = []
@@ -23,36 +44,52 @@ module Necropsy
23
44
  end
24
45
  chain
25
46
  end
47
+
48
+ private
49
+
50
+ def paths_for(kind)
51
+ case kind.to_sym
52
+ when :runtime then runtime_paths
53
+ when :test then test_paths
54
+ when :external then external_paths
55
+ else raise ArgumentError, 'kind must be runtime, test, or external'
56
+ end
57
+ end
26
58
  end
27
59
 
28
60
  class Engine
29
- def initialize(graph)
61
+ def initialize(graph, projection: :conservative, scope: nil)
30
62
  @graph = graph
63
+ @projection = EvidenceStore.normalize_projection(projection)
64
+ @scope = scope
31
65
  end
32
66
 
33
67
  def call
34
- runtime_roots = graph.entry_points.reject(&:test?).map(&:node_id)
35
- test_roots = graph.entry_points.select(&:test?).map(&:node_id)
68
+ roots = graph.entry_points.group_by(&:domain)
36
69
 
37
70
  Result.new(
38
- runtime_paths: traverse(runtime_roots),
39
- test_paths: traverse(test_roots)
71
+ runtime_paths: traverse(roots.fetch(:runtime, []).map(&:node_id), domain: :runtime),
72
+ test_paths: traverse(roots.fetch(:test, []).map(&:node_id), domain: :test),
73
+ external_paths: traverse(roots.fetch(:external, []).map(&:node_id), domain: :external)
40
74
  )
41
75
  end
42
76
 
43
77
  private
44
78
 
45
- attr_reader :graph
79
+ attr_reader :graph, :projection, :scope
46
80
 
47
- def traverse(roots)
81
+ def traverse(roots, domain:)
48
82
  visited = {}
49
- queue = roots.compact.uniq
83
+ queue = roots.compact.uniq.select { |node_id| traversable_in_domain?(node_id, domain) }
50
84
  queue.each { |node_id| visited[node_id] = nil }
85
+ head = 0
51
86
 
52
- until queue.empty?
53
- node_id = queue.shift
54
- graph.edges_from(node_id).each_key do |callee_id|
87
+ while head < queue.length
88
+ node_id = queue.fetch(head)
89
+ head += 1
90
+ graph.edges_from(node_id, projection: projection, scope: scope).each_key do |callee_id|
55
91
  next if visited.key?(callee_id)
92
+ next unless traversable_in_domain?(callee_id, domain)
56
93
 
57
94
  visited[callee_id] = node_id
58
95
  queue << callee_id
@@ -61,6 +98,10 @@ module Necropsy
61
98
 
62
99
  visited
63
100
  end
101
+
102
+ def traversable_in_domain?(node_id, domain)
103
+ domain == :test || !graph.nodes.fetch(node_id).test
104
+ end
64
105
  end
65
106
  end
66
107
  end