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,436 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
class WhyNotExplanation
|
|
5
|
+
SCHEMA_VERSION = 'necropsy.why-not.v1'
|
|
6
|
+
ITEM_LIMIT = 100
|
|
7
|
+
RESOLUTIONS_PER_SITE_LIMIT = 20
|
|
8
|
+
NESTED_DEPTH_LIMIT = 12
|
|
9
|
+
STRING_BYTES_LIMIT = 4_096
|
|
10
|
+
BOUNDS_KEY = '_bounds'
|
|
11
|
+
RESOLUTION_STATUSES = %w[complete partial unknown unrecorded].freeze
|
|
12
|
+
RESOLUTION_BLOCKERS = %i[unknown_dispatch partial_dispatch resolution_conflict resolution_invalid].freeze
|
|
13
|
+
|
|
14
|
+
def initialize(report)
|
|
15
|
+
@report = report
|
|
16
|
+
@graph = report.graph
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(node)
|
|
20
|
+
finding = report.finding_for_definition(node.graph_id)
|
|
21
|
+
blockers = canonical_sort(finding ? finding.blockers : graph.matching_blockers(node))
|
|
22
|
+
sites = incoming_call_sites(node)
|
|
23
|
+
site_records = sites.to_h { |site| [site.call_site_id, graph.resolution_records(site.call_site_id)] }
|
|
24
|
+
rejections = target_rejections(node, site_records)
|
|
25
|
+
definitions = graph.definitions_for(node.symbol_id)
|
|
26
|
+
state = state(node, finding)
|
|
27
|
+
risks = risk_flags(node, definitions, blockers)
|
|
28
|
+
assumption_values = assumptions(site_records.values.flatten)
|
|
29
|
+
suggestions = suggested_next_evidence(finding, blockers, sites)
|
|
30
|
+
|
|
31
|
+
payload = {
|
|
32
|
+
'schema_version' => SCHEMA_VERSION,
|
|
33
|
+
'status' => 'why_not',
|
|
34
|
+
'state' => state,
|
|
35
|
+
'action' => action(state, risks),
|
|
36
|
+
'risk_flags' => risks,
|
|
37
|
+
'classification' => finding&.classification&.to_s,
|
|
38
|
+
'confidence' => finding&.confidence&.to_s,
|
|
39
|
+
'artifact_context' => artifact_context(node),
|
|
40
|
+
'physical_definition' => node.to_h,
|
|
41
|
+
'same_name_definitions' => definitions.first(ITEM_LIMIT).map(&:to_h),
|
|
42
|
+
'same_name_definition_summary' => bounded_summary(definitions.length, ITEM_LIMIT),
|
|
43
|
+
'reachability_or_absence' => reachability_or_absence(node, sites, blockers),
|
|
44
|
+
'incoming_call_sites_examined' => sites.first(ITEM_LIMIT).map do |site|
|
|
45
|
+
call_site_payload(site, node, site_records.fetch(site.call_site_id))
|
|
46
|
+
end,
|
|
47
|
+
'incoming_call_site_summary' => bounded_summary(sites.length, ITEM_LIMIT),
|
|
48
|
+
'resolution_status' => resolution_status(site_records),
|
|
49
|
+
'target_rejection_reasons' => rejections.first(ITEM_LIMIT),
|
|
50
|
+
'target_rejection_summary' => bounded_summary(rejections.length, ITEM_LIMIT),
|
|
51
|
+
'blockers' => blockers.first(ITEM_LIMIT).map(&:to_h),
|
|
52
|
+
'blocker_summary' => bounded_summary(blockers.length, ITEM_LIMIT),
|
|
53
|
+
'unknown_or_partial_blockers' => bounded_blockers(
|
|
54
|
+
blockers.select { |blocker| resolution_blocker?(blocker) }
|
|
55
|
+
),
|
|
56
|
+
'unknown_or_partial_blocker_summary' => bounded_summary(
|
|
57
|
+
blockers.count { |blocker| resolution_blocker?(blocker) }, ITEM_LIMIT
|
|
58
|
+
),
|
|
59
|
+
'world_and_root_policy' => world_and_root_policy(node),
|
|
60
|
+
'non_ruby_matches' => bounded_blockers(
|
|
61
|
+
blockers.select { |blocker| blocker.kind == :unparsed_external_reference }
|
|
62
|
+
),
|
|
63
|
+
'non_ruby_match_summary' => bounded_summary(
|
|
64
|
+
blockers.count { |blocker| blocker.kind == :unparsed_external_reference }, ITEM_LIMIT
|
|
65
|
+
),
|
|
66
|
+
'parse_and_analyzer_failures' => failure_payload(node, blockers),
|
|
67
|
+
'enabled_rules_and_types' => enabled_rules_and_types,
|
|
68
|
+
'assumptions' => bounded_values(assumption_values),
|
|
69
|
+
'assumption_summary' => bounded_summary(assumption_values.length, ITEM_LIMIT),
|
|
70
|
+
'suggested_next_evidence' => suggestions.first(ITEM_LIMIT),
|
|
71
|
+
'suggested_next_evidence_summary' => bounded_summary(suggestions.length, ITEM_LIMIT),
|
|
72
|
+
'limits' => {
|
|
73
|
+
'items_per_collection' => ITEM_LIMIT,
|
|
74
|
+
'resolutions_per_call_site' => RESOLUTIONS_PER_SITE_LIMIT
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
bounded_payload(payload)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
attr_reader :report, :graph
|
|
83
|
+
|
|
84
|
+
def state(node, finding)
|
|
85
|
+
return 'blocked' if finding&.classification == :blocked
|
|
86
|
+
return 'test_only' if finding&.classification == :test_only_reachable
|
|
87
|
+
return 'candidate' if finding
|
|
88
|
+
return 'runtime_reachable' if report.reachability.runtime_paths.key?(node.graph_id)
|
|
89
|
+
return 'external_reachable' if report.reachability.external_paths.key?(node.graph_id)
|
|
90
|
+
return 'test_only' if report.reachability.test_paths.key?(node.graph_id)
|
|
91
|
+
|
|
92
|
+
'not_classified'
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def action(state, risks)
|
|
96
|
+
case state
|
|
97
|
+
when 'runtime_reachable', 'external_reachable' then 'keep'
|
|
98
|
+
when 'test_only' then 'observe'
|
|
99
|
+
when 'blocked' then 'review'
|
|
100
|
+
when 'candidate' then risks.empty? ? 'verify' : 'review'
|
|
101
|
+
else 'verify'
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def risk_flags(node, definitions, blockers)
|
|
106
|
+
flags = []
|
|
107
|
+
flags << 'public_or_protected_visibility' if %i[public protected].include?(node.visibility)
|
|
108
|
+
flags << 'generated_method' unless %i[def defs define_method].include?(node.defined_via)
|
|
109
|
+
flags << 'duplicate_or_redefinition' if definitions.length > 1
|
|
110
|
+
flags << 'no_owner' if node.owner.nil? || node.owner.empty?
|
|
111
|
+
flags << 'test_definition' if node.test
|
|
112
|
+
flags << 'dynamic_owner' if graph.class_info(node.owner)&.dynamic
|
|
113
|
+
flags << 'analysis_incomplete' if blockers.any?
|
|
114
|
+
flags.sort
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def incoming_call_sites(node)
|
|
118
|
+
sites = graph.call_sites_for_message(node.name) + graph.call_sites_resolving_definition(node.graph_id)
|
|
119
|
+
sites.uniq.sort_by { |site| [site.file.to_s, site.line.to_i, site.call_site_id.to_s] }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def call_site_payload(site, node, records)
|
|
123
|
+
displayed = records.first(RESOLUTIONS_PER_SITE_LIMIT)
|
|
124
|
+
{
|
|
125
|
+
'call_site' => bounded_call_site(site),
|
|
126
|
+
'resolution_statuses' => records.empty? ? ['unrecorded'] : records.map { |record| record.resolution.status.to_s }.uniq.sort,
|
|
127
|
+
'resolutions' => displayed.map { |record| examined_resolution(record, node) },
|
|
128
|
+
'resolution_summary' => bounded_summary(records.length, RESOLUTIONS_PER_SITE_LIMIT)
|
|
129
|
+
}
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def examined_resolution(record, node)
|
|
133
|
+
resolution = record.resolution
|
|
134
|
+
rejected = canonical_sort(resolution.rejected_targets)
|
|
135
|
+
{
|
|
136
|
+
'producer' => record.producer,
|
|
137
|
+
'producer_version' => record.producer_version,
|
|
138
|
+
'status' => resolution.status.to_s,
|
|
139
|
+
'matches_physical_definition' => resolution.target_definition_ids.include?(node.graph_id),
|
|
140
|
+
'target_definition_ids' => resolution.target_definition_ids.first(ITEM_LIMIT),
|
|
141
|
+
'target_definition_summary' => bounded_summary(resolution.target_definition_ids.length, ITEM_LIMIT),
|
|
142
|
+
'unknown_scope' => resolution.unknown_scope&.to_h,
|
|
143
|
+
'rejected_targets' => rejected.first(ITEM_LIMIT).map(&:to_h),
|
|
144
|
+
'rejected_target_summary' => bounded_summary(rejected.length, ITEM_LIMIT),
|
|
145
|
+
'evidence_ids' => resolution.evidence_ids.first(ITEM_LIMIT),
|
|
146
|
+
'evidence_summary' => bounded_summary(resolution.evidence_ids.length, ITEM_LIMIT),
|
|
147
|
+
'assumptions' => record.assumptions.first(ITEM_LIMIT),
|
|
148
|
+
'assumption_summary' => bounded_summary(record.assumptions.length, ITEM_LIMIT)
|
|
149
|
+
}
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def bounded_call_site(site)
|
|
153
|
+
payload = site.to_h.except('metadata')
|
|
154
|
+
metadata = site.metadata
|
|
155
|
+
receiver_candidates = Array(metadata['receiver_candidates'] || metadata[:receiver_candidates])
|
|
156
|
+
payload['metadata'] = {
|
|
157
|
+
'original_message' => metadata['original_message'] || metadata[:original_message],
|
|
158
|
+
'implicit_from' => metadata['implicit_from'] || metadata[:implicit_from],
|
|
159
|
+
'receiver_candidates' => receiver_candidates.first(ITEM_LIMIT),
|
|
160
|
+
'receiver_candidate_summary' => bounded_summary(receiver_candidates.length, ITEM_LIMIT)
|
|
161
|
+
}.compact
|
|
162
|
+
payload
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def resolution_status(resolutions)
|
|
166
|
+
counts = RESOLUTION_STATUSES.to_h { |status| [status, 0] }
|
|
167
|
+
resolutions.each_value do |records|
|
|
168
|
+
if records.empty?
|
|
169
|
+
counts['unrecorded'] += 1
|
|
170
|
+
else
|
|
171
|
+
records.each { |record| counts[record.resolution.status.to_s] += 1 }
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
counts
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def target_rejections(node, resolutions)
|
|
178
|
+
values = resolutions.flat_map do |call_site_id, records|
|
|
179
|
+
records.flat_map do |record|
|
|
180
|
+
record.resolution.rejected_targets.filter_map do |target|
|
|
181
|
+
next unless target.definition_id == node.graph_id
|
|
182
|
+
|
|
183
|
+
target.to_h.merge('call_site_id' => call_site_id, 'producer' => record.producer)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
canonical_sort(values)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def resolution_blocker?(blocker)
|
|
191
|
+
RESOLUTION_BLOCKERS.include?(blocker.kind.to_sym)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def world_and_root_policy(node)
|
|
195
|
+
policy = graph.observation.fetch('world_policy', { 'world' => 'unknown', 'load_roots' => 'unknown' })
|
|
196
|
+
roots = canonical_sort(graph.entry_points.select { |root| root.definition_id == node.graph_id })
|
|
197
|
+
root_rules = graph.entry_points.map { |root| root.reason.to_s }.uniq.sort
|
|
198
|
+
{
|
|
199
|
+
'world' => policy.fetch('world'),
|
|
200
|
+
'load_roots' => policy.fetch('load_roots'),
|
|
201
|
+
'matching_roots' => roots.first(ITEM_LIMIT).map(&:to_h),
|
|
202
|
+
'matching_root_summary' => bounded_summary(roots.length, ITEM_LIMIT),
|
|
203
|
+
'enabled_root_rules' => root_rules.first(ITEM_LIMIT),
|
|
204
|
+
'enabled_root_rule_summary' => bounded_summary(root_rules.length, ITEM_LIMIT),
|
|
205
|
+
'reachable_domains' => reachable_domains(node)
|
|
206
|
+
}
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def reachable_domains(node)
|
|
210
|
+
id = node.graph_id
|
|
211
|
+
{ 'runtime' => report.reachability.runtime_paths, 'external' => report.reachability.external_paths,
|
|
212
|
+
'test' => report.reachability.test_paths }.filter_map { |domain, paths| domain if paths.key?(id) }
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def reachability_or_absence(node, sites, blockers)
|
|
216
|
+
domain = %i[runtime external test].find { |candidate| report.reachability.witness(node.graph_id, kind: candidate) }
|
|
217
|
+
unless domain
|
|
218
|
+
return {
|
|
219
|
+
'kind' => 'absence',
|
|
220
|
+
'runtime_witness' => false,
|
|
221
|
+
'external_witness' => false,
|
|
222
|
+
'examined_incoming_call_sites' => sites.length,
|
|
223
|
+
'matching_blockers' => blockers.length
|
|
224
|
+
}
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
path = report.reachability.witness(node.graph_id, kind: domain)
|
|
228
|
+
root_id = path.first
|
|
229
|
+
roots = canonical_sort(graph.entry_points.select do |root|
|
|
230
|
+
root.definition_id == root_id && root.domain == domain
|
|
231
|
+
end)
|
|
232
|
+
{
|
|
233
|
+
'kind' => 'witness',
|
|
234
|
+
'domain' => domain.to_s,
|
|
235
|
+
'definition_ids' => path.first(ITEM_LIMIT),
|
|
236
|
+
'path_summary' => bounded_summary(path.length, ITEM_LIMIT),
|
|
237
|
+
'root' => roots.first&.to_h,
|
|
238
|
+
'path_length' => path.length
|
|
239
|
+
}
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def failure_payload(node, blockers)
|
|
243
|
+
parse_blockers = blockers.select { |blocker| blocker.kind == :parse_incomplete }
|
|
244
|
+
affected_files = parse_blockers.filter_map { |blocker| blocker.metadata['file'] }.uniq
|
|
245
|
+
affected_files << node.file if graph.file_statuses.fetch(node.file, :complete) != :complete
|
|
246
|
+
parse_failures = canonical_sort(graph.source_errors.select { |error| affected_files.include?(error.file) })
|
|
247
|
+
analyzer_failures = blockers.select { |blocker| blocker.kind == :analyzer_failure }
|
|
248
|
+
{
|
|
249
|
+
'parse_failures' => parse_failures.first(ITEM_LIMIT).map(&:to_h),
|
|
250
|
+
'parse_failure_summary' => bounded_summary(parse_failures.length, ITEM_LIMIT),
|
|
251
|
+
'parse_blockers' => parse_blockers.first(ITEM_LIMIT).map(&:to_h),
|
|
252
|
+
'parse_blocker_summary' => bounded_summary(parse_blockers.length, ITEM_LIMIT),
|
|
253
|
+
'analyzer_failures' => analyzer_failures.first(ITEM_LIMIT).map(&:to_h),
|
|
254
|
+
'analyzer_failure_summary' => bounded_summary(analyzer_failures.length, ITEM_LIMIT)
|
|
255
|
+
}
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def artifact_context(node)
|
|
259
|
+
policy = graph.observation.fetch('world_policy', {})
|
|
260
|
+
source_snapshot = report.source_snapshot || {
|
|
261
|
+
'status' => 'unavailable', 'sha256' => 'unavailable', 'reason' => 'project_context_unavailable'
|
|
262
|
+
}
|
|
263
|
+
{
|
|
264
|
+
'tool_version' => Necropsy::VERSION,
|
|
265
|
+
'source_digest' => source_snapshot.fetch('sha256'),
|
|
266
|
+
'source_snapshot' => source_snapshot,
|
|
267
|
+
'definition_body_digest' => node.body_digest || 'unavailable',
|
|
268
|
+
'configuration_sha256' => policy.fetch('configuration_sha256', 'unavailable')
|
|
269
|
+
}
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def enabled_rules_and_types
|
|
273
|
+
profiles = canonical_sort(graph.profiles)
|
|
274
|
+
types = profiles.select { |profile| profile.kind.to_s == 'type' }
|
|
275
|
+
{
|
|
276
|
+
'analyzers' => profiles.first(ITEM_LIMIT).map(&:to_h),
|
|
277
|
+
'analyzer_summary' => bounded_summary(profiles.length, ITEM_LIMIT),
|
|
278
|
+
'resolution_producers' => resolution_producers.first(ITEM_LIMIT),
|
|
279
|
+
'resolution_producer_summary' => bounded_summary(resolution_producers.length, ITEM_LIMIT),
|
|
280
|
+
'root_rules' => root_rules.first(ITEM_LIMIT),
|
|
281
|
+
'root_rule_summary' => bounded_summary(root_rules.length, ITEM_LIMIT),
|
|
282
|
+
'type_providers' => types.first(ITEM_LIMIT).map(&:to_h),
|
|
283
|
+
'type_provider_summary' => bounded_summary(types.length, ITEM_LIMIT)
|
|
284
|
+
}
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def resolution_producers
|
|
288
|
+
graph.resolution_records.map(&:producer).uniq.sort
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def root_rules
|
|
292
|
+
graph.entry_points.map { |root| root.reason.to_s }.uniq.sort
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def assumptions(records)
|
|
296
|
+
(graph.profiles.flat_map(&:assumptions) + records.flat_map(&:assumptions)).uniq.sort
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def suggested_next_evidence(finding, blockers, sites)
|
|
300
|
+
suggestions = blockers.map { |blocker| blocker_suggestion(blocker) }
|
|
301
|
+
suggestions << state_suggestion(finding, sites) if suggestions.empty?
|
|
302
|
+
canonical_sort(suggestions.compact.uniq { |suggestion| BoundedCanonicalizer.dump(suggestion) })
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def blocker_suggestion(blocker)
|
|
306
|
+
details = blocker.metadata
|
|
307
|
+
location = [details['file'], details['line']].compact.join(':')
|
|
308
|
+
{
|
|
309
|
+
'kind' => blocker.suggested_action.to_s,
|
|
310
|
+
'details' => [blocker.reason, location.empty? ? nil : "Inspect #{location}"].compact.join('. ')
|
|
311
|
+
}
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def state_suggestion(finding, sites)
|
|
315
|
+
if finding&.classification == :test_only_reachable
|
|
316
|
+
{ 'kind' => 'observe_runtime', 'details' => 'Exercise the definition through a representative runtime entry point.' }
|
|
317
|
+
elsif sites.empty?
|
|
318
|
+
{ 'kind' => 'find_callers', 'details' => 'Add an explicit root/caller or representative runtime observation.' }
|
|
319
|
+
else
|
|
320
|
+
{ 'kind' => 'observe_call_sites', 'details' => 'Capture runtime receiver and target identities for the examined call sites.' }
|
|
321
|
+
end
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def bounded_blockers(values)
|
|
325
|
+
values.first(ITEM_LIMIT).map(&:to_h)
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def bounded_values(values)
|
|
329
|
+
values.first(ITEM_LIMIT)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def bounded_summary(total, limit)
|
|
333
|
+
returned = [total, limit].min
|
|
334
|
+
{ 'total' => total, 'returned' => returned, 'truncated' => total - returned }
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def canonical_sort(values)
|
|
338
|
+
values.sort_by do |value|
|
|
339
|
+
payload = value.respond_to?(:to_h) ? value.to_h : value
|
|
340
|
+
BoundedCanonicalizer.dump(bounded_payload(payload))
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def bounded_payload(value, depth = 0)
|
|
345
|
+
return { '_truncated' => 'depth_limit' } if depth >= NESTED_DEPTH_LIMIT
|
|
346
|
+
|
|
347
|
+
value = value.to_h if !value.is_a?(Hash) && !value.is_a?(Array) && value.respond_to?(:to_h)
|
|
348
|
+
case value
|
|
349
|
+
when Hash then bounded_hash(value, depth)
|
|
350
|
+
when Array then value.first(ITEM_LIMIT).map { |entry| bounded_payload(entry, depth + 1) }
|
|
351
|
+
when String then bounded_string(value)
|
|
352
|
+
when Numeric, TrueClass, FalseClass, NilClass then value
|
|
353
|
+
else bounded_string(value.to_s)
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def bounded_hash(value, depth)
|
|
358
|
+
entries = resolved_hash_entries(value)
|
|
359
|
+
value_bounds = {}
|
|
360
|
+
entries.first(ITEM_LIMIT).each_with_object({}) do |(key, entry), result|
|
|
361
|
+
result[key] = bounded_payload(entry, depth + 1)
|
|
362
|
+
value_bounds[key] = { 'collection' => bounded_summary(entry.length, ITEM_LIMIT) } if entry.is_a?(Array)
|
|
363
|
+
if entry.is_a?(String) && entry.bytesize > STRING_BYTES_LIMIT
|
|
364
|
+
value_bounds[key] ||= {}
|
|
365
|
+
value_bounds[key]['bytes'] = { 'total' => entry.bytesize, 'returned' => STRING_BYTES_LIMIT }
|
|
366
|
+
end
|
|
367
|
+
end.tap do |result|
|
|
368
|
+
bounds = {}
|
|
369
|
+
bounds['fields'] = bounded_summary(entries.length, ITEM_LIMIT) if entries.length > ITEM_LIMIT
|
|
370
|
+
bounds['values'] = value_bounds unless value_bounds.empty?
|
|
371
|
+
result[BOUNDS_KEY] = bounds unless bounds.empty?
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def resolved_hash_entries(value)
|
|
376
|
+
entries = value.map do |key, entry|
|
|
377
|
+
identity = hash_key_identity(key)
|
|
378
|
+
[bounded_key(key), identity, BoundedCanonicalizer.dump(bounded_payload(entry)), entry]
|
|
379
|
+
end
|
|
380
|
+
counts = entries.each_with_object(Hash.new(0)) { |(key, _identity, _value, _entry), result| result[key] += 1 }
|
|
381
|
+
occupied = entries.to_h { |key, _identity, _value, _entry| [key, true] }
|
|
382
|
+
occupied[BOUNDS_KEY] = true
|
|
383
|
+
|
|
384
|
+
entries.sort_by { |key, identity, entry_identity, _entry| [key, identity, entry_identity] }.map do |key, identity, _entry_identity, entry|
|
|
385
|
+
resolved = if counts[key] == 1 && key != BOUNDS_KEY
|
|
386
|
+
key
|
|
387
|
+
else
|
|
388
|
+
collision_safe_key(key, identity, occupied)
|
|
389
|
+
end
|
|
390
|
+
occupied[resolved] = true
|
|
391
|
+
[resolved, entry]
|
|
392
|
+
end.sort_by(&:first)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def hash_key_identity(key)
|
|
396
|
+
type = key.class.name || key.class.to_s
|
|
397
|
+
value = case key
|
|
398
|
+
when String then key.b
|
|
399
|
+
when Symbol then key.to_s.b
|
|
400
|
+
else BoundedCanonicalizer.dump(bounded_payload(key)).b
|
|
401
|
+
end
|
|
402
|
+
"#{type}\0".b + value
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
def collision_safe_key(key, identity, occupied)
|
|
406
|
+
digest = Digest::SHA256.hexdigest(identity)
|
|
407
|
+
attempt = 0
|
|
408
|
+
loop do
|
|
409
|
+
suffix = attempt.zero? ? "[#{digest}]" : "[#{digest}:#{attempt}]"
|
|
410
|
+
candidate = bounded_key("#{key}#{suffix}")
|
|
411
|
+
return candidate unless occupied.key?(candidate)
|
|
412
|
+
|
|
413
|
+
attempt += 1
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def bounded_string(value)
|
|
418
|
+
normalized = value.encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
|
|
419
|
+
return normalized if normalized.bytesize <= STRING_BYTES_LIMIT
|
|
420
|
+
|
|
421
|
+
suffix = "\u2026:#{Digest::SHA256.hexdigest(value.b)}"
|
|
422
|
+
"#{utf8_prefix(normalized, STRING_BYTES_LIMIT - suffix.bytesize)}#{suffix}"
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def bounded_key(value)
|
|
426
|
+
bounded_string(value.to_s)
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def utf8_prefix(value, byte_limit)
|
|
430
|
+
prefix = value.byteslice(0, byte_limit)
|
|
431
|
+
prefix = prefix.byteslice(0, prefix.bytesize - 1) until prefix.valid_encoding?
|
|
432
|
+
|
|
433
|
+
prefix
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
class WhyNotRenderer
|
|
5
|
+
def initialize(payload)
|
|
6
|
+
@payload = payload
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def render
|
|
10
|
+
lines = heading
|
|
11
|
+
append_definitions(lines)
|
|
12
|
+
append_policy(lines)
|
|
13
|
+
append_call_sites(lines)
|
|
14
|
+
append_blockers(lines)
|
|
15
|
+
append_special_diagnostics(lines)
|
|
16
|
+
append_rules(lines)
|
|
17
|
+
append_suggestions(lines)
|
|
18
|
+
lines.join("\n")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
attr_reader :payload
|
|
24
|
+
|
|
25
|
+
def heading
|
|
26
|
+
node = payload.fetch('physical_definition')
|
|
27
|
+
[
|
|
28
|
+
"Why-not (#{payload.fetch('state')}): #{node_reference(node)}",
|
|
29
|
+
"Physical definition: #{node['file']}:#{node['line']} #{node['kind']} visibility=#{node['visibility']}",
|
|
30
|
+
"Classification: #{payload['classification'] || 'none'} confidence=#{payload['confidence'] || 'none'}",
|
|
31
|
+
"Action: #{payload.fetch('action')}; risks=#{list_or_none(payload.fetch('risk_flags'))}",
|
|
32
|
+
"Schema: #{payload.fetch('schema_version')}",
|
|
33
|
+
artifact_context
|
|
34
|
+
]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def artifact_context
|
|
38
|
+
artifact = payload.fetch('artifact_context')
|
|
39
|
+
"Artifact context: tool=#{artifact['tool_version']} source=#{artifact['source_digest']} " \
|
|
40
|
+
"config=#{artifact['configuration_sha256']}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def append_definitions(lines)
|
|
44
|
+
definitions = payload.fetch('same_name_definitions')
|
|
45
|
+
summary = payload.fetch('same_name_definition_summary')
|
|
46
|
+
lines << "Same-name physical definitions: #{summary.fetch('total')} (shown #{summary.fetch('returned')})"
|
|
47
|
+
definitions.each do |definition|
|
|
48
|
+
lines << " #{node_reference(definition)} at #{definition['file']}:#{definition['line']}"
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def append_policy(lines)
|
|
53
|
+
policy = payload.fetch('world_and_root_policy')
|
|
54
|
+
domains = policy.fetch('reachable_domains')
|
|
55
|
+
lines << "World/root policy: world=#{policy['world']} load_roots=#{policy['load_roots']} " \
|
|
56
|
+
"reachable=#{domains.empty? ? 'none' : domains.join(',')}"
|
|
57
|
+
roots = policy.fetch('matching_roots')
|
|
58
|
+
root_summary = policy.fetch('matching_root_summary')
|
|
59
|
+
lines << "Matching roots: #{roots.empty? ? 'none' : roots.map { |root| root['reason'] }.join(', ')} " \
|
|
60
|
+
"(#{root_summary.fetch('returned')}/#{root_summary.fetch('total')})"
|
|
61
|
+
lines << "Enabled root rules: #{list_or_none(policy.fetch('enabled_root_rules'))}"
|
|
62
|
+
rule_summary = policy.fetch('enabled_root_rule_summary')
|
|
63
|
+
lines << " ... #{rule_summary.fetch('truncated')} root rules omitted" if rule_summary.fetch('truncated').positive?
|
|
64
|
+
explanation = payload.fetch('reachability_or_absence')
|
|
65
|
+
line = if explanation.fetch('kind') == 'witness'
|
|
66
|
+
"Witness: #{explanation.fetch('domain')} #{explanation.fetch('definition_ids').join(' -> ')}"
|
|
67
|
+
else
|
|
68
|
+
"Absence: no runtime/external witness; examined=#{explanation.fetch('examined_incoming_call_sites')} " \
|
|
69
|
+
"blockers=#{explanation.fetch('matching_blockers')}"
|
|
70
|
+
end
|
|
71
|
+
lines << line
|
|
72
|
+
path_summary = explanation['path_summary']
|
|
73
|
+
lines << " ... #{path_summary.fetch('truncated')} witness steps omitted" if path_summary&.fetch('truncated')&.positive?
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def append_call_sites(lines)
|
|
77
|
+
sites = payload.fetch('incoming_call_sites_examined')
|
|
78
|
+
summary = payload.fetch('incoming_call_site_summary')
|
|
79
|
+
lines << "Incoming call sites examined: #{summary.fetch('total')} (shown #{summary.fetch('returned')})"
|
|
80
|
+
sites.each do |examined|
|
|
81
|
+
site = examined.fetch('call_site')
|
|
82
|
+
lines << " #{site['file']}:#{site['line']} caller=#{site['caller_id']} " \
|
|
83
|
+
"receiver=#{site['receiver_kind']}:#{site['receiver_name']} message=#{site['message']}"
|
|
84
|
+
lines << " resolution=#{examined.fetch('resolution_statuses').join(',')}"
|
|
85
|
+
examined.fetch('resolutions').each { |resolution| append_resolution(lines, resolution) }
|
|
86
|
+
resolution_summary = examined.fetch('resolution_summary')
|
|
87
|
+
lines << " ... #{resolution_summary.fetch('truncated')} resolutions omitted" if resolution_summary.fetch('truncated').positive?
|
|
88
|
+
end
|
|
89
|
+
counts = payload.fetch('resolution_status')
|
|
90
|
+
lines << "Resolution status: #{counts.map { |status, count| "#{status}=#{count}" }.join(', ')}"
|
|
91
|
+
lines << " ... #{summary.fetch('truncated')} call sites omitted" if summary.fetch('truncated').positive?
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def append_resolution(lines, resolution)
|
|
95
|
+
match = resolution['matches_physical_definition'] ? 'matched' : 'not-matched'
|
|
96
|
+
lines << " #{resolution['producer']} status=#{resolution['status']} target=#{match}"
|
|
97
|
+
Array(resolution['rejected_targets']).each do |target|
|
|
98
|
+
lines << " rejected #{target['definition_id']}: #{target['reason']}"
|
|
99
|
+
end
|
|
100
|
+
rejection_summary = resolution.fetch('rejected_target_summary')
|
|
101
|
+
lines << " ... #{rejection_summary.fetch('truncated')} rejected targets omitted" if rejection_summary.fetch('truncated').positive?
|
|
102
|
+
scope = resolution['unknown_scope']
|
|
103
|
+
lines << " residual #{scope['scope_kind']}=#{scope['scope_value'].inspect}" if scope
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def append_blockers(lines)
|
|
107
|
+
blockers = payload.fetch('blockers')
|
|
108
|
+
unknown_summary = payload.fetch('unknown_or_partial_blocker_summary')
|
|
109
|
+
blocker_summary = payload.fetch('blocker_summary')
|
|
110
|
+
lines << "Unknown/partial blockers: #{unknown_summary.fetch('total')}"
|
|
111
|
+
lines << "Matching blockers: #{blocker_summary.fetch('total')} (shown #{blocker_summary.fetch('returned')})"
|
|
112
|
+
blockers.each do |blocker|
|
|
113
|
+
metadata = blocker.fetch('metadata', {})
|
|
114
|
+
location = [metadata['file'], metadata['line']].compact.join(':')
|
|
115
|
+
location = " at #{location}" unless location.empty?
|
|
116
|
+
lines << " blocker #{blocker['kind']}#{location}: #{blocker['reason']}"
|
|
117
|
+
end
|
|
118
|
+
unknown_ids = unknown_blocker_keys(blockers)
|
|
119
|
+
payload.fetch('unknown_or_partial_blockers').each do |blocker|
|
|
120
|
+
next if unknown_ids.include?(blocker_key(blocker))
|
|
121
|
+
|
|
122
|
+
lines << " unknown blocker #{blocker['kind']}: #{blocker['reason']}"
|
|
123
|
+
end
|
|
124
|
+
rejections = payload.fetch('target_rejection_reasons')
|
|
125
|
+
rejection_summary = payload.fetch('target_rejection_summary')
|
|
126
|
+
lines << "Target rejection reasons: #{rejections.empty? ? 'none' : rejection_summary.fetch('total')}"
|
|
127
|
+
rejections.each do |rejection|
|
|
128
|
+
lines << " rejected #{rejection['definition_id']} by #{rejection['producer']}: #{rejection['reason']}"
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def append_special_diagnostics(lines)
|
|
133
|
+
matches = payload.fetch('non_ruby_matches')
|
|
134
|
+
match_summary = payload.fetch('non_ruby_match_summary')
|
|
135
|
+
lines << "Non-Ruby matches: #{match_summary.fetch('total')} (shown #{match_summary.fetch('returned')})"
|
|
136
|
+
matches.each do |match|
|
|
137
|
+
metadata = match.fetch('metadata')
|
|
138
|
+
lines << " #{metadata['file']}:#{metadata['line']} [#{metadata['match_kind']}] #{metadata['snippet']}"
|
|
139
|
+
end
|
|
140
|
+
failures = payload.fetch('parse_and_analyzer_failures')
|
|
141
|
+
lines << "Parse failures: #{summary_count(failures, 'parse_failure_summary')}; " \
|
|
142
|
+
"parse blockers=#{summary_count(failures, 'parse_blocker_summary')}; " \
|
|
143
|
+
"analyzer failures=#{summary_count(failures, 'analyzer_failure_summary')}"
|
|
144
|
+
failures.fetch('parse_failures').each do |failure|
|
|
145
|
+
lines << " #{failure['file']}:#{failure['line']} [#{failure['type']}] #{failure['message']}"
|
|
146
|
+
end
|
|
147
|
+
failures.fetch('analyzer_failures').each do |failure|
|
|
148
|
+
lines << " analyzer #{failure['source']}: #{failure['reason']}"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def append_rules(lines)
|
|
153
|
+
rules = payload.fetch('enabled_rules_and_types')
|
|
154
|
+
analyzers = rules.fetch('analyzers').map { |profile| "#{profile['name']}(#{profile['kind']})" }
|
|
155
|
+
types = rules.fetch('type_providers').map { |profile| profile['name'] }
|
|
156
|
+
lines << "Enabled analyzers: #{list_or_none(analyzers)}"
|
|
157
|
+
lines << "Enabled type providers: #{list_or_none(types)}"
|
|
158
|
+
analyzer_summary = rules.fetch('analyzer_summary')
|
|
159
|
+
type_summary = rules.fetch('type_provider_summary')
|
|
160
|
+
lines << " ... #{analyzer_summary.fetch('truncated')} analyzers omitted" if analyzer_summary.fetch('truncated').positive?
|
|
161
|
+
lines << " ... #{type_summary.fetch('truncated')} type providers omitted" if type_summary.fetch('truncated').positive?
|
|
162
|
+
lines << "Assumptions: #{list_or_none(payload.fetch('assumptions'))}"
|
|
163
|
+
assumption_summary = payload.fetch('assumption_summary')
|
|
164
|
+
lines << " ... #{assumption_summary.fetch('truncated')} assumptions omitted" if assumption_summary.fetch('truncated').positive?
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def append_suggestions(lines)
|
|
168
|
+
lines << 'Suggested next evidence:'
|
|
169
|
+
payload.fetch('suggested_next_evidence').each do |suggestion|
|
|
170
|
+
lines << " #{suggestion['kind']}: #{suggestion['details']}"
|
|
171
|
+
end
|
|
172
|
+
summary = payload.fetch('suggested_next_evidence_summary')
|
|
173
|
+
lines << " ... #{summary.fetch('truncated')} suggestions omitted" if summary.fetch('truncated').positive?
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def node_reference(node)
|
|
177
|
+
"#{node['symbol_id']} [#{node['definition_id']}]"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def list_or_none(items)
|
|
181
|
+
items.empty? ? 'none' : items.join(', ')
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def summary_count(payload, key)
|
|
185
|
+
summary = payload.fetch(key)
|
|
186
|
+
"#{summary.fetch('total')} (shown #{summary.fetch('returned')})"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def unknown_blocker_keys(blockers)
|
|
190
|
+
blockers.to_set { |blocker| blocker_key(blocker) }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def blocker_key(blocker)
|
|
194
|
+
[blocker['kind'], blocker['scope_kind'], blocker['scope_value'], blocker['source'], blocker['reason']]
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|