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,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