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.
Files changed (126) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/MEASUREMENTS.md +27 -0
  4. data/README.md +182 -17
  5. data/bench/README.md +92 -0
  6. data/bench/audit.rb +113 -0
  7. data/bench/audits/0.2.1/audit.json +501 -0
  8. data/bench/audits/0.2.1/audit.md +55 -0
  9. data/bench/audits/0.2.1/baseline_performance.yml +16 -0
  10. data/bench/audits/0.2.1/config.yml +42 -0
  11. data/bench/audits/0.2.1/review.yml +56 -0
  12. data/bench/audits/0.2.1/review_queue.yml +3651 -0
  13. data/bench/corpora/v1/README.md +30 -0
  14. data/bench/corpora/v1/labels.yml +37 -0
  15. data/bench/corpora/v1/manifest.yml +63 -0
  16. data/bench/corpora/v1/rubocop.necropsy.yml +7 -0
  17. data/bench/corpora/v1/self.necropsy.yml +8 -0
  18. data/bench/corpora/v1/tools/debride.yml +42 -0
  19. data/bench/corpora/v1/tools/spoom.yml +12 -0
  20. data/bench/corpora/v1/tools/type_aware.yml +11 -0
  21. data/bench/golden/v1/candidate_union.json +21918 -0
  22. data/bench/golden/v1/metadata.json +12 -0
  23. data/bench/golden/v1/reports/dynamic_evidence.json +146 -0
  24. data/bench/golden/v1/reports/plain_ruby.json +135 -0
  25. data/bench/golden/v1/reports/rails.json +176 -0
  26. data/bench/golden/v1/reports/rubocop_1_75_0.json +26848 -0
  27. data/bench/golden/v1/reports/self.json +3449 -0
  28. data/bench/review_queue.rb +35 -0
  29. data/bench/run.rb +31 -0
  30. data/bench/schema/candidate-union-v1.schema.json +70 -0
  31. data/docs/impv_implementation_matrix.md +179 -0
  32. data/docs/migrations/0.2.1.md +57 -0
  33. data/docs/migrations/0.3.0.md +207 -0
  34. data/docs/migrations/0.4.0.md +13 -0
  35. data/docs/necropsy_performance_adr.md +43 -0
  36. data/docs/necropsy_scope_decisions.md +58 -0
  37. data/docs/necropsy_type_facts_adr.md +22 -0
  38. data/gemfiles/prism_min.gemfile +9 -0
  39. data/gemfiles/prism_min.gemfile.lock +50 -0
  40. data/lib/necropsy/analyzer.rb +121 -2
  41. data/lib/necropsy/analyzers/dynamic/coverage_collector.rb +69 -17
  42. data/lib/necropsy/analyzers/dynamic/coverage_importer.rb +99 -9
  43. data/lib/necropsy/analyzers/dynamic/coverband_importer.rb +69 -299
  44. data/lib/necropsy/analyzers/dynamic/coverband_payload_set.rb +149 -0
  45. data/lib/necropsy/analyzers/dynamic/observation_policy.rb +96 -0
  46. data/lib/necropsy/analyzers/dynamic/redis_input_limits.rb +121 -0
  47. data/lib/necropsy/analyzers/dynamic/redis_nonblocking_io.rb +94 -0
  48. data/lib/necropsy/analyzers/dynamic/redis_payload_loader.rb +165 -0
  49. data/lib/necropsy/analyzers/dynamic/redis_transport.rb +217 -0
  50. data/lib/necropsy/analyzers/dynamic/runtime_reference.rb +96 -0
  51. data/lib/necropsy/analyzers/dynamic/trace_point_collector.rb +133 -23
  52. data/lib/necropsy/analyzers/dynamic/trace_point_importer.rb +3 -1
  53. data/lib/necropsy/analyzers/legacy_result_adapter.rb +226 -0
  54. data/lib/necropsy/analyzers/static/cha.rb +33 -73
  55. data/lib/necropsy/analyzers/static/name_resolution.rb +133 -12
  56. data/lib/necropsy/analyzers/static/rta.rb +237 -29
  57. data/lib/necropsy/ast_scanner/call_recording.rb +149 -30
  58. data/lib/necropsy/ast_scanner/call_site_creation.rb +54 -0
  59. data/lib/necropsy/ast_scanner/definition_creation.rb +43 -0
  60. data/lib/necropsy/ast_scanner/dsl_macros.rb +431 -41
  61. data/lib/necropsy/ast_scanner/method_definitions.rb +232 -50
  62. data/lib/necropsy/ast_scanner/references.rb +43 -9
  63. data/lib/necropsy/ast_scanner/ruby_semantics.rb +101 -15
  64. data/lib/necropsy/ast_scanner/traversal.rb +234 -71
  65. data/lib/necropsy/ast_scanner/value_definitions.rb +23 -13
  66. data/lib/necropsy/ast_scanner.rb +67 -6
  67. data/lib/necropsy/bench/candidate_union.rb +555 -0
  68. data/lib/necropsy/bench/claim_gate.rb +112 -0
  69. data/lib/necropsy/bench/evaluator.rb +329 -16
  70. data/lib/necropsy/bench/finding_facts.rb +152 -0
  71. data/lib/necropsy/bench/precision_gate.rb +144 -0
  72. data/lib/necropsy/bench/release_audit/adversarial_runner.rb +56 -0
  73. data/lib/necropsy/bench/release_audit/artifact_writer.rb +112 -0
  74. data/lib/necropsy/bench/release_audit/config_validator.rb +112 -0
  75. data/lib/necropsy/bench/release_audit/git_snapshot.rb +36 -0
  76. data/lib/necropsy/bench/release_audit/performance_gate.rb +165 -0
  77. data/lib/necropsy/bench/release_audit/run_provenance.rb +133 -0
  78. data/lib/necropsy/bench/release_audit.rb +360 -0
  79. data/lib/necropsy/bench/report_normalizer.rb +140 -0
  80. data/lib/necropsy/bench/review_queue.rb +154 -0
  81. data/lib/necropsy/bench/safety_mutation_harness.rb +59 -0
  82. data/lib/necropsy/bench/seed_runner.rb +408 -0
  83. data/lib/necropsy/bounded_canonicalizer.rb +218 -0
  84. data/lib/necropsy/cache/scan_cache.rb +85 -17
  85. data/lib/necropsy/call_site_identity.rb +54 -0
  86. data/lib/necropsy/cli.rb +220 -33
  87. data/lib/necropsy/clock.rb +40 -0
  88. data/lib/necropsy/confidence/scorer.rb +103 -58
  89. data/lib/necropsy/configuration.rb +224 -21
  90. data/lib/necropsy/convention_rules.rb +138 -0
  91. data/lib/necropsy/definition_identity/canonical_digest.rb +278 -0
  92. data/lib/necropsy/definition_identity.rb +37 -0
  93. data/lib/necropsy/diagnostics.rb +176 -36
  94. data/lib/necropsy/embedded_ruby.rb +55 -0
  95. data/lib/necropsy/entry_points/plain.rb +111 -10
  96. data/lib/necropsy/entry_points/rails.rb +322 -41
  97. data/lib/necropsy/entry_points/test.rb +6 -1
  98. data/lib/necropsy/flow_interpreter.rb +460 -0
  99. data/lib/necropsy/graph/blocker_matching.rb +338 -0
  100. data/lib/necropsy/graph/call_graph.rb +1099 -109
  101. data/lib/necropsy/graph/definition_index.rb +149 -0
  102. data/lib/necropsy/graph/dynamic_evidence_tracking.rb +206 -0
  103. data/lib/necropsy/graph/evidence_store.rb +213 -0
  104. data/lib/necropsy/graph/resolution_store.rb +497 -0
  105. data/lib/necropsy/graph_self_check.rb +79 -0
  106. data/lib/necropsy/guardrail/baseline.rb +350 -13
  107. data/lib/necropsy/guardrail/quarantine.rb +94 -9
  108. data/lib/necropsy/load_graph.rb +206 -0
  109. data/lib/necropsy/models.rb +878 -13
  110. data/lib/necropsy/performance_profiler.rb +108 -0
  111. data/lib/necropsy/project.rb +327 -25
  112. data/lib/necropsy/reachability/engine.rb +54 -13
  113. data/lib/necropsy/reference_barrier.rb +458 -0
  114. data/lib/necropsy/report.rb +113 -4
  115. data/lib/necropsy/reporter.rb +431 -15
  116. data/lib/necropsy/runner.rb +233 -18
  117. data/lib/necropsy/runtime_feedback.rb +136 -0
  118. data/lib/necropsy/semantics_matrix.rb +153 -0
  119. data/lib/necropsy/type_facts.rb +53 -0
  120. data/lib/necropsy/version.rb +1 -1
  121. data/lib/necropsy/why_not_explanation.rb +436 -0
  122. data/lib/necropsy/why_not_renderer.rb +197 -0
  123. data/lib/necropsy/world_policy.rb +90 -0
  124. data/lib/necropsy.rb +35 -2
  125. data/schema/necropsy-report-v2.schema.json +366 -0
  126. 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec path: '..'
