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,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
|
|
|
@@ -79,13 +87,23 @@ module Necropsy
|
|
|
79
87
|
end
|
|
80
88
|
|
|
81
89
|
def write_payload(result:, started_at:, finished_at:)
|
|
90
|
+
references = executed_references(result)
|
|
82
91
|
payload = {
|
|
83
|
-
'
|
|
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,
|
|
84
98
|
'observation' => {
|
|
85
99
|
'started_at' => started_at.iso8601,
|
|
86
100
|
'finished_at' => finished_at.iso8601,
|
|
87
101
|
'days' => [((finished_at - started_at) / 86_400.0).ceil, 1].max,
|
|
88
|
-
'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
|
+
}
|
|
89
107
|
}.tap { |observation| observation['run_id'] = run_id if run_id }
|
|
90
108
|
}
|
|
91
109
|
FileUtils.mkdir_p(File.dirname(output))
|
|
@@ -124,13 +142,19 @@ module Necropsy
|
|
|
124
142
|
end
|
|
125
143
|
|
|
126
144
|
def empty_payload?(payload)
|
|
127
|
-
Array(payload['nodes']).empty? && payload
|
|
145
|
+
Array(payload['nodes']).empty? && Array(payload['node_references']).empty? &&
|
|
146
|
+
payload.fetch('observation', {}).empty?
|
|
128
147
|
end
|
|
129
148
|
|
|
130
149
|
def merge_payload(left, right)
|
|
131
150
|
observation = merge_observation(left.fetch('observation', {}), right.fetch('observation', {}))
|
|
132
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'],
|
|
133
156
|
'nodes' => (Array(left['nodes']) + Array(right['nodes'])).uniq.sort,
|
|
157
|
+
'node_references' => merge_references(left['node_references'], right['node_references']),
|
|
134
158
|
'observation' => observation
|
|
135
159
|
}
|
|
136
160
|
end
|
|
@@ -138,11 +162,12 @@ module Necropsy
|
|
|
138
162
|
def merge_observation(left, right)
|
|
139
163
|
started_at = [left['started_at'], right['started_at']].compact.min
|
|
140
164
|
finished_at = [left['finished_at'], right['finished_at']].compact.max
|
|
141
|
-
observation =
|
|
165
|
+
observation = ObservationPolicy.compatible_merge(left, right)
|
|
142
166
|
observation['started_at'] = started_at if started_at
|
|
143
167
|
observation['finished_at'] = finished_at if finished_at
|
|
144
168
|
observation['days'] = merged_days(left, right, started_at, finished_at)
|
|
145
169
|
observation['collector'] = 'coverage'
|
|
170
|
+
observation['collector_overhead'] = merge_overhead(left, right)
|
|
146
171
|
observation['processes'] = process_count(left) + process_count(right)
|
|
147
172
|
observation
|
|
148
173
|
end
|
|
@@ -167,16 +192,25 @@ module Necropsy
|
|
|
167
192
|
1
|
|
168
193
|
end
|
|
169
194
|
|
|
170
|
-
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)
|
|
171
205
|
result.flat_map do |path, coverage|
|
|
172
206
|
next [] unless project_path?(path)
|
|
173
207
|
|
|
174
208
|
coverage.fetch(:methods, {}).filter_map do |method_key, count|
|
|
175
209
|
next unless count.to_i.positive?
|
|
176
210
|
|
|
177
|
-
|
|
211
|
+
node_reference_for(path, method_key)
|
|
178
212
|
end
|
|
179
|
-
end
|
|
213
|
+
end.uniq { |reference| RuntimeReference.key(reference) }.sort_by { |reference| RuntimeReference.sort_key(reference) }
|
|
180
214
|
end
|
|
181
215
|
|
|
182
216
|
def project_path?(path)
|
|
@@ -192,6 +226,24 @@ module Necropsy
|
|
|
192
226
|
"#{owner_name}#{separator}#{method_name}"
|
|
193
227
|
end
|
|
194
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
|
+
|
|
195
247
|
def owner_and_separator(owner)
|
|
196
248
|
return [owner.name, '#'] if owner.respond_to?(:name) && owner.name
|
|
197
249
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
4
|
require 'yaml'
|
|
5
|
+
require_relative 'runtime_reference'
|
|
5
6
|
|
|
6
7
|
module Necropsy
|
|
7
8
|
module Analyzers
|
|
@@ -16,29 +17,64 @@ module Necropsy
|
|
|
16
17
|
return AnalyzerResult.empty unless source
|
|
17
18
|
|
|
18
19
|
payload = load_payload(File.expand_path(source, project.root))
|
|
19
|
-
|
|
20
|
+
observation = ObservationPolicy.metadata(payload, expected_revision: config['expected_source_revision'])
|
|
21
|
+
malformed = []
|
|
22
|
+
alive = node_references(payload).map do |raw_reference|
|
|
23
|
+
node_id = normalized_or_raw(raw_reference, malformed, :node)
|
|
20
24
|
AliveEvidence.new(
|
|
21
25
|
node_id: node_id,
|
|
22
|
-
evidence: evidence(
|
|
23
|
-
|
|
26
|
+
evidence: evidence(
|
|
27
|
+
kind: :alive,
|
|
28
|
+
details: "Coverage marked #{reference_label(node_id)} as executed",
|
|
29
|
+
metadata: observation.merge('node_reference' => node_id),
|
|
30
|
+
grade: :observed,
|
|
31
|
+
relation: :execution,
|
|
32
|
+
source: { 'type' => profile.name.to_s, 'node_reference' => node_id },
|
|
33
|
+
scope: ObservationPolicy.evidence_scope(observation).merge('node_reference' => node_id)
|
|
34
|
+
)
|
|
24
35
|
)
|
|
25
36
|
end
|
|
26
37
|
|
|
27
|
-
edge_evidences =
|
|
28
|
-
|
|
29
|
-
|
|
38
|
+
edge_evidences = edge_references(payload).filter_map do |edge|
|
|
39
|
+
unless edge.is_a?(Hash)
|
|
40
|
+
malformed << malformed_reference(:edge, edge)
|
|
41
|
+
next
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
caller = edge['caller_id'] || edge[:caller_id]
|
|
45
|
+
callee = edge['callee_id'] || edge[:callee_id]
|
|
46
|
+
caller_id = normalized_or_raw(caller, malformed, :edge_caller)
|
|
47
|
+
callee_id = normalized_or_raw(callee, malformed, :edge_callee)
|
|
30
48
|
EdgeEvidence.new(
|
|
31
49
|
caller_id: caller_id,
|
|
32
50
|
callee_id: callee_id,
|
|
33
|
-
evidence: evidence(
|
|
51
|
+
evidence: evidence(
|
|
52
|
+
kind: :call_edge,
|
|
53
|
+
details: "Coverage observed #{reference_label(caller_id)} -> #{reference_label(callee_id)}",
|
|
54
|
+
metadata: { 'caller_reference' => caller_id, 'callee_reference' => callee_id },
|
|
55
|
+
grade: :observed,
|
|
56
|
+
relation: :observed_call,
|
|
57
|
+
source: {
|
|
58
|
+
'type' => profile.name.to_s,
|
|
59
|
+
'caller_reference' => caller_id,
|
|
60
|
+
'callee_reference' => callee_id
|
|
61
|
+
},
|
|
62
|
+
scope: ObservationPolicy.evidence_scope(observation).merge(
|
|
63
|
+
'caller_reference' => caller_id,
|
|
64
|
+
'callee_reference' => callee_id
|
|
65
|
+
)
|
|
66
|
+
)
|
|
34
67
|
)
|
|
35
68
|
end
|
|
69
|
+
observation = observation.merge('malformed_references' => malformed) unless malformed.empty?
|
|
36
70
|
|
|
37
71
|
AnalyzerResult.new(
|
|
38
72
|
edge_evidences: edge_evidences,
|
|
39
73
|
alive_evidences: alive,
|
|
40
74
|
uncertainties: {},
|
|
41
|
-
observation: { 'coverage' =>
|
|
75
|
+
observation: { 'coverage' => observation },
|
|
76
|
+
resolutions: [],
|
|
77
|
+
evidences: result_evidences(edge_evidences, alive)
|
|
42
78
|
)
|
|
43
79
|
end
|
|
44
80
|
|
|
@@ -47,7 +83,9 @@ module Necropsy
|
|
|
47
83
|
name: :coverage,
|
|
48
84
|
kind: :dynamic,
|
|
49
85
|
soundness: :observational,
|
|
50
|
-
description: 'Imports method execution and observed edges from Ruby Coverage output.'
|
|
86
|
+
description: 'Imports method execution and observed edges from Ruby Coverage output.',
|
|
87
|
+
version: Necropsy::VERSION,
|
|
88
|
+
assumptions: ['positive_observations_only']
|
|
51
89
|
)
|
|
52
90
|
end
|
|
53
91
|
|
|
@@ -55,6 +93,58 @@ module Necropsy
|
|
|
55
93
|
|
|
56
94
|
attr_reader :config
|
|
57
95
|
|
|
96
|
+
def node_references(payload)
|
|
97
|
+
structured = payload['node_references'] if payload.key?('node_references')
|
|
98
|
+
RuntimeReference.preferred(structured: structured, legacy: payload['executed'] || payload['nodes'])
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def edge_references(payload)
|
|
102
|
+
structured = payload['edge_references'] if payload.key?('edge_references')
|
|
103
|
+
return Array(payload['edges']) if structured.nil?
|
|
104
|
+
|
|
105
|
+
structured_edges = Array(structured)
|
|
106
|
+
structured_pairs = structured_edges.filter_map { |edge| edge_symbol_pair(edge) }.to_set
|
|
107
|
+
structured_edges + Array(payload['edges']).reject do |edge|
|
|
108
|
+
structured_pairs.include?(edge_symbol_pair(edge))
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def edge_symbol_pair(edge)
|
|
113
|
+
return unless edge.is_a?(Hash)
|
|
114
|
+
|
|
115
|
+
caller = RuntimeReference.normalize(edge['caller_id'] || edge[:caller_id])
|
|
116
|
+
callee = RuntimeReference.normalize(edge['callee_id'] || edge[:callee_id])
|
|
117
|
+
return unless caller && callee
|
|
118
|
+
|
|
119
|
+
[reference_symbol(caller), reference_symbol(callee)]
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def reference_symbol(reference)
|
|
123
|
+
reference.is_a?(Hash) ? reference['symbol_id'] : reference
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def normalized_or_raw(reference, malformed, kind)
|
|
127
|
+
normalized = RuntimeReference.normalize(reference)
|
|
128
|
+
return normalized if normalized
|
|
129
|
+
|
|
130
|
+
malformed << malformed_reference(kind, reference)
|
|
131
|
+
reference
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def malformed_reference(kind, reference)
|
|
135
|
+
{ 'kind' => kind.to_s, 'reference' => reference }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def reference_label(reference)
|
|
139
|
+
return reference.to_s unless reference.is_a?(Hash)
|
|
140
|
+
|
|
141
|
+
symbol = reference['symbol_id'] || reference[:symbol_id] || '<missing symbol>'
|
|
142
|
+
file = reference['file'] || reference[:file]
|
|
143
|
+
line = reference['line'] || reference[:line]
|
|
144
|
+
location = [file, line].compact.join(':')
|
|
145
|
+
location.empty? ? symbol.to_s : "#{symbol} at #{location}"
|
|
146
|
+
end
|
|
147
|
+
|
|
58
148
|
def load_payload(path)
|
|
59
149
|
raise Error, "Coverage source does not exist: #{path}" unless File.file?(path)
|
|
60
150
|
|