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,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Necropsy
|
|
4
|
+
class ConventionRules
|
|
5
|
+
Rule = Data.define(:id, :family, :method_names, :owner_patterns, :ancestor_patterns, :frameworks) do
|
|
6
|
+
def initialize(id:, family:, methods: [], owner_patterns: [], ancestor_patterns: [], frameworks: [])
|
|
7
|
+
id = id.to_s
|
|
8
|
+
family = family.to_sym
|
|
9
|
+
raise ArgumentError, 'rule id must not be empty' if id.empty?
|
|
10
|
+
raise ArgumentError, 'rule family must not be empty' if family.to_s.empty?
|
|
11
|
+
raise ArgumentError, 'rule methods must be bounded strings' unless Array(methods).all? do |name|
|
|
12
|
+
name.is_a?(String) && !name.empty? && name.bytesize <= 128
|
|
13
|
+
end
|
|
14
|
+
raise ArgumentError, 'rule owner patterns must be bounded strings' unless Array(owner_patterns).all? do |pattern|
|
|
15
|
+
pattern.is_a?(String) && !pattern.empty? && pattern.bytesize <= 256
|
|
16
|
+
end
|
|
17
|
+
raise ArgumentError, 'rule ancestor patterns must be bounded strings' unless Array(ancestor_patterns).all? do |pattern|
|
|
18
|
+
pattern.is_a?(String) && !pattern.empty? && pattern.bytesize <= 256
|
|
19
|
+
end
|
|
20
|
+
raise ArgumentError, 'rule frameworks must be bounded strings' unless Array(frameworks).all? do |name|
|
|
21
|
+
name.is_a?(String) && !name.empty? && name.bytesize <= 64
|
|
22
|
+
end
|
|
23
|
+
raise ArgumentError, 'rules may not be unscoped' if Array(owner_patterns).empty? && Array(ancestor_patterns).empty?
|
|
24
|
+
|
|
25
|
+
super(
|
|
26
|
+
id: id.freeze,
|
|
27
|
+
family: family,
|
|
28
|
+
method_names: Array(methods).map(&:to_s).uniq.sort.freeze,
|
|
29
|
+
owner_patterns: Array(owner_patterns).map(&:to_s).uniq.sort.freeze,
|
|
30
|
+
ancestor_patterns: Array(ancestor_patterns).map(&:to_s).uniq.sort.freeze,
|
|
31
|
+
frameworks: Array(frameworks).map(&:to_s).uniq.sort.freeze
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
BUILT_IN = [
|
|
37
|
+
Rule.new(
|
|
38
|
+
id: 'rubocop.event_callback', family: :ancestor_scoped_hook,
|
|
39
|
+
ancestor_patterns: ['RuboCop::Cop', 'RuboCop::Cop::Base'], frameworks: ['rubocop']
|
|
40
|
+
),
|
|
41
|
+
Rule.new(
|
|
42
|
+
id: 'rails.callback_symbol', family: :symbol_argument_method_reference,
|
|
43
|
+
methods: %w[after_action after_save before_action before_save validate],
|
|
44
|
+
owner_patterns: ['*Controller', '*Job', '*Mailer', '*Record', '*Validator'], frameworks: ['rails']
|
|
45
|
+
),
|
|
46
|
+
Rule.new(
|
|
47
|
+
id: 'rails.application_base', family: :ancestor_scoped_hook,
|
|
48
|
+
methods: %w[after_action after_save before_action before_save validate],
|
|
49
|
+
ancestor_patterns: %w[ApplicationController ApplicationJob ApplicationMailer ApplicationRecord],
|
|
50
|
+
frameworks: ['rails']
|
|
51
|
+
),
|
|
52
|
+
Rule.new(
|
|
53
|
+
id: 'rails.action_cable', family: :framework_runtime_hook,
|
|
54
|
+
methods: %w[receive subscribed unsubscribed], owner_patterns: ['*Channel'], frameworks: ['rails']
|
|
55
|
+
),
|
|
56
|
+
Rule.new(
|
|
57
|
+
id: 'rails.active_job', family: :framework_runtime_hook,
|
|
58
|
+
methods: %w[deserialize perform serialize], owner_patterns: ['*Job'], ancestor_patterns: ['ApplicationJob'],
|
|
59
|
+
frameworks: ['rails']
|
|
60
|
+
),
|
|
61
|
+
Rule.new(
|
|
62
|
+
id: 'sidekiq.worker', family: :framework_runtime_hook,
|
|
63
|
+
methods: ['perform'], ancestor_patterns: %w[Sidekiq::Job Sidekiq::Worker], frameworks: ['sidekiq']
|
|
64
|
+
),
|
|
65
|
+
Rule.new(
|
|
66
|
+
id: 'graphql.runtime', family: :framework_runtime_hook,
|
|
67
|
+
methods: %w[authorized? ready? resolve subscribed update],
|
|
68
|
+
owner_patterns: %w[*Mutation *Resolver *Subscription *Type],
|
|
69
|
+
ancestor_patterns: %w[GraphQL::Schema::Mutation GraphQL::Schema::Resolver GraphQL::Schema::Subscription],
|
|
70
|
+
frameworks: ['graphql']
|
|
71
|
+
),
|
|
72
|
+
Rule.new(
|
|
73
|
+
id: 'active_model_serializers.public_surface', family: :declarative_public_surface,
|
|
74
|
+
owner_patterns: ['*Serializer'], frameworks: ['active_model_serializers']
|
|
75
|
+
),
|
|
76
|
+
Rule.new(
|
|
77
|
+
id: 'blueprinter.public_surface', family: :declarative_public_surface,
|
|
78
|
+
owner_patterns: ['*Blueprint'], frameworks: ['blueprinter']
|
|
79
|
+
),
|
|
80
|
+
Rule.new(
|
|
81
|
+
id: 'view_component.runtime', family: :framework_runtime_hook,
|
|
82
|
+
methods: %w[before_render call render?], owner_patterns: ['*Component'], frameworks: ['view_component']
|
|
83
|
+
)
|
|
84
|
+
].freeze
|
|
85
|
+
|
|
86
|
+
MAX_RULES = 32
|
|
87
|
+
|
|
88
|
+
def initialize(rules: BUILT_IN)
|
|
89
|
+
@rules = Array(rules).freeze
|
|
90
|
+
validate_rules!
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def method_hit(owner:, method_name:, ancestors:, frameworks: [])
|
|
94
|
+
candidate = @rules.find do |rule|
|
|
95
|
+
next false unless rule.method_names.empty? || rule.method_names.include?(method_name.to_s)
|
|
96
|
+
next false unless framework_enabled?(rule, frameworks)
|
|
97
|
+
|
|
98
|
+
scoped_match?(rule, owner, ancestors)
|
|
99
|
+
end
|
|
100
|
+
hit(candidate, owner: owner, method_name: method_name) if candidate
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
attr_reader :rules
|
|
106
|
+
|
|
107
|
+
def validate_rules!
|
|
108
|
+
raise ArgumentError, "too many convention rules (max #{MAX_RULES})" if rules.length > MAX_RULES
|
|
109
|
+
|
|
110
|
+
ids = rules.map(&:id)
|
|
111
|
+
raise ArgumentError, 'convention rule ids must be unique' unless ids.uniq == ids
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def framework_enabled?(rule, frameworks)
|
|
115
|
+
rule.frameworks.empty? || rule.frameworks.intersect?(Array(frameworks).map(&:to_s))
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def scoped_match?(rule, owner, ancestors)
|
|
119
|
+
owner = owner.to_s
|
|
120
|
+
ancestors = Array(ancestors).map(&:to_s)
|
|
121
|
+
owner_matches = rule.owner_patterns.any? { |pattern| File.fnmatch?(pattern, owner, File::FNM_EXTGLOB) }
|
|
122
|
+
ancestor_matches = rule.ancestor_patterns.any? do |pattern|
|
|
123
|
+
ancestors.any? { |candidate| candidate == pattern || candidate.start_with?("#{pattern}::") }
|
|
124
|
+
end
|
|
125
|
+
owner_matches || ancestor_matches
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def hit(rule, owner:, method_name:)
|
|
129
|
+
{
|
|
130
|
+
'rule_id' => rule.id,
|
|
131
|
+
'family' => rule.family.to_s,
|
|
132
|
+
'owner' => owner.to_s,
|
|
133
|
+
'method' => method_name.to_s,
|
|
134
|
+
'reason' => "#{rule.id} matched #{owner}##{method_name}"
|
|
135
|
+
}.freeze
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Necropsy
|
|
7
|
+
module DefinitionIdentity
|
|
8
|
+
class CanonicalizationError < StandardError; end
|
|
9
|
+
class LimitExceeded < CanonicalizationError; end
|
|
10
|
+
class CycleError < CanonicalizationError; end
|
|
11
|
+
class UnsupportedTypeError < CanonicalizationError; end
|
|
12
|
+
|
|
13
|
+
class CanonicalDigest
|
|
14
|
+
MAX_DEPTH = 256
|
|
15
|
+
MAX_ITEMS = 1_000_000
|
|
16
|
+
MAX_SCALAR_BYTES = 64 * 1024 * 1024
|
|
17
|
+
MAX_TOTAL_BYTES = 256 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
def initialize(max_depth: MAX_DEPTH, max_items: MAX_ITEMS, max_scalar_bytes: MAX_SCALAR_BYTES,
|
|
20
|
+
max_total_bytes: MAX_TOTAL_BYTES)
|
|
21
|
+
@max_depth = positive_limit(max_depth, :max_depth)
|
|
22
|
+
@max_items = positive_limit(max_items, :max_items)
|
|
23
|
+
@max_scalar_bytes = positive_limit(max_scalar_bytes, :max_scalar_bytes)
|
|
24
|
+
@max_total_bytes = positive_limit(max_total_bytes, :max_total_bytes)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def hexdigest(value)
|
|
28
|
+
reset_state
|
|
29
|
+
output = DigestOutput.new(byte_budget)
|
|
30
|
+
process([[:value, value, 0, output]])
|
|
31
|
+
output.hexdigest
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def hexdigest_payload(values)
|
|
35
|
+
reset_state
|
|
36
|
+
output = DigestOutput.new(byte_budget)
|
|
37
|
+
output.write('[')
|
|
38
|
+
values.each_with_index do |value, index|
|
|
39
|
+
count_item!
|
|
40
|
+
output.write(',') if index.positive?
|
|
41
|
+
output.write(payload_scalar(value))
|
|
42
|
+
end
|
|
43
|
+
output.write(']')
|
|
44
|
+
output.hexdigest
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :max_depth, :max_items, :max_scalar_bytes, :max_total_bytes, :active_containers, :byte_budget
|
|
50
|
+
|
|
51
|
+
def reset_state
|
|
52
|
+
@active_containers = {}
|
|
53
|
+
@items = 0
|
|
54
|
+
@byte_budget = ByteBudget.new(max_total_bytes)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def process(stack)
|
|
58
|
+
until stack.empty?
|
|
59
|
+
action, *arguments = stack.pop
|
|
60
|
+
send("process_#{action}", stack, *arguments)
|
|
61
|
+
end
|
|
62
|
+
rescue CanonicalizationError
|
|
63
|
+
raise
|
|
64
|
+
rescue SystemStackError => e
|
|
65
|
+
raise CanonicalizationError, "Could not canonicalize definition: #{e.message}"
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
raise CanonicalizationError, "Could not canonicalize definition: #{e.class}: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def process_value(stack, value, depth, output)
|
|
71
|
+
count_item!
|
|
72
|
+
check_depth!(depth)
|
|
73
|
+
case value
|
|
74
|
+
when Prism::Node then start_node(stack, value, depth, output)
|
|
75
|
+
when Prism::Location then output.write('null')
|
|
76
|
+
when Array then start_array(stack, value, depth, output)
|
|
77
|
+
when Hash then start_hash(stack, value, depth, output)
|
|
78
|
+
when Symbol then tagged_scalar(output, 'symbol', value.to_s)
|
|
79
|
+
when String then tagged_scalar(output, 'string', value)
|
|
80
|
+
when Integer then tagged_scalar(output, 'integer', value.to_s)
|
|
81
|
+
when Float then write_float(output, value)
|
|
82
|
+
when true, false then output.write(%(["boolean",#{value}]))
|
|
83
|
+
when nil then output.write('["nil"]')
|
|
84
|
+
else raise UnsupportedTypeError, "Unsupported definition payload type: #{value.class}"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def start_node(stack, node, depth, output)
|
|
89
|
+
enter_container!(node)
|
|
90
|
+
fields = node.deconstruct_keys(nil).filter_map do |key, value|
|
|
91
|
+
[key.to_s, value] unless DefinitionIdentity.send(:excluded_key?, key)
|
|
92
|
+
end.sort_by(&:first)
|
|
93
|
+
type = checked_string(node.type.to_s)
|
|
94
|
+
flags = node.send(:flags) & ~Prism::NodeFlags::NEWLINE
|
|
95
|
+
output.write("[\"node\",#{json_string(type)},#{flags},[")
|
|
96
|
+
stack << [:node_field, node, fields, 0, depth, output]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def process_node_field(stack, node, fields, index, depth, output)
|
|
100
|
+
if index >= fields.length
|
|
101
|
+
output.write(']]')
|
|
102
|
+
leave_container(node)
|
|
103
|
+
return
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
key, value = fields.fetch(index)
|
|
107
|
+
output.write(',') if index.positive?
|
|
108
|
+
output.write("[#{json_string(checked_string(key))},")
|
|
109
|
+
stack << [:node_field_complete, node, fields, index, depth, output]
|
|
110
|
+
stack << [:value, value, depth + 1, output]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def process_node_field_complete(stack, node, fields, index, depth, output)
|
|
114
|
+
output.write(']')
|
|
115
|
+
stack << [:node_field, node, fields, index + 1, depth, output]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def start_array(stack, value, depth, output)
|
|
119
|
+
enter_container!(value)
|
|
120
|
+
output.write('["array",[')
|
|
121
|
+
stack << [:array_next, value, 0, depth, output]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def process_array_next(stack, value, index, depth, output)
|
|
125
|
+
if index >= value.length
|
|
126
|
+
output.write(']]')
|
|
127
|
+
leave_container(value)
|
|
128
|
+
return
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
output.write(',') if index.positive?
|
|
132
|
+
stack << [:array_next, value, index + 1, depth, output]
|
|
133
|
+
stack << [:value, value.fetch(index), depth + 1, output]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def start_hash(stack, value, depth, output)
|
|
137
|
+
enter_container!(value)
|
|
138
|
+
stack << [:hash_next, value, value.each_pair, [], depth, output]
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def process_hash_next(stack, value, iterator, pairs, depth, output)
|
|
142
|
+
key, item = iterator.next
|
|
143
|
+
pair = StringOutput.new(byte_budget)
|
|
144
|
+
pair.write('[')
|
|
145
|
+
stack << [:hash_pair_complete, value, iterator, pairs, depth, output, pair]
|
|
146
|
+
stack << [:raw, pair, ']']
|
|
147
|
+
stack << [:value, item, depth + 1, pair]
|
|
148
|
+
stack << [:raw, pair, ',']
|
|
149
|
+
stack << [:value, key, depth + 1, pair]
|
|
150
|
+
rescue StopIteration
|
|
151
|
+
output.write('["hash",[')
|
|
152
|
+
pairs.sort.each_with_index do |pair, index|
|
|
153
|
+
output.write(',') if index.positive?
|
|
154
|
+
output.write(pair)
|
|
155
|
+
end
|
|
156
|
+
output.write(']]')
|
|
157
|
+
leave_container(value)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def process_hash_pair_complete(stack, value, iterator, pairs, depth, output, pair)
|
|
161
|
+
pairs << pair.to_s
|
|
162
|
+
stack << [:hash_next, value, iterator, pairs, depth, output]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def process_raw(_stack, output, bytes)
|
|
166
|
+
output.write(bytes)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def tagged_scalar(output, tag, value)
|
|
170
|
+
output.write(%([#{json_string(tag)},#{json_string_or_fingerprint(value)}]))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def write_float(output, value)
|
|
174
|
+
raise UnsupportedTypeError, 'Definition payload contains a non-finite float' unless value.finite?
|
|
175
|
+
|
|
176
|
+
tagged_scalar(output, 'float', value.to_s)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def payload_scalar(value)
|
|
180
|
+
case value
|
|
181
|
+
when String then json_string_or_fingerprint(value)
|
|
182
|
+
when Integer then value.to_s
|
|
183
|
+
else raise UnsupportedTypeError, "Unsupported definition identity component: #{value.class}"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def json_string_or_fingerprint(value)
|
|
188
|
+
checked_string(value)
|
|
189
|
+
JSON.generate(value)
|
|
190
|
+
rescue JSON::GeneratorError, EncodingError
|
|
191
|
+
JSON.generate(['invalid_string', value.encoding.name, value.bytesize, Digest::SHA256.hexdigest(value.b)])
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def json_string(value)
|
|
195
|
+
JSON.generate(value)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def checked_string(value)
|
|
199
|
+
return value if value.bytesize <= max_scalar_bytes
|
|
200
|
+
|
|
201
|
+
raise LimitExceeded, "Canonical scalar exceeds maximum size #{max_scalar_bytes}"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def enter_container!(value)
|
|
205
|
+
object_id = value.object_id
|
|
206
|
+
raise CycleError, "Definition payload contains a cycle at #{value.class}" if active_containers.key?(object_id)
|
|
207
|
+
|
|
208
|
+
active_containers[object_id] = true
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def leave_container(value)
|
|
212
|
+
active_containers.delete(value.object_id)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def check_depth!(depth)
|
|
216
|
+
return if depth <= max_depth
|
|
217
|
+
|
|
218
|
+
raise LimitExceeded, "Canonical payload exceeds maximum depth #{max_depth}"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def count_item!
|
|
222
|
+
@items += 1
|
|
223
|
+
raise LimitExceeded, "Canonical payload exceeds maximum item count #{max_items}" if @items > max_items
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def positive_limit(value, name)
|
|
227
|
+
value = Integer(value)
|
|
228
|
+
raise ArgumentError, "#{name} must be positive" unless value.positive?
|
|
229
|
+
|
|
230
|
+
value
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
class DigestOutput
|
|
234
|
+
def initialize(budget)
|
|
235
|
+
@budget = budget
|
|
236
|
+
@digest = Digest::SHA256.new
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def write(value)
|
|
240
|
+
@budget.consume(value.bytesize)
|
|
241
|
+
@digest.update(value.b)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def hexdigest
|
|
245
|
+
@digest.hexdigest
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
class StringOutput
|
|
250
|
+
def initialize(budget)
|
|
251
|
+
@budget = budget
|
|
252
|
+
@value = String.new(encoding: Encoding::BINARY)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def write(value)
|
|
256
|
+
@budget.consume(value.bytesize)
|
|
257
|
+
@value << value.b
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def to_s
|
|
261
|
+
@value
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
class ByteBudget
|
|
266
|
+
def initialize(limit)
|
|
267
|
+
@limit = limit
|
|
268
|
+
@bytes = 0
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def consume(bytes)
|
|
272
|
+
@bytes += bytes
|
|
273
|
+
raise LimitExceeded, "Canonical payload exceeds maximum size #{@limit}" if @bytes > @limit
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'prism'
|
|
4
|
+
require_relative 'definition_identity/canonical_digest'
|
|
5
|
+
|
|
6
|
+
module Necropsy
|
|
7
|
+
module DefinitionIdentity
|
|
8
|
+
VERSION = 1
|
|
9
|
+
PREFIX = "def:v#{VERSION}".freeze
|
|
10
|
+
EXCLUDED_KEYS = %i[node_id location flags].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def body_digest(node, **limits)
|
|
15
|
+
CanonicalDigest.new(**limits).hexdigest(node)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def definition_id(kind:, symbol_id:, relative_path:, body_digest:, ordinal:)
|
|
19
|
+
ordinal = Integer(ordinal)
|
|
20
|
+
raise ArgumentError, 'definition ordinal must be positive' unless ordinal.positive?
|
|
21
|
+
|
|
22
|
+
payload = [kind.to_s, symbol_id.to_s, relative_path.to_s, body_digest.to_s, ordinal]
|
|
23
|
+
"#{PREFIX}:#{CanonicalDigest.new.hexdigest_payload(payload)}"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def file_root_id(relative_path:)
|
|
27
|
+
payload = ['file_root', relative_path.to_s]
|
|
28
|
+
"#{PREFIX}:#{CanonicalDigest.new.hexdigest_payload(payload)}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def excluded_key?(key)
|
|
32
|
+
name = key.to_s
|
|
33
|
+
EXCLUDED_KEYS.include?(key.to_sym) || name.end_with?('_loc', 'comment', 'comments')
|
|
34
|
+
end
|
|
35
|
+
private_class_method :excluded_key?
|
|
36
|
+
end
|
|
37
|
+
end
|