6
+
7
+ gem 'prism', '~> 1.0.0'
8
+ gem 'rake', '~> 13.0'
9
+ gem 'rspec', '~> 3.0'
@@ -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
@@ -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: profile.name, weight: 1.0, metadata: {})
16
- Evidence.new(analyzer: analyzer, kind: kind, weight: weight, details: details, metadata: metadata)
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 = Time.now.utc
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: Time.now.utc)
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 = Time.now.utc
41
+ started_at = current_time
38
42
  started = start_coverage
39
43
 
40
44
  at_exit do
41
- finished_at = Time.now.utc
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
- 'nodes' => executed_nodes(result).sort,
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.fetch('observation', {}).empty?
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 = left.merge(right)
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 executed_nodes(result)
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
- node_id_for(method_key)
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
- alive = Array(payload['executed'] || payload['nodes']).map do |node_id|
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(kind: :alive, details: "Coverage marked #{node_id} as executed",
23
- metadata: payload['observation'] || {})
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 = Array(payload['edges']).map do |edge|
28
- caller_id = edge['caller_id'] || edge[:caller_id]
29
- callee_id = edge['callee_id'] || edge[:callee_id]
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(kind: :call_edge, details: "Coverage observed #{caller_id} -> #{callee_id}")
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' => payload['observation'] || {} }
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