marshalsea 0.1.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.
@@ -0,0 +1,277 @@
1
+ # ©AngelaMos | 2026
2
+ # inspector.rb
3
+ # frozen_string_literal: true
4
+
5
+ require "psych"
6
+
7
+ module Marshalsea
8
+ module Psych
9
+ class DocumentError < StandardError; end
10
+
11
+ class MalformedDocumentError < DocumentError; end
12
+
13
+ class InputTypeError < DocumentError; end
14
+
15
+ class LimitExceededError < DocumentError; end
16
+
17
+ module Tags
18
+ PATTERN = %r{\A!ruby/(?<kind>[a-z_-]+)(?::(?<class_name>.+))?\z}
19
+
20
+ KIND_OBJECT = "object"
21
+ KIND_HASH = "hash"
22
+ KIND_ARRAY = "array"
23
+ KIND_STRING = "string"
24
+ KIND_STRUCT = "struct"
25
+ KIND_EXCEPTION = "exception"
26
+ KIND_MARSHALABLE = "marshalable"
27
+
28
+ REVIVAL_METHODS = {
29
+ KIND_OBJECT => "init_with",
30
+ KIND_HASH => "[]=",
31
+ KIND_ARRAY => "init_with",
32
+ KIND_STRING => "init_with",
33
+ KIND_STRUCT => "init_with",
34
+ KIND_EXCEPTION => "init_with",
35
+ KIND_MARSHALABLE => "marshal_load"
36
+ }.freeze
37
+
38
+ GATED_KINDS = [KIND_MARSHALABLE].freeze
39
+
40
+ module_function
41
+
42
+ def parse(tag)
43
+ match = PATTERN.match(tag.to_s)
44
+ return nil unless match
45
+
46
+ [match[:kind], match[:class_name]]
47
+ end
48
+ end
49
+
50
+ class Limits
51
+ DEFAULT_MAX_BYTES = 1_048_576
52
+ DEFAULT_MAX_DEPTH = 64
53
+ DEFAULT_MAX_NODES = 10_000
54
+ DEFAULT_MAX_ALIASES = 64
55
+ DEFAULT_MAX_DOCUMENTS = 8
56
+
57
+ ROLE_BYTES = "document bytes"
58
+ ROLE_DEPTH = "nesting depth"
59
+ ROLE_NODES = "nodes"
60
+ ROLE_ALIASES = "aliases"
61
+ ROLE_DOCUMENTS = "documents"
62
+
63
+ attr_reader :max_bytes, :max_depth, :max_nodes, :max_aliases, :max_documents
64
+
65
+ def initialize(max_bytes: DEFAULT_MAX_BYTES, max_depth: DEFAULT_MAX_DEPTH,
66
+ max_nodes: DEFAULT_MAX_NODES, max_aliases: DEFAULT_MAX_ALIASES,
67
+ max_documents: DEFAULT_MAX_DOCUMENTS)
68
+ @max_bytes = max_bytes
69
+ @max_depth = max_depth
70
+ @max_nodes = max_nodes
71
+ @max_aliases = max_aliases
72
+ @max_documents = max_documents
73
+ end
74
+ end
75
+
76
+ class Reference
77
+ attr_reader :class_name, :kind, :key_position
78
+
79
+ def initialize(class_name:, kind:, key_position:)
80
+ @class_name = class_name
81
+ @kind = kind
82
+ @key_position = key_position
83
+ end
84
+
85
+ def key_position?
86
+ @key_position
87
+ end
88
+
89
+ def gated?
90
+ Tags::GATED_KINDS.include?(kind)
91
+ end
92
+
93
+ def revival_method
94
+ Tags::REVIVAL_METHODS.fetch(kind, nil)
95
+ end
96
+
97
+ def to_s
98
+ "#{class_name} (!ruby/#{kind})"
99
+ end
100
+ end
101
+
102
+ class Document
103
+ attr_reader :references, :alias_count, :node_count, :document_count
104
+
105
+ def initialize(references:, alias_count:, node_count:, document_count:)
106
+ @references = references
107
+ @alias_count = alias_count
108
+ @node_count = node_count
109
+ @document_count = document_count
110
+ end
111
+
112
+ def class_names
113
+ references.filter_map(&:class_name).uniq
114
+ end
115
+
116
+ def revivable
117
+ references.reject { |reference| reference.class_name.nil? }
118
+ end
119
+
120
+ def key_position_references
121
+ references.select(&:key_position?)
122
+ end
123
+
124
+ def gated_references
125
+ references.select(&:gated?)
126
+ end
127
+ end
128
+
129
+ class Inspector
130
+ Decision = Marshalsea::Marshal::BoundaryDetector::Decision
131
+
132
+ REASON_INPUT_TYPE = "input is not a String"
133
+ REASON_MALFORMED = "document is not parseable YAML: %s"
134
+ REASON_UNAPPROVED = "document revives unapproved class %s through %s"
135
+ REASON_KEY_DISPATCH = "document puts %s in a mapping key, so its #hash and #== run " \
136
+ "while the mapping is rebuilt"
137
+
138
+ LIMITATION_NOTICE = <<~NOTICE
139
+ SECURITY LIMITATION
140
+
141
+ Marshalsea::Psych::Inspector reads a YAML document through Psych.parse_stream, which
142
+ builds an AST and revives nothing. It never calls YAML.load or YAML.unsafe_load.
143
+
144
+ Unlike Marshal, Psych's own allowlist is a real veto: Psych checks the tag before it
145
+ revives the object, where Marshal runs its proc after the callback has already fired.
146
+ Same intent, opposite outcome, decided entirely by where the check sits. That means
147
+ YAML.safe_load with permitted_classes is a boundary and this inspector is only
148
+ detection and reporting on top of it.
149
+
150
+ Prefer YAML.safe_load. Use this to see what a document would revive, to log it, or
151
+ to reject a document before it reaches a loader you do not control.
152
+
153
+ Alias expansion is not performed here, so an alias bomb costs nothing to inspect.
154
+ It still costs whatever the eventual loader spends expanding it, which is why the
155
+ alias count is bounded and reported rather than ignored.
156
+ NOTICE
157
+
158
+ def initialize(permitted_class_names: [], limits: Limits.new)
159
+ @permitted_class_names = permitted_class_names.map(&:to_s).freeze
160
+ @limits = limits
161
+ end
162
+
163
+ def inspect_document(input)
164
+ return reject(REASON_INPUT_TYPE) unless input.is_a?(String)
165
+
166
+ snapshot = input.dup.freeze
167
+ enforce_size(snapshot)
168
+ evaluate(read(snapshot), snapshot)
169
+ rescue DocumentError => e
170
+ reject(format(REASON_MALFORMED, e.class.name.split("::").last))
171
+ end
172
+
173
+ def read(source)
174
+ Walk.new(limits).call(::Psych.parse_stream(source))
175
+ rescue ::Psych::SyntaxError => e
176
+ raise MalformedDocumentError, e.message
177
+ end
178
+
179
+ private
180
+
181
+ attr_reader :permitted_class_names, :limits
182
+
183
+ def enforce_size(source)
184
+ return if source.bytesize <= limits.max_bytes
185
+
186
+ raise LimitExceededError, "#{Limits::ROLE_BYTES} #{source.bytesize} exceeds #{limits.max_bytes}"
187
+ end
188
+
189
+ def evaluate(document, snapshot)
190
+ violation = violation_for(document)
191
+ return reject(violation) if violation
192
+
193
+ Decision.new(state: Decision::STATE_PROCEED, snapshot: snapshot, result: document)
194
+ end
195
+
196
+ def violation_for(document)
197
+ keyed = document.key_position_references.first
198
+ return format(REASON_KEY_DISPATCH, keyed.class_name.inspect) if keyed
199
+
200
+ unapproved = document.revivable.reject do |reference|
201
+ permitted_class_names.include?(reference.class_name)
202
+ end
203
+ return nil if unapproved.empty?
204
+
205
+ format(REASON_UNAPPROVED, unapproved.first.class_name.inspect,
206
+ unapproved.first.revival_method)
207
+ end
208
+
209
+ def reject(reason)
210
+ Decision.new(state: Decision::STATE_BLOCKED, reason: reason)
211
+ end
212
+ end
213
+
214
+ class Walk
215
+ MAPPING_KEY_STRIDE = 2
216
+
217
+ def initialize(limits)
218
+ @limits = limits
219
+ @references = []
220
+ @aliases = 0
221
+ @nodes = 0
222
+ @documents = 0
223
+ end
224
+
225
+ def call(stream)
226
+ @documents = stream.children.length
227
+ check(@documents, limits.max_documents, Limits::ROLE_DOCUMENTS)
228
+ stream.children.each { |child| visit(child, 1, false) }
229
+ Document.new(references: @references.freeze, alias_count: @aliases,
230
+ node_count: @nodes, document_count: @documents)
231
+ end
232
+
233
+ private
234
+
235
+ attr_reader :limits
236
+
237
+ def check(value, ceiling, role)
238
+ return if value <= ceiling
239
+
240
+ raise LimitExceededError, "#{role} #{value} exceeds #{ceiling}"
241
+ end
242
+
243
+ def visit(node, depth, key_position)
244
+ check(depth, limits.max_depth, Limits::ROLE_DEPTH)
245
+ @nodes += 1
246
+ check(@nodes, limits.max_nodes, Limits::ROLE_NODES)
247
+
248
+ if node.is_a?(::Psych::Nodes::Alias)
249
+ @aliases += 1
250
+ check(@aliases, limits.max_aliases, Limits::ROLE_ALIASES)
251
+ end
252
+
253
+ record(node, key_position)
254
+ descend(node, depth)
255
+ end
256
+
257
+ def record(node, key_position)
258
+ return unless node.respond_to?(:tag)
259
+
260
+ kind, class_name = Tags.parse(node.tag)
261
+ return unless kind
262
+
263
+ @references << Reference.new(class_name: class_name, kind: kind, key_position: key_position)
264
+ end
265
+
266
+ def descend(node, depth)
267
+ children = node.children
268
+ return unless children
269
+
270
+ mapping = node.is_a?(::Psych::Nodes::Mapping)
271
+ children.each_with_index do |child, index|
272
+ visit(child, depth + 1, mapping && (index % MAPPING_KEY_STRIDE).zero?)
273
+ end
274
+ end
275
+ end
276
+ end
277
+ end
@@ -0,0 +1,397 @@
1
+ # ©AngelaMos | 2026
2
+ # scanner.rb
3
+ # frozen_string_literal: true
4
+
5
+ module Marshalsea
6
+ class ParseRecoveredError < StandardError
7
+ def initialize(errors)
8
+ super("Prism recovered an incomplete tree from #{errors} syntax errors")
9
+ end
10
+ end
11
+
12
+ class Scanner
13
+ GATE_GATED = :gated
14
+ GATE_SOFT = :soft
15
+ GATE_UNGATED = :ungated
16
+ GATE_LINK = :link
17
+
18
+ FORMAT_MARSHAL = :marshal
19
+ FORMAT_PSYCH = :psych
20
+
21
+ VARIADIC = -1
22
+
23
+ ENTRY_POINTS = {
24
+ "marshal_load" => { gate: GATE_GATED, arity: 1, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
25
+ "_load_data" => { gate: GATE_GATED, arity: 1, formats: [FORMAT_MARSHAL] },
26
+ "_load" => { gate: GATE_GATED, arity: 1, formats: [FORMAT_MARSHAL], singleton: true },
27
+ "init_with" => { gate: GATE_SOFT, arity: 1, formats: [FORMAT_PSYCH] },
28
+ "hash" => { gate: GATE_UNGATED, arity: 0, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
29
+ "eql?" => { gate: GATE_UNGATED, arity: 1, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
30
+ "<=>" => { gate: GATE_UNGATED, arity: 1, formats: [FORMAT_MARSHAL] },
31
+ "==" => { gate: GATE_UNGATED, arity: 1, formats: [FORMAT_PSYCH] },
32
+ "[]=" => { gate: GATE_UNGATED, arity: 2, formats: [FORMAT_PSYCH] },
33
+ "method_missing" => { gate: GATE_UNGATED, arity: VARIADIC, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
34
+ "respond_to_missing?" => { gate: GATE_UNGATED, arity: 2, formats: [FORMAT_MARSHAL, FORMAT_PSYCH] },
35
+ "respond_to?" => { gate: GATE_UNGATED, arity: VARIADIC, formats: [FORMAT_PSYCH] },
36
+ "to_s" => { gate: GATE_LINK, arity: 0, formats: [] },
37
+ "coerce" => { gate: GATE_LINK, arity: 1, formats: [] }
38
+ }.freeze
39
+
40
+ GATED_METHODS = ENTRY_POINTS.select { |_, spec| spec[:gate] == GATE_GATED && !spec[:singleton] }
41
+ .keys.freeze
42
+ GATED_SINGLETON_METHODS = ENTRY_POINTS.select { |_, spec| spec[:singleton] }.keys.freeze
43
+ UNGATED_METHODS = ENTRY_POINTS.select { |_, spec| spec[:gate] == GATE_UNGATED }.keys.freeze
44
+ LINK_METHODS = ENTRY_POINTS.select { |_, spec| spec[:gate] == GATE_LINK }.keys.freeze
45
+ INSTANCE_METHODS = ENTRY_POINTS.reject { |_, spec| spec[:singleton] }.keys.freeze
46
+ SINGLETON_METHODS = GATED_SINGLETON_METHODS
47
+
48
+ PRISM_AVAILABLE = begin
49
+ require "prism"
50
+ true
51
+ rescue LoadError
52
+ false
53
+ end
54
+
55
+ LOCATION_SEPARATOR = ":"
56
+ UNKNOWN_LOCATION = nil
57
+
58
+ STATE_UNREADABLE = :unreadable
59
+ STATE_UNANALYSABLE = :unanalysable
60
+ STATE_VERDICTS = [true, false].freeze
61
+
62
+ SITE_MODULE_NAME = :module_name
63
+ SITE_OWN_METHODS = :own_methods
64
+ SITE_CANDIDATE = :candidate
65
+ SITE_SOURCE_PARSE = :source_parse
66
+ SITE_STATE_ANALYSIS = :state_analysis
67
+
68
+ SITES = [SITE_MODULE_NAME, SITE_OWN_METHODS, SITE_CANDIDATE,
69
+ SITE_SOURCE_PARSE, SITE_STATE_ANALYSIS].freeze
70
+
71
+ LOSSY_SITES = [SITE_MODULE_NAME, SITE_OWN_METHODS, SITE_CANDIDATE].freeze
72
+
73
+ SUBJECT_UNNAMED = "(module that cannot report a name)"
74
+
75
+ class Candidate
76
+ attr_reader :class_name, :method_name, :gate, :source_location, :arity, :formats
77
+
78
+ def initialize(class_name:, method_name:, gate:, source_location:, arity:, singleton:,
79
+ touches_state:, formats:)
80
+ @class_name = class_name
81
+ @method_name = method_name
82
+ @gate = gate
83
+ @source_location = source_location
84
+ @arity = arity
85
+ @singleton = singleton
86
+ @touches_state = touches_state
87
+ @formats = formats
88
+ end
89
+
90
+ def singleton?
91
+ @singleton
92
+ end
93
+
94
+ def gated?
95
+ gate == GATE_GATED
96
+ end
97
+
98
+ def soft_gated?
99
+ gate == GATE_SOFT
100
+ end
101
+
102
+ def link?
103
+ gate == GATE_LINK
104
+ end
105
+
106
+ def entry_point?
107
+ !link?
108
+ end
109
+
110
+ def dispatch_arity
111
+ ENTRY_POINTS.fetch(method_name).fetch(:arity)
112
+ end
113
+
114
+ def accepts_dispatch?
115
+ required = dispatch_arity
116
+ return true if required == VARIADIC
117
+ return arity == required unless arity.negative?
118
+
119
+ required >= (arity.abs - 1)
120
+ end
121
+
122
+ def zero_arity?
123
+ arity.zero?
124
+ end
125
+
126
+ def touches_state?
127
+ @touches_state == true
128
+ end
129
+
130
+ def state_known?
131
+ STATE_VERDICTS.include?(@touches_state)
132
+ end
133
+
134
+ def unanalysable?
135
+ @touches_state == STATE_UNANALYSABLE
136
+ end
137
+
138
+ def unreadable_source?
139
+ @touches_state == STATE_UNREADABLE
140
+ end
141
+
142
+ def reachable?
143
+ return false unless entry_point?
144
+ return false unless accepts_dispatch?
145
+ return true if gated? || soft_gated?
146
+
147
+ touches_state? || unreadable_source?
148
+ end
149
+
150
+ def to_s
151
+ "#{class_name}#{singleton? ? '.' : '#'}#{method_name}"
152
+ end
153
+ end
154
+
155
+ class Suppression
156
+ attr_reader :site, :subject, :error_class
157
+
158
+ def initialize(site:, subject:, error_class:)
159
+ @site = site
160
+ @subject = subject
161
+ @error_class = error_class
162
+ end
163
+
164
+ def lossy?
165
+ LOSSY_SITES.include?(site)
166
+ end
167
+
168
+ def to_s
169
+ "#{site} #{subject} (#{error_class})"
170
+ end
171
+ end
172
+
173
+ class Report
174
+ attr_reader :candidates, :scanned_modules, :suppressions
175
+
176
+ def initialize(candidates, scanned_modules, suppressions)
177
+ @candidates = candidates
178
+ @scanned_modules = scanned_modules
179
+ @suppressions = suppressions
180
+ end
181
+
182
+ def suppressed_count
183
+ suppressions.length
184
+ end
185
+
186
+ def suppressions_by_site
187
+ suppressions.each_with_object({}) do |suppression, counts|
188
+ counts[suppression.site] = counts.fetch(suppression.site, 0) + 1
189
+ end
190
+ end
191
+
192
+ def complete?
193
+ suppressions.empty?
194
+ end
195
+
196
+ def candidates_lost?
197
+ suppressions.any?(&:lossy?)
198
+ end
199
+
200
+ def unanalysable
201
+ candidates.select(&:unanalysable?)
202
+ end
203
+
204
+ def fully_analysed?
205
+ candidates.all?(&:state_known?)
206
+ end
207
+
208
+ def gated
209
+ candidates.select(&:gated?)
210
+ end
211
+
212
+ def ungated
213
+ candidates.select { |candidate| candidate.gate == GATE_UNGATED }
214
+ end
215
+
216
+ def links
217
+ candidates.select(&:link?)
218
+ end
219
+
220
+ def entry_points
221
+ candidates.select(&:entry_point?)
222
+ end
223
+
224
+ def reachable
225
+ candidates.select(&:reachable?)
226
+ end
227
+
228
+ def reachable_in(format)
229
+ reachable.select { |candidate| candidate.formats.include?(format) }
230
+ end
231
+
232
+ def prism_available?
233
+ PRISM_AVAILABLE
234
+ end
235
+ end
236
+
237
+ def initialize(namespace: nil)
238
+ @namespace = namespace
239
+ @candidates = []
240
+ @definition_cache = {}
241
+ @scanned_modules = 0
242
+ @suppressions = []
243
+ end
244
+
245
+ def scan
246
+ each_named_module do |mod, name|
247
+ @scanned_modules += 1
248
+ collect_instance_methods(mod, name)
249
+ collect_singleton_methods(mod, name)
250
+ end
251
+
252
+ Report.new(@candidates.sort_by(&:to_s), @scanned_modules, @suppressions.freeze)
253
+ end
254
+
255
+ private
256
+
257
+ attr_reader :namespace
258
+
259
+ def suppress(site, subject, error)
260
+ @suppressions << Suppression.new(site: site, subject: subject, error_class: error.class.name)
261
+ end
262
+
263
+ def each_named_module
264
+ ObjectSpace.each_object(Module) do |mod|
265
+ name = safe_name(mod)
266
+ next unless name
267
+ next unless in_namespace?(name)
268
+
269
+ yield mod, name
270
+ end
271
+ end
272
+
273
+ def safe_name(mod)
274
+ name = mod.name
275
+ name if name.is_a?(String) && !name.empty?
276
+ rescue StandardError => e
277
+ suppress(SITE_MODULE_NAME, SUBJECT_UNNAMED, e)
278
+ nil
279
+ end
280
+
281
+ def in_namespace?(name)
282
+ namespace.nil? || name == namespace || name.start_with?("#{namespace}::")
283
+ end
284
+
285
+ def collect_instance_methods(mod, name)
286
+ (own_instance_methods(mod, name) & INSTANCE_METHODS).each do |method_name|
287
+ record(mod, name, method_name, singleton: false)
288
+ end
289
+ end
290
+
291
+ def collect_singleton_methods(mod, name)
292
+ (own_singleton_methods(mod, name) & SINGLETON_METHODS).each do |method_name|
293
+ record(mod, name, method_name, singleton: true)
294
+ end
295
+ end
296
+
297
+ def own_instance_methods(mod, name)
298
+ (mod.instance_methods(false) +
299
+ mod.private_instance_methods(false) +
300
+ mod.protected_instance_methods(false)).map(&:to_s)
301
+ rescue StandardError => e
302
+ suppress(SITE_OWN_METHODS, name, e)
303
+ []
304
+ end
305
+
306
+ def own_singleton_methods(mod, name)
307
+ (mod.singleton_methods(false) +
308
+ mod.singleton_class.private_instance_methods(false)).map(&:to_s)
309
+ rescue StandardError => e
310
+ suppress(SITE_OWN_METHODS, name, e)
311
+ []
312
+ end
313
+
314
+ def qualified(name, method_name, singleton)
315
+ "#{name}#{singleton ? '.' : '#'}#{method_name}"
316
+ end
317
+
318
+ def record(mod, name, method_name, singleton:)
319
+ spec = ENTRY_POINTS.fetch(method_name)
320
+ handle = singleton ? mod.singleton_class.instance_method(method_name) : mod.instance_method(method_name)
321
+
322
+ @candidates << Candidate.new(
323
+ class_name: name,
324
+ method_name: method_name,
325
+ gate: spec.fetch(:gate),
326
+ source_location: format_location(handle.source_location),
327
+ arity: handle.arity,
328
+ singleton: singleton,
329
+ touches_state: state_reference_in(handle, qualified(name, method_name, singleton)),
330
+ formats: spec.fetch(:formats)
331
+ )
332
+ rescue StandardError, ScriptError => e
333
+ suppress(SITE_CANDIDATE, qualified(name, method_name, singleton), e)
334
+ nil
335
+ end
336
+
337
+ def format_location(location)
338
+ return UNKNOWN_LOCATION unless location
339
+
340
+ location.join(LOCATION_SEPARATOR)
341
+ end
342
+
343
+ def state_reference_in(handle, subject)
344
+ return STATE_UNANALYSABLE unless PRISM_AVAILABLE
345
+
346
+ path, line = handle.source_location
347
+ return STATE_UNANALYSABLE unless path && line
348
+
349
+ definitions = definitions_for(path)
350
+ return STATE_UNREADABLE unless definitions
351
+
352
+ node = definitions[line]
353
+ return STATE_UNREADABLE unless node
354
+
355
+ node.compact_child_nodes.any? { |child| state_reference?(child) }
356
+ rescue StandardError, ScriptError => e
357
+ suppress(SITE_STATE_ANALYSIS, subject, e)
358
+ STATE_UNREADABLE
359
+ end
360
+
361
+ def definitions_for(path)
362
+ return @definition_cache[path] if @definition_cache.key?(path)
363
+
364
+ @definition_cache[path] = parse_definitions(path)
365
+ end
366
+
367
+ def parse_definitions(path)
368
+ parsed = Prism.parse_file(path)
369
+ if parsed.failure?
370
+ suppress(SITE_SOURCE_PARSE, path, ParseRecoveredError.new(parsed.errors.length))
371
+ return nil
372
+ end
373
+
374
+ found = {}
375
+ collect_definitions(parsed.value, found)
376
+ found
377
+ rescue StandardError, ScriptError => e
378
+ suppress(SITE_SOURCE_PARSE, path, e)
379
+ nil
380
+ end
381
+
382
+ def collect_definitions(node, found)
383
+ return unless node.is_a?(Prism::Node)
384
+
385
+ found[node.location.start_line] = node if node.is_a?(Prism::DefNode)
386
+ node.compact_child_nodes.each { |child| collect_definitions(child, found) }
387
+ end
388
+
389
+ def state_reference?(node)
390
+ return false unless node.is_a?(Prism::Node)
391
+ return true if node.is_a?(Prism::InstanceVariableReadNode)
392
+ return true if node.is_a?(Prism::CallNode) && node.receiver.nil?
393
+
394
+ node.compact_child_nodes.any? { |child| state_reference?(child) }
395
+ end
396
+ end
397
+ end
@@ -0,0 +1,7 @@
1
+ # ©AngelaMos | 2026
2
+ # version.rb
3
+ # frozen_string_literal: true
4
+
5
+ module Marshalsea
6
+ VERSION = "0.1.0"
7
+ end
data/lib/marshalsea.rb ADDED
@@ -0,0 +1,19 @@
1
+ # ©AngelaMos | 2026
2
+ # marshalsea.rb
3
+ # frozen_string_literal: true
4
+
5
+ require_relative "marshalsea/version"
6
+ require_relative "marshalsea/marshal/constants"
7
+ require_relative "marshalsea/marshal/errors"
8
+ require_relative "marshalsea/marshal/node"
9
+ require_relative "marshalsea/marshal/float_body"
10
+ require_relative "marshalsea/marshal/limits"
11
+ require_relative "marshalsea/marshal/parser"
12
+ require_relative "marshalsea/marshal/boundary_detector"
13
+ require_relative "marshalsea/marshal/load_guard"
14
+ require_relative "marshalsea/psych/inspector"
15
+ require_relative "marshalsea/scanner"
16
+ require_relative "marshalsea/chains"
17
+
18
+ module Marshalsea
19
+ end