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
@@ -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,13 +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
10
- .serena
15
+ .necropsy_cache
16
+ .ruby-lsp
11
17
  coverage
12
18
  doc
13
19
  node_modules
@@ -15,6 +21,7 @@ module Necropsy
15
21
  tmp
16
22
  vendor
17
23
  ].freeze
24
+ NESTED_EXCLUDED_DIRECTORIES = EXCLUDED_DIRECTORIES.freeze
18
25
 
19
26
  attr_reader :root, :config
20
27
 
@@ -24,25 +31,156 @@ module Necropsy
24
31
  end
25
32
 
26
33
  def scan_result
27
- @scan_result ||= Cache::ScanCache.new(project: self).fetch(ruby_files) do
28
- 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
29
41
  end
30
42
  end
31
43
 
32
44
  def ruby_files
33
45
  @ruby_files ||= begin
34
- globbed = Dir.glob(File.join(root, '**', '*.rb'))
35
- special = %w[Rakefile].map { |name| File.join(root, name) }.select { |file| File.file?(file) }
36
- rake = Dir.glob(File.join(root, '**', '*.rake'))
37
- executables = Dir.glob(File.join(root, '{bin,exe}', '*')).select { |file| File.file?(file) }
38
- gemspecs = Dir.glob(File.join(root, '*.gemspec'))
39
- (globbed + special + rake + executables + gemspecs).uniq.select { |file| analyzable_file?(file) }.sort
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?
53
+ candidates.reject! { |file| excluded_path?(relative_path(file)) }
54
+ candidates.sort
40
55
  end
41
56
  end
42
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
+
43
182
  def test_file?(file)
44
- relative = relative_path(file)
45
- relative.start_with?('spec/', 'test/')
183
+ test_source_path?(relative_path(file))
46
184
  end
47
185
 
48
186
  def relative_path(file)
@@ -55,11 +193,221 @@ module Necropsy
55
193
 
56
194
  private
57
195
 
58
- 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)
59
317
  relative_parts = relative_path(file).split(File::SEPARATOR)
60
- !relative_parts.intersect?(EXCLUDED_DIRECTORIES)
318
+ EXCLUDED_DIRECTORIES.include?(relative_parts.first) ||
319
+ relative_parts.drop(1).intersect?(NESTED_EXCLUDED_DIRECTORIES)
320
+ rescue ArgumentError
321
+ true
322
+ end
323
+
324
+ def cache_output_path?(file)
325
+ File.expand_path(file) == File.expand_path(config.cache_path, root)
61
326
  rescue ArgumentError
62
327
  false
63
328
  end
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
+
349
+ def ruby_source?(file)
350
+ return true if file.end_with?('.rb', '.rake', '.gemspec') || File.basename(file) == 'Rakefile'
351
+
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)
355
+ false
356
+ end
357
+
358
+ def analyzed_path?(relative)
359
+ config.analyze_paths.any? { |pattern| path_matches?(pattern, relative) }
360
+ end
361
+
362
+ def excluded_path?(relative)
363
+ config.exclude_paths.any? { |pattern| path_matches?(pattern, relative) }
364
+ end
365
+
366
+ def path_matches?(pattern, relative)
367
+ flags = File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH
368
+ File.fnmatch?(pattern, relative, flags) || File.fnmatch?(File.join(pattern, '**', '*'), relative, flags)
369
+ end
370
+
371
+ def warn_excluded_entry_points(candidates)
372
+ excluded = candidates.filter_map do |file|
373
+ relative = relative_path(file)
374
+ relative if potential_entry_point_path?(relative) && !ruby_files_include_candidate?(relative)
375
+ end
376
+ return if excluded.empty?
377
+
378
+ sample = excluded.sort.first(5)
379
+ suffix = excluded.length > sample.length ? " and #{excluded.length - sample.length} more" : ''
380
+ scope = excluded_scope_label
381
+ warn "Necropsy #{scope} excludes potential entry points: #{sample.join(', ')}#{suffix}. " \
382
+ 'Use report.include to filter findings without narrowing analysis.'
383
+ end
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
+
407
+ def potential_entry_point_path?(relative)
408
+ relative == 'Rakefile' || relative == 'config/routes.rb' ||
409
+ relative.start_with?('bin/', 'exe/') || test_source_path?(relative) ||
410
+ relative.end_with?('.rake', '.gemspec')
411
+ end
64
412
  end
65
413
  end
@@ -2,41 +2,106 @@
2
2
 
3
3
  module Necropsy
4
4
  module Reachability
5
- Result = Data.define(:runtime_alive, :test_alive)
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
+
23
+ def runtime_alive
24
+ runtime_paths.keys
25
+ end
26
+
27
+ def test_alive
28
+ test_paths.keys
29
+ end
30
+
31
+ def external_alive
32
+ external_paths.keys
33
+ end
34
+
35
+ def witness(node_id, kind: :runtime)
36
+ paths = paths_for(kind)
37
+ return unless paths.key?(node_id)
38
+
39
+ chain = []
40
+ current = node_id
41
+ while current
42
+ chain.unshift(current)
43
+ current = paths[current]
44
+ end
45
+ chain
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
58
+ end
6
59
 
7
60
  class Engine
8
- def initialize(graph)
61
+ def initialize(graph, projection: :conservative, scope: nil)
9
62
  @graph = graph
63
+ @projection = EvidenceStore.normalize_projection(projection)
64
+ @scope = scope
10
65
  end
11
66
 
12
67
  def call
13
- runtime_roots = graph.entry_points.reject(&:test?).map(&:node_id)
14
- test_roots = graph.entry_points.select(&:test?).map(&:node_id)
68
+ roots = graph.entry_points.group_by(&:domain)
15
69
 
16
70
  Result.new(
17
- runtime_alive: traverse(runtime_roots),
18
- test_alive: 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)
19
74
  )
20
75
  end
21
76
 
22
77
  private
23
78
 
24
- attr_reader :graph
79
+ attr_reader :graph, :projection, :scope
25
80
 
26
- def traverse(roots)
27
- visited = Set.new
28
- queue = roots.compact.uniq
81
+ def traverse(roots, domain:)
82
+ visited = {}
83
+ queue = roots.compact.uniq.select { |node_id| traversable_in_domain?(node_id, domain) }
84
+ queue.each { |node_id| visited[node_id] = nil }
85
+ head = 0
29
86
 
30
- until queue.empty?
31
- node_id = queue.shift
32
- next if visited.include?(node_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|
91
+ next if visited.key?(callee_id)
92
+ next unless traversable_in_domain?(callee_id, domain)
33
93
 
34
- visited << node_id
35
- graph.edges_from(node_id).each_key { |callee_id| queue << callee_id unless visited.include?(callee_id) }
94
+ visited[callee_id] = node_id
95
+ queue << callee_id
96
+ end
36
97
  end
37
98
 
38
99
  visited
39
100
  end
101
+
102
+ def traversable_in_domain?(node_id, domain)
103
+ domain == :test || !graph.nodes.fetch(node_id).test
104
+ end
40
105
  end
41
106
  end
42
107
  end