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.
- checksums.yaml +4 -4
- data/.rubocop.yml +33 -0
- data/CHANGELOG.md +44 -0
- data/MEASUREMENTS.md +112 -0
- data/README.md +233 -16
- data/Rakefile +3 -1
- 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 +81 -19
- data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +104 -10
- data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +74 -250
- 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 +225 -20
- 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 -69
- data/lib/necropsy/analyzers/static/name_resolution.rb +151 -21
- data/lib/necropsy/analyzers/static/rta.rb +249 -25
- data/lib/necropsy/ast_scanner/call_recording.rb +275 -0
- 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 +532 -0
- data/lib/necropsy/ast_scanner/method_definitions.rb +358 -0
- data/lib/necropsy/ast_scanner/references.rb +115 -0
- data/lib/necropsy/ast_scanner/ruby_semantics.rb +263 -0
- data/lib/necropsy/ast_scanner/traversal.rb +347 -0
- data/lib/necropsy/ast_scanner/value_definitions.rb +78 -0
- data/lib/necropsy/ast_scanner.rb +89 -564
- data/lib/necropsy/bench/candidate_union.rb +555 -0
- data/lib/necropsy/bench/claim_gate.rb +112 -0
- data/lib/necropsy/bench/evaluator.rb +356 -24
- 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 +94 -18
- data/lib/necropsy/call_site_identity.rb +54 -0
- data/lib/necropsy/cli.rb +296 -53
- data/lib/necropsy/clock.rb +40 -0
- data/lib/necropsy/confidence/scorer.rb +181 -58
- data/lib/necropsy/configuration.rb +339 -18
- 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 +342 -0
- data/lib/necropsy/embedded_ruby.rb +55 -0
- data/lib/necropsy/entry_points/plain.rb +125 -7
- data/lib/necropsy/entry_points/rails.rb +422 -105
- 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 +1234 -62
- 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 +359 -13
- data/lib/necropsy/guardrail/diff.rb +5 -2
- data/lib/necropsy/guardrail/quarantine.rb +101 -10
- data/lib/necropsy/load_graph.rb +206 -0
- data/lib/necropsy/models.rb +894 -21
- data/lib/necropsy/performance_profiler.rb +108 -0
- data/lib/necropsy/project.rb +361 -13
- data/lib/necropsy/reachability/engine.rb +80 -15
- data/lib/necropsy/reference_barrier.rb +458 -0
- data/lib/necropsy/report.rb +153 -14
- data/lib/necropsy/reporter.rb +441 -19
- data/lib/necropsy/runner.rb +258 -14
- data/lib/necropsy/runtime_feedback.rb +136 -0
- data/lib/necropsy/semantics_matrix.rb +153 -0
- data/lib/necropsy/trace_point_runtime.rb +19 -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 +39 -2
- data/schema/necropsy-report-v2.schema.json +366 -0
- data/script/measure.rb +20 -0
- 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
|
data/lib/necropsy/project.rb
CHANGED
|
@@ -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
|
-
.
|
|
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(
|
|
28
|
-
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
|
|
29
41
|
end
|
|
30
42
|
end
|
|
31
43
|
|
|
32
44
|
def ruby_files
|
|
33
45
|
@ruby_files ||= begin
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(:
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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 =
|
|
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
|
-
|
|
31
|
-
node_id = queue.
|
|
32
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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
|