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,43 @@
|
|
|
1
|
+
# Performance profiling and cache decision
|
|
2
|
+
|
|
3
|
+
## Decision
|
|
4
|
+
|
|
5
|
+
Necropsy exposes an opt-in phase profiler through `Necropsy.analyze(profile: true)` and
|
|
6
|
+
records wall time, allocations, RSS, graph counts, resolution-cache hits, and report/index
|
|
7
|
+
size. The benchmark runner remains the release source for pinned corpus budgets.
|
|
8
|
+
|
|
9
|
+
The existing scan cache now keys every input file by SHA-256 content digest and writes through
|
|
10
|
+
a temporary file followed by rename. A same-size or restored-mtime edit therefore cannot reuse
|
|
11
|
+
stale facts, and an interrupted/corrupt write falls back to a fresh scan.
|
|
12
|
+
|
|
13
|
+
## PERF-02 no-go for now: per-file facts
|
|
14
|
+
|
|
15
|
+
The first measurement on the maintained sample fixture showed the analyzer phases dominating
|
|
16
|
+
the run (name resolution, CHA, and RTA) while the scan phase was smaller. That does not justify
|
|
17
|
+
the complexity and invalidation surface of a per-file fact store yet. The current cache keeps a
|
|
18
|
+
single deterministic scan result; a per-file cache should be proposed only after a pinned,
|
|
19
|
+
representative corpus demonstrates parse/scan as a material cost.
|
|
20
|
+
|
|
21
|
+
## Reconsideration threshold
|
|
22
|
+
|
|
23
|
+
Revisit per-file facts when the benchmark profile shows parse/scan as the largest phase or at
|
|
24
|
+
least 40% of wall time across two representative corpora, with a warm-run improvement target of
|
|
25
|
+
20% and no fresh-scan parity differences.
|
|
26
|
+
|
|
27
|
+
## PERF-03 no-go for now: process-parallel parsing
|
|
28
|
+
|
|
29
|
+
Process-parallel Prism parsing adds worker startup, serialization, deterministic ordinal merge,
|
|
30
|
+
crash recovery, and cache invalidation surfaces while the profiled scan phase is not dominant.
|
|
31
|
+
Thread parallelism is not a substitute because the supported Ruby engines do not provide the same
|
|
32
|
+
execution guarantees. Reconsider only after PERF-02's 40% scan threshold is met and a prototype
|
|
33
|
+
shows at least 25% cold-run improvement on two corpora with byte-identical reports and identical
|
|
34
|
+
health under worker failure.
|
|
35
|
+
|
|
36
|
+
## PERF-04 no-go for now: template/reference fact cache
|
|
37
|
+
|
|
38
|
+
Reference files already participate in the source snapshot and scan-cache key, but the conservative
|
|
39
|
+
reference barrier intentionally reruns after candidate generation. Persisting candidate-specific
|
|
40
|
+
matches would couple cache entries to physical identities, thresholds, parser policy, and ignored
|
|
41
|
+
artifact paths. Reconsider when reference/template analysis is at least 20% of p95 wall time on two
|
|
42
|
+
corpora and a prototype demonstrates byte-identical blockers across edits, budget exhaustion, and
|
|
43
|
+
generated-file policy changes.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Scope decisions from the 148-item adversarial review
|
|
2
|
+
|
|
3
|
+
This record covers proposals whose desired safety outcome is already met by a smaller mechanism,
|
|
4
|
+
or whose literal implementation would weaken the reviewed removal contract. A no-go is a completed
|
|
5
|
+
decision with a measurable reconsideration gate, not an untracked deferral.
|
|
6
|
+
|
|
7
|
+
## Definition activation and root-seeded RTA (#44, #52)
|
|
8
|
+
|
|
9
|
+
Necropsy does not emit `shadowed_definition`. Ruby activation depends on load order, conditional
|
|
10
|
+
reopens, `remove_method`, `undef_method`, and eval. Duplicate physical definitions remain visible
|
|
11
|
+
and blocked unless an evidenced load/activation witness closes those variables. Reconsider the
|
|
12
|
+
classification only when an activation oracle corpus covers those operations with zero
|
|
13
|
+
known-positive loss.
|
|
14
|
+
|
|
15
|
+
Root-seeded fixed-point RTA is also not enabled. Default RTA is rank-only and cannot remove an edge;
|
|
16
|
+
legacy pruning makes analysis health invalid. A second fixed point would therefore add complexity
|
|
17
|
+
without improving the safety claim. Reconsider it as a precision experiment only if two reviewed
|
|
18
|
+
corpora show at least a 10% blocked-candidate reduction with unchanged known-positive recall.
|
|
19
|
+
|
|
20
|
+
## Clusters, frontier, and a separate activation graph (#69, #70, #72)
|
|
21
|
+
|
|
22
|
+
The primary review identity remains a physical definition because baseline, quarantine, SARIF, and
|
|
23
|
+
source edits operate at that granularity. Collapsing cycles into removal units can hide a mixed-risk
|
|
24
|
+
member, while the current `why-not` payload already exposes witnesses, incoming sites, blockers, and
|
|
25
|
+
the next evidence for each definition. Literal load edges are represented as typed evidence and
|
|
26
|
+
unrooted load units are diagnosed without maintaining a second mutable graph.
|
|
27
|
+
|
|
28
|
+
Reconsider cluster/frontier presentation after a review-time study demonstrates at least a 20%
|
|
29
|
+
reduction in median triage time without missed mixed-risk definitions. Split the activation graph
|
|
30
|
+
only if a new analysis requires state that cannot be represented by typed load evidence.
|
|
31
|
+
|
|
32
|
+
## Template/reference parsers and generated files (#81, #90, #95)
|
|
33
|
+
|
|
34
|
+
Haml, Slim, Jbuilder, Builder, YAML, JSON, TOML, GraphQL SDL, SQL, and OpenAPI do not share a sound
|
|
35
|
+
notion of “method reference.” A token adapter branded as a parser would create unjustified negative
|
|
36
|
+
evidence. The current generic barrier only adds blockers; unreadable, oversized, generated, or
|
|
37
|
+
budget-exhausted runtime inputs add a global blocker and degraded health. ERB is the exception
|
|
38
|
+
because executable Ruby regions can be extracted and parsed by Prism.
|
|
39
|
+
|
|
40
|
+
`trusted_generated: true` is rejected: provenance does not prove that generated output cannot name
|
|
41
|
+
a runtime callback. Reconsider a format adapter only with a versioned grammar, conformance corpus,
|
|
42
|
+
and adversarial false-negative tests. Reconsider a generated bypass only when its producing input
|
|
43
|
+
is scanned and a content-addressed derivation proves reference equivalence.
|
|
44
|
+
|
|
45
|
+
## Presenter conventions (#85)
|
|
46
|
+
|
|
47
|
+
ActiveModelSerializers, Blueprinter, and ViewComponent have declared runtime contracts and use the
|
|
48
|
+
shared `ConventionRules` schema. “Presenter” alone has no gem-independent invocation contract, so
|
|
49
|
+
rooting every `*Presenter` public method would erase useful candidates. Projects can declare their
|
|
50
|
+
actual hook ancestry through `implicit_callers`. Add a built-in presenter rule only for a named gem
|
|
51
|
+
with documented dispatch semantics and a reviewed fixture.
|
|
52
|
+
|
|
53
|
+
## Compatibility producer versions (#115)
|
|
54
|
+
|
|
55
|
+
All current producers emit a concrete version. Legacy custom analyzers are normalized to the stable
|
|
56
|
+
literal `unversioned`; breaking their construction contract inside schema v2 would not improve the
|
|
57
|
+
claim because capability validation and atomic staging still gate complete resolution. Make profile
|
|
58
|
+
version mandatory only in the next major analyzer contract, with a migration reader for v2.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# TYPE-01 decision record
|
|
2
|
+
|
|
3
|
+
Status: no-go for external and interprocedural type providers in the removal decision.
|
|
4
|
+
|
|
5
|
+
The 0.4 implementation keeps a small `TypeFact` value object and an empty provider profile so that optional type evidence has a stable boundary. No Sorbet or RBS parser is enabled by default, and no type fact can remove a target unless it is explicitly authoritative and complete. Hints and conflicting facts remain explanatory evidence only.
|
|
6
|
+
|
|
7
|
+
The repository contains syntax fixtures with `.rbs` files, but those fixtures are not a reviewed type corpus: they do not label receiver facts, expected call targets, stale signatures, conflicting reopenings, or generated RBI/RBS behavior. Treating their existence as evidence for a provider would therefore be circular.
|
|
8
|
+
|
|
9
|
+
The same decision covers demand-driven points-to, interprocedural return and argument propagation,
|
|
10
|
+
constructor instance-variable summaries, and cross-load-unit constant facts. Those analyses can add
|
|
11
|
+
ranking evidence in an experiment, but cannot remove a conservative target until their open-world,
|
|
12
|
+
mutation, aliasing, and load-order assumptions are represented as claims. The existing local finite
|
|
13
|
+
flow remains bounded and fails closed; Proc/lambda values are local only.
|
|
14
|
+
|
|
15
|
+
## Reconsideration gate
|
|
16
|
+
|
|
17
|
+
Pin at least two reviewed RBS projects and two reviewed Sorbet projects. Each must label static call
|
|
18
|
+
targets and stale/conflicting type declarations at physical-definition granularity. Compare the
|
|
19
|
+
provider on/off for candidate precision, known-positive recall, blocked reduction, wall time, p95
|
|
20
|
+
RSS, and failure health. A provider may ship as hint-only when it improves a primary metric without
|
|
21
|
+
reducing recall. It may participate in complete resolution only after adversarial stale-signature,
|
|
22
|
+
dynamic-reopen, and load-order mutations preserve every known-positive target.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
PATH
|
|
2
|
+
remote: ..
|
|
3
|
+
specs:
|
|
4
|
+
necropsy (0.3.0)
|
|
5
|
+
prism (>= 1.0, < 2.0)
|
|
6
|
+
|
|
7
|
+
GEM
|
|
8
|
+
remote: https://rubygems.org/
|
|
9
|
+
specs:
|
|
10
|
+
diff-lcs (1.6.2)
|
|
11
|
+
prism (1.0.0)
|
|
12
|
+
rake (13.4.2)
|
|
13
|
+
rspec (3.13.2)
|
|
14
|
+
rspec-core (~> 3.13.0)
|
|
15
|
+
rspec-expectations (~> 3.13.0)
|
|
16
|
+
rspec-mocks (~> 3.13.0)
|
|
17
|
+
rspec-core (3.13.6)
|
|
18
|
+
rspec-support (~> 3.13.0)
|
|
19
|
+
rspec-expectations (3.13.5)
|
|
20
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
21
|
+
rspec-support (~> 3.13.0)
|
|
22
|
+
rspec-mocks (3.13.8)
|
|
23
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
|
24
|
+
rspec-support (~> 3.13.0)
|
|
25
|
+
rspec-support (3.13.7)
|
|
26
|
+
|
|
27
|
+
PLATFORMS
|
|
28
|
+
arm64-darwin-24
|
|
29
|
+
ruby
|
|
30
|
+
|
|
31
|
+
DEPENDENCIES
|
|
32
|
+
necropsy!
|
|
33
|
+
prism (~> 1.0.0)
|
|
34
|
+
rake (~> 13.0)
|
|
35
|
+
rspec (~> 3.0)
|
|
36
|
+
|
|
37
|
+
CHECKSUMS
|
|
38
|
+
bundler (4.0.13) sha256=19f08be7f27022cf0b89f27da0b044ae075e8270a9ef44ad248a932614e1ca3b
|
|
39
|
+
diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
|
|
40
|
+
necropsy (0.3.0)
|
|
41
|
+
prism (1.0.0) sha256=cc20cb9b52c2530a6a94f5fb532349ca7a5dca9c32e43bb7cef0cc7b1834db70
|
|
42
|
+
rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701
|
|
43
|
+
rspec (3.13.2) sha256=206284a08ad798e61f86d7ca3e376718d52c0bc944626b2349266f239f820587
|
|
44
|
+
rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
|
|
45
|
+
rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
|
|
46
|
+
rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
|
|
47
|
+
rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
|
|
48
|
+
|
|
49
|
+
BUNDLED WITH
|
|
50
|
+
4.0.13
|
data/lib/necropsy/analyzer.rb
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'digest'
|
|
4
|
+
require_relative 'bounded_canonicalizer'
|
|
5
|
+
|
|
3
6
|
module Necropsy
|
|
7
|
+
module EvidenceIdentity
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def generate(attributes)
|
|
11
|
+
"evidence:v1:#{Digest::SHA256.hexdigest(BoundedCanonicalizer.dump(attributes))}"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Analyzer-produced records are assembled from trusted scalar/model data.
|
|
15
|
+
# Sorting ordinary Hash keys and using the JSON encoder avoids the
|
|
16
|
+
# allocation-heavy type-tag/hex walk needed for untrusted legacy payloads.
|
|
17
|
+
# EvidenceStore still canonicalizes the final payload and quarantines any
|
|
18
|
+
# accidental fast-path collision.
|
|
19
|
+
def generate_fast(attributes)
|
|
20
|
+
"evidence:v1:#{Digest::SHA256.hexdigest(JSON.generate(fast_payload(attributes)))}"
|
|
21
|
+
rescue JSON::GeneratorError, TypeError, SystemStackError
|
|
22
|
+
generate(attributes)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def fast_payload(value)
|
|
26
|
+
case value
|
|
27
|
+
when Hash
|
|
28
|
+
value.keys.sort_by(&:to_s).to_h { |key| [key.to_s, fast_payload(value.fetch(key))] }
|
|
29
|
+
when Array
|
|
30
|
+
value.map { |item| fast_payload(item) }
|
|
31
|
+
when Symbol
|
|
32
|
+
value.to_s
|
|
33
|
+
else
|
|
34
|
+
value.respond_to?(:to_h) ? fast_payload(value.to_h) : value
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
private_class_method :fast_payload
|
|
38
|
+
end
|
|
39
|
+
private_constant :EvidenceIdentity
|
|
40
|
+
|
|
41
|
+
module EvidenceCollection
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
def collect(*collections)
|
|
45
|
+
records = collections.flatten.compact.map { |item| unwrap(item) }
|
|
46
|
+
records.uniq { |record| identity(record) }
|
|
47
|
+
.sort_by { |record| identity(record) }
|
|
48
|
+
.freeze
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def unwrap(item)
|
|
52
|
+
item.respond_to?(:evidence) ? item.evidence : item
|
|
53
|
+
end
|
|
54
|
+
private_class_method :unwrap
|
|
55
|
+
|
|
56
|
+
def identity(record)
|
|
57
|
+
evidence_id = record.evidence_id if record.respond_to?(:evidence_id)
|
|
58
|
+
return evidence_id if evidence_id
|
|
59
|
+
|
|
60
|
+
payload = record.respond_to?(:to_h) ? record.to_h.except('evidence_id') : record
|
|
61
|
+
EvidenceIdentity.generate(payload)
|
|
62
|
+
end
|
|
63
|
+
private_class_method :identity
|
|
64
|
+
end
|
|
65
|
+
private_constant :EvidenceCollection
|
|
66
|
+
|
|
4
67
|
class Analyzer
|
|
5
68
|
def analyze(_graph, _project)
|
|
6
69
|
raise NotImplementedError, "#{self.class} must implement #analyze"
|
|
@@ -10,10 +73,66 @@ module Necropsy
|
|
|
10
73
|
raise NotImplementedError, "#{self.class} must implement #profile"
|
|
11
74
|
end
|
|
12
75
|
|
|
76
|
+
def capabilities
|
|
77
|
+
[].freeze
|
|
78
|
+
end
|
|
79
|
+
|
|
13
80
|
private
|
|
14
81
|
|
|
15
|
-
def evidence(kind:, details:, analyzer:
|
|
16
|
-
|
|
82
|
+
def evidence(kind:, details:, analyzer: nil, weight: 1.0, metadata: {}, producer: nil, producer_version: nil,
|
|
83
|
+
grade: :heuristic, relation: nil, source: nil, assumptions: nil, scope: nil)
|
|
84
|
+
analyzer_profile = profile
|
|
85
|
+
analyzer ||= analyzer_profile.name
|
|
86
|
+
producer ||= analyzer
|
|
87
|
+
producer_version ||= analyzer_profile.version
|
|
88
|
+
relation ||= kind
|
|
89
|
+
assumptions = analyzer_profile.assumptions if assumptions.nil?
|
|
90
|
+
producer_version ||= 'unversioned'
|
|
91
|
+
source ||= { 'type' => 'analyzer', 'producer' => producer.to_s }
|
|
92
|
+
scope ||= {}
|
|
93
|
+
record = Evidence.new(
|
|
94
|
+
analyzer: analyzer, kind: kind, weight: weight, details: details, metadata: metadata,
|
|
95
|
+
producer: producer, producer_version: producer_version, grade: grade, relation: relation,
|
|
96
|
+
source: source, assumptions: assumptions, scope: scope
|
|
97
|
+
)
|
|
98
|
+
evidence_id = EvidenceIdentity.generate_fast(record.to_h.except('evidence_id'))
|
|
99
|
+
record.with(evidence_id: evidence_id)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def result_evidences(*collections)
|
|
103
|
+
EvidenceCollection.collect(*collections)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def resolution_record(site, targets, evidences, status: nil, rejected_targets: [], unknown_scope: nil)
|
|
107
|
+
analyzer_profile = profile
|
|
108
|
+
target_ids = targets.map(&:graph_id).uniq.sort
|
|
109
|
+
status ||= target_ids.empty? ? :unknown : :partial
|
|
110
|
+
unknown_scope = status == :complete ? nil : (unknown_scope || residual_scope(site))
|
|
111
|
+
ResolutionRecord.new(
|
|
112
|
+
resolution: Resolution.new(
|
|
113
|
+
call_site_id: site.call_site_id,
|
|
114
|
+
target_definition_ids: target_ids,
|
|
115
|
+
status: status,
|
|
116
|
+
unknown_scope: unknown_scope,
|
|
117
|
+
rejected_targets: rejected_targets,
|
|
118
|
+
evidence_ids: result_evidences(evidences).filter_map(&:evidence_id)
|
|
119
|
+
),
|
|
120
|
+
producer: analyzer_profile.name,
|
|
121
|
+
producer_version: analyzer_profile.version || 'unversioned',
|
|
122
|
+
assumptions: analyzer_profile.assumptions
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def residual_scope(site)
|
|
127
|
+
UnknownScope.new(scope_kind: :message, scope_value: site.message, match: :exact)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def call_site_evidence_source(site)
|
|
131
|
+
{ 'call_site_id' => site.call_site_id, 'file' => site.file, 'line' => site.line }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def call_site_evidence_scope(site)
|
|
135
|
+
{ 'call_site_id' => site.call_site_id, 'caller_definition_id' => site.caller_id }
|
|
17
136
|
end
|
|
18
137
|
end
|
|
19
138
|
end
|
|
@@ -4,41 +4,45 @@ require 'coverage'
|
|
|
4
4
|
require 'fileutils'
|
|
5
5
|
require 'time'
|
|
6
6
|
require 'yaml'
|
|
7
|
+
require_relative '../../clock'
|
|
8
|
+
require_relative 'runtime_reference'
|
|
9
|
+
require_relative 'observation_policy'
|
|
7
10
|
|
|
8
11
|
module Necropsy
|
|
9
12
|
module Analyzers
|
|
10
13
|
module Dynamic
|
|
11
14
|
class CoverageCollector
|
|
12
|
-
def self.record(root:, output:, &)
|
|
13
|
-
new(root: root, output: output).record(&)
|
|
15
|
+
def self.record(root:, output:, clock: nil, &)
|
|
16
|
+
new(root: root, output: output, clock: clock).record(&)
|
|
14
17
|
end
|
|
15
18
|
|
|
16
|
-
def self.install_at_exit(root:, output:, merge: false, run_id: nil)
|
|
17
|
-
new(root: root, output: output, merge: merge, run_id: run_id).install_at_exit
|
|
19
|
+
def self.install_at_exit(root:, output:, merge: false, run_id: nil, clock: nil)
|
|
20
|
+
new(root: root, output: output, merge: merge, run_id: run_id, clock: clock).install_at_exit
|
|
18
21
|
end
|
|
19
22
|
|
|
20
|
-
def initialize(root:, output:, merge: false, run_id: nil)
|
|
23
|
+
def initialize(root:, output:, merge: false, run_id: nil, clock: nil)
|
|
21
24
|
@root = File.expand_path(root)
|
|
22
25
|
@output = output
|
|
23
26
|
@merge = merge
|
|
24
27
|
@run_id = run_id
|
|
28
|
+
@clock = clock || -> { Clock.new.time }
|
|
25
29
|
end
|
|
26
30
|
|
|
27
31
|
def record
|
|
28
|
-
started_at =
|
|
32
|
+
started_at = current_time
|
|
29
33
|
Coverage.start(methods: true)
|
|
30
34
|
yield
|
|
31
|
-
write_payload(result: Coverage.result, started_at: started_at, finished_at:
|
|
35
|
+
write_payload(result: Coverage.result, started_at: started_at, finished_at: current_time)
|
|
32
36
|
ensure
|
|
33
37
|
Coverage.result(stop: true, clear: true) if Coverage.running?
|
|
34
38
|
end
|
|
35
39
|
|
|
36
40
|
def install_at_exit
|
|
37
|
-
started_at =
|
|
41
|
+
started_at = current_time
|
|
38
42
|
started = start_coverage
|
|
39
43
|
|
|
40
44
|
at_exit do
|
|
41
|
-
finished_at =
|
|
45
|
+
finished_at = current_time
|
|
42
46
|
result = coverage_result(started: started)
|
|
43
47
|
write_payload(result: result, started_at: started_at, finished_at: finished_at)
|
|
44
48
|
rescue StandardError => e
|
|
@@ -48,12 +52,16 @@ module Necropsy
|
|
|
48
52
|
|
|
49
53
|
private
|
|
50
54
|
|
|
51
|
-
attr_reader :root, :output, :run_id
|
|
55
|
+
attr_reader :root, :output, :run_id, :clock
|
|
52
56
|
|
|
53
57
|
def merge?
|
|
54
58
|
@merge
|
|
55
59
|
end
|
|
56
60
|
|
|
61
|
+
def current_time
|
|
62
|
+
clock.call.utc
|
|
63
|
+
end
|
|
64
|
+
|
|
57
65
|
def start_coverage
|
|
58
66
|
return false if Coverage.running?
|
|
59
67
|
|
|
@@ -63,19 +71,39 @@ module Necropsy
|
|
|
63
71
|
|
|
64
72
|
def coverage_result(started:)
|
|
65
73
|
return Coverage.result(stop: true, clear: true) if started && Coverage.running?
|
|
66
|
-
|
|
74
|
+
|
|
75
|
+
if Coverage.respond_to?(:peek_result) && Coverage.running?
|
|
76
|
+
result = Coverage.peek_result
|
|
77
|
+
return result if method_coverage?(result)
|
|
78
|
+
|
|
79
|
+
warn 'Necropsy coverage collector found Coverage already running without methods: true; no methods were recorded.'
|
|
80
|
+
end
|
|
67
81
|
|
|
68
82
|
{}
|
|
69
83
|
end
|
|
70
84
|
|
|
85
|
+
def method_coverage?(result)
|
|
86
|
+
result.values.any? { |coverage| coverage.is_a?(Hash) && coverage.key?(:methods) }
|
|
87
|
+
end
|
|
88
|
+
|
|
71
89
|
def write_payload(result:, started_at:, finished_at:)
|
|
90
|
+
references = executed_references(result)
|
|
72
91
|
payload = {
|
|
73
|
-
'
|
|
92
|
+
'schema_version' => 2,
|
|
93
|
+
'collector' => { 'name' => 'necropsy-coverage', 'version' => Necropsy::VERSION },
|
|
94
|
+
'scope' => { 'sample_unit' => 'process', 'sample_rate' => 1.0 },
|
|
95
|
+
'quality' => { 'dropped_events' => 0, 'overflowed' => false },
|
|
96
|
+
'nodes' => references.map { |reference| reference.fetch('symbol_id') }.uniq.sort,
|
|
97
|
+
'node_references' => references,
|
|
74
98
|
'observation' => {
|
|
75
99
|
'started_at' => started_at.iso8601,
|
|
76
100
|
'finished_at' => finished_at.iso8601,
|
|
77
101
|
'days' => [((finished_at - started_at) / 86_400.0).ceil, 1].max,
|
|
78
|
-
'collector' => 'coverage'
|
|
102
|
+
'collector' => 'coverage',
|
|
103
|
+
'collector_overhead' => {
|
|
104
|
+
'wall_time_seconds' => [finished_at - started_at, 0].max.round(6),
|
|
105
|
+
'observed_nodes' => references.length
|
|
106
|
+
}
|
|
79
107
|
}.tap { |observation| observation['run_id'] = run_id if run_id }
|
|
80
108
|
}
|
|
81
109
|
FileUtils.mkdir_p(File.dirname(output))
|
|
@@ -99,7 +127,7 @@ module Necropsy
|
|
|
99
127
|
content = file.read
|
|
100
128
|
return {} if content.empty?
|
|
101
129
|
|
|
102
|
-
YAML.
|
|
130
|
+
YAML.safe_load(content, aliases: false) || {}
|
|
103
131
|
rescue Psych::Exception
|
|
104
132
|
{}
|
|
105
133
|
end
|
|
@@ -114,13 +142,19 @@ module Necropsy
|
|
|
114
142
|
end
|
|
115
143
|
|
|
116
144
|
def empty_payload?(payload)
|
|
117
|
-
Array(payload['nodes']).empty? && payload
|
|
145
|
+
Array(payload['nodes']).empty? && Array(payload['node_references']).empty? &&
|
|
146
|
+
payload.fetch('observation', {}).empty?
|
|
118
147
|
end
|
|
119
148
|
|
|
120
149
|
def merge_payload(left, right)
|
|
121
150
|
observation = merge_observation(left.fetch('observation', {}), right.fetch('observation', {}))
|
|
122
151
|
{
|
|
152
|
+
'schema_version' => [left['schema_version'], right['schema_version'], 2].compact.max,
|
|
153
|
+
'collector' => right['collector'] || left['collector'],
|
|
154
|
+
'scope' => right['scope'] || left['scope'],
|
|
155
|
+
'quality' => right['quality'] || left['quality'],
|
|
123
156
|
'nodes' => (Array(left['nodes']) + Array(right['nodes'])).uniq.sort,
|
|
157
|
+
'node_references' => merge_references(left['node_references'], right['node_references']),
|
|
124
158
|
'observation' => observation
|
|
125
159
|
}
|
|
126
160
|
end
|
|
@@ -128,11 +162,12 @@ module Necropsy
|
|
|
128
162
|
def merge_observation(left, right)
|
|
129
163
|
started_at = [left['started_at'], right['started_at']].compact.min
|
|
130
164
|
finished_at = [left['finished_at'], right['finished_at']].compact.max
|
|
131
|
-
observation =
|
|
165
|
+
observation = ObservationPolicy.compatible_merge(left, right)
|
|
132
166
|
observation['started_at'] = started_at if started_at
|
|
133
167
|
observation['finished_at'] = finished_at if finished_at
|
|
134
168
|
observation['days'] = merged_days(left, right, started_at, finished_at)
|
|
135
169
|
observation['collector'] = 'coverage'
|
|
170
|
+
observation['collector_overhead'] = merge_overhead(left, right)
|
|
136
171
|
observation['processes'] = process_count(left) + process_count(right)
|
|
137
172
|
observation
|
|
138
173
|
end
|
|
@@ -157,16 +192,25 @@ module Necropsy
|
|
|
157
192
|
1
|
|
158
193
|
end
|
|
159
194
|
|
|
160
|
-
def
|
|
195
|
+
def merge_overhead(left, right)
|
|
196
|
+
left_overhead = left.fetch('collector_overhead', {})
|
|
197
|
+
right_overhead = right.fetch('collector_overhead', {})
|
|
198
|
+
{
|
|
199
|
+
'wall_time_seconds' => left_overhead['wall_time_seconds'].to_f + right_overhead['wall_time_seconds'].to_f,
|
|
200
|
+
'observed_nodes' => left_overhead['observed_nodes'].to_i + right_overhead['observed_nodes'].to_i
|
|
201
|
+
}
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def executed_references(result)
|
|
161
205
|
result.flat_map do |path, coverage|
|
|
162
206
|
next [] unless project_path?(path)
|
|
163
207
|
|
|
164
208
|
coverage.fetch(:methods, {}).filter_map do |method_key, count|
|
|
165
209
|
next unless count.to_i.positive?
|
|
166
210
|
|
|
167
|
-
|
|
211
|
+
node_reference_for(path, method_key)
|
|
168
212
|
end
|
|
169
|
-
end
|
|
213
|
+
end.uniq { |reference| RuntimeReference.key(reference) }.sort_by { |reference| RuntimeReference.sort_key(reference) }
|
|
170
214
|
end
|
|
171
215
|
|
|
172
216
|
def project_path?(path)
|
|
@@ -182,6 +226,24 @@ module Necropsy
|
|
|
182
226
|
"#{owner_name}#{separator}#{method_name}"
|
|
183
227
|
end
|
|
184
228
|
|
|
229
|
+
def node_reference_for(path, method_key)
|
|
230
|
+
symbol_id = node_id_for(method_key)
|
|
231
|
+
return unless symbol_id
|
|
232
|
+
|
|
233
|
+
RuntimeReference.build(
|
|
234
|
+
symbol_id: symbol_id,
|
|
235
|
+
file: RuntimeReference.relative_file(root, path),
|
|
236
|
+
line: method_key[2]
|
|
237
|
+
)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def merge_references(left, right)
|
|
241
|
+
(Array(left) + Array(right))
|
|
242
|
+
.filter_map { |reference| RuntimeReference.normalize(reference) }
|
|
243
|
+
.uniq { |reference| RuntimeReference.key(reference) }
|
|
244
|
+
.sort_by { |reference| RuntimeReference.sort_key(reference) }
|
|
245
|
+
end
|
|
246
|
+
|
|
185
247
|
def owner_and_separator(owner)
|
|
186
248
|
return [owner.name, '#'] if owner.respond_to?(:name) && owner.name
|
|
187
249
|
|