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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/MEASUREMENTS.md +27 -0
- data/README.md +182 -17
- data/bench/README.md +92 -0
- data/bench/audit.rb +113 -0
- data/bench/audits/0.2.1/audit.json +501 -0
- data/bench/audits/0.2.1/audit.md +55 -0
- data/bench/audits/0.2.1/baseline_performance.yml +16 -0
- data/bench/audits/0.2.1/config.yml +42 -0
- data/bench/audits/0.2.1/review.yml +56 -0
- data/bench/audits/0.2.1/review_queue.yml +3651 -0
- data/bench/corpora/v1/README.md +30 -0
- data/bench/corpora/v1/labels.yml +37 -0
- data/bench/corpora/v1/manifest.yml +63 -0
- data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
- data/bench/corpora/v1/self.necropsy.yml +8 -0
- data/bench/corpora/v1/tools/debride.yml +42 -0
- data/bench/corpora/v1/tools/spoom.yml +12 -0
- data/bench/corpora/v1/tools/type_aware.yml +11 -0
- data/bench/golden/v1/candidate_union.json +21918 -0
- data/bench/golden/v1/metadata.json +12 -0
- data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
- data/bench/golden/v1/reports/plain_ruby.json +135 -0
- data/bench/golden/v1/reports/rails.json +176 -0
- data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
- data/bench/golden/v1/reports/self.json +3449 -0
- data/bench/review_queue.rb +35 -0
- data/bench/run.rb +31 -0
- data/bench/schema/candidate-union-v1.schema.json +70 -0
- data/docs/impv_implementation_matrix.md +179 -0
- data/docs/migrations/0.2.1.md +57 -0
- data/docs/migrations/0.3.0.md +207 -0
- data/docs/migrations/0.4.0.md +13 -0
- data/docs/necropsy_performance_adr.md +43 -0
- data/docs/necropsy_scope_decisions.md +58 -0
- data/docs/necropsy_type_facts_adr.md +22 -0
- data/gemfiles/prism_min.gemfile +9 -0
- data/gemfiles/prism_min.gemfile.lock +50 -0
- data/lib/necropsy/analyzer.rb +121 -2
- data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
- data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
- data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
- data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
- data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
- data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
- data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
- data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
- data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
- data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
- data/lib/necropsy/analyzers/static/cha.rb +33 -73
- data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
- data/lib/necropsy/analyzers/static/rta.rb +237 -29
- data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
- data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
- data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
- data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
- data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
- data/lib/necropsy/ast_scanner/references.rb +43 -9
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
- data/lib/necropsy/ast_scanner/traversal.rb +234 -71
- data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
- data/lib/necropsy/ast_scanner.rb +67 -6
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +329 -16
- data/lib/necropsy/bench/finding_facts.rb +152 -0
- data/lib/necropsy/bench/precision_gate.rb +144 -0
- data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
- data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
- data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
- data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
- data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
- data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
- data/lib/necropsy/bench/release_audit.rb +360 -0
- data/lib/necropsy/bench/report_normalizer.rb +140 -0
- data/lib/necropsy/bench/review_queue.rb +154 -0
- data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
- data/lib/necropsy/bench/seed_runner.rb +408 -0
- data/lib/necropsy/bounded_canonicalizer.rb +218 -0
- data/lib/necropsy/cache/scan_cache.rb +85 -17
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +220 -33
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +103 -58
- data/lib/necropsy/configuration.rb +224 -21
- data/lib/necropsy/convention_rules.rb +138 -0
- data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
- data/lib/necropsy/definition_identity.rb +37 -0
- data/lib/necropsy/diagnostics.rb +176 -36
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +111 -10
- data/lib/necropsy/entry_points/rails.rb +322 -41
- data/lib/necropsy/entry_points/test.rb +6 -1
- data/lib/necropsy/flow_interpreter.rb +460 -0
- data/lib/necropsy/graph/blocker_matching.rb +338 -0
- data/lib/necropsy/graph/call_graph.rb +1099 -109
- data/lib/necropsy/graph/definition_index.rb +149 -0
- data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
- data/lib/necropsy/graph/evidence_store.rb +213 -0
- data/lib/necropsy/graph/resolution_store.rb +497 -0
- data/lib/necropsy/graph_self_check.rb +79 -0
- data/lib/necropsy/guardrail/baseline.rb +350 -13
- data/lib/necropsy/guardrail/quarantine.rb +94 -9
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +878 -13
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +327 -25
- data/lib/necropsy/reachability/engine.rb +54 -13
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +113 -4
- data/lib/necropsy/reporter.rb +431 -15
- data/lib/necropsy/runner.rb +233 -18
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/type_facts.rb +53 -0
- data/lib/necropsy/version.rb +1 -1
- data/lib/necropsy/why_not_explanation.rb +436 -0
- data/lib/necropsy/why_not_renderer.rb +197 -0
- data/lib/necropsy/world_policy.rb +90 -0
- data/lib/necropsy.rb +35 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- 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
|
data/lib/necropsy/project.rb
CHANGED
|
@@ -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(
|
|
27
|
-
AstScanner.new(
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
candidates
|
|
39
|
-
candidates.select! { |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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
75
|
-
rescue
|
|
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
|
|
80
|
-
config.
|
|
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
|
-
|
|
89
|
-
|
|
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) && !
|
|
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
|
-
|
|
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/'
|
|
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
|
|
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
|
-
|
|
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(
|
|
39
|
-
test_paths: traverse(
|
|
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
|
-
|
|
53
|
-
node_id = queue.
|
|
54
|
-
|
|
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
|