rigortype 0.4.2 → 0.4.4
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/README.md +1 -1
- data/data/core_overlay/ostruct.rbs +40 -0
- data/docs/handbook/11-sig-gen.md +28 -5
- data/docs/manual/02-cli-reference.md +23 -4
- data/docs/manual/03-configuration.md +21 -8
- data/docs/manual/04-diagnostics.md +44 -2
- data/docs/manual/05-inspecting-types.md +50 -0
- data/docs/manual/07-plugins.md +1 -1
- data/docs/manual/10-mcp-server.md +32 -3
- data/docs/manual/12-caching.md +18 -1
- data/docs/manual/14-rails-quickstart.md +2 -2
- data/docs/manual/18-removing-dead-code.md +26 -9
- data/docs/manual/plugins/rigor-rails-routes.md +2 -2
- data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
- data/exe/rigor +12 -17
- data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
- data/lib/rigor/analysis/check_rules.rb +68 -57
- data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
- data/lib/rigor/analysis/incremental.rb +12 -0
- data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
- data/lib/rigor/analysis/incremental_session.rb +207 -40
- data/lib/rigor/analysis/path_expansion.rb +62 -6
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
- data/lib/rigor/analysis/reachability/graph.rb +139 -15
- data/lib/rigor/analysis/reachability/scan.rb +374 -173
- data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
- data/lib/rigor/analysis/rule_catalog.rb +6 -1
- data/lib/rigor/analysis/run_cache_key.rb +31 -7
- data/lib/rigor/analysis/run_cache_probe.rb +6 -4
- data/lib/rigor/analysis/run_stats.rb +28 -6
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
- data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
- data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
- data/lib/rigor/analysis/runner.rb +62 -18
- data/lib/rigor/analysis/severity_stamp.rb +34 -1
- data/lib/rigor/analysis/worker_session.rb +58 -2
- data/lib/rigor/cache/descriptor.rb +40 -6
- data/lib/rigor/cache/engine_source.rb +21 -7
- data/lib/rigor/cache/incremental_snapshot.rb +56 -12
- data/lib/rigor/cli/check_command.rb +138 -39
- data/lib/rigor/cli/docs_command.rb +5 -4
- data/lib/rigor/cli/lens_command.rb +144 -0
- data/lib/rigor/cli/lsp_command.rb +5 -2
- data/lib/rigor/cli/mcp_command.rb +1 -1
- data/lib/rigor/cli/skill_command.rb +3 -3
- data/lib/rigor/cli/skill_describe.rb +1 -1
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/unused_command.rb +157 -24
- data/lib/rigor/cli.rb +30 -4
- data/lib/rigor/config_audit.rb +111 -15
- data/lib/rigor/configuration/dependencies.rb +19 -11
- data/lib/rigor/configuration.rb +15 -0
- data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
- data/lib/rigor/environment/installed_gem_set.rb +38 -11
- data/lib/rigor/environment/lockfile_resolver.rb +37 -6
- data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
- data/lib/rigor/environment/rbs_loader.rb +79 -10
- data/lib/rigor/environment/required_features.rb +4 -2
- data/lib/rigor/environment.rb +12 -5
- data/lib/rigor/inference/block_call_timing.rb +5 -0
- data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
- data/lib/rigor/inference/captured_locals.rb +69 -6
- data/lib/rigor/inference/definer_resolution.rb +16 -7
- data/lib/rigor/inference/expression_typer.rb +117 -29
- data/lib/rigor/inference/guard_rebinding.rb +7 -2
- data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
- data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
- data/lib/rigor/inference/match_rebinding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
- data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
- data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
- data/lib/rigor/inference/method_dispatcher.rb +52 -3
- data/lib/rigor/inference/module_function_state.rb +80 -13
- data/lib/rigor/inference/narrowing.rb +184 -68
- data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
- data/lib/rigor/inference/project_patched_methods.rb +2 -2
- data/lib/rigor/inference/project_patched_scanner.rb +38 -24
- data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
- data/lib/rigor/inference/refined_dispatch.rb +62 -14
- data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
- data/lib/rigor/inference/repeated_or_writes.rb +416 -1
- data/lib/rigor/inference/scope_indexer.rb +565 -61
- data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
- data/lib/rigor/inference/statement_evaluator.rb +307 -60
- data/lib/rigor/inference/untyped_rejoin.rb +75 -0
- data/lib/rigor/language_server/project_context.rb +56 -5
- data/lib/rigor/lens/anchor.rb +115 -0
- data/lib/rigor/lens/contract_reader.rb +133 -0
- data/lib/rigor/lens/document.rb +113 -0
- data/lib/rigor/lens/file_reader.rb +802 -0
- data/lib/rigor/lens/members.rb +203 -0
- data/lib/rigor/lens/parameters.rb +91 -0
- data/lib/rigor/lens/plugins.rb +40 -0
- data/lib/rigor/lens/run.rb +322 -0
- data/lib/rigor/lens/slot.rb +79 -0
- data/lib/rigor/lens.rb +41 -0
- data/lib/rigor/mcp/server.rb +61 -20
- data/lib/rigor/plugin/box_probe.rb +154 -7
- data/lib/rigor/project_environment.rb +23 -1
- data/lib/rigor/protection/mutation_cache.rb +2 -1
- data/lib/rigor/scope.rb +33 -17
- data/lib/rigor/sig_gen/generator.rb +45 -5
- data/lib/rigor/sig_gen/layout_index.rb +36 -0
- data/lib/rigor/sig_gen/method_candidate.rb +8 -0
- data/lib/rigor/sig_gen/path_mapper.rb +18 -2
- data/lib/rigor/sig_gen/writer.rb +47 -34
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
- data/sig/rigor/scope.rbs +3 -0
- data/sig/rigor.rbs +6 -3
- data/skills/rigor-project-init/references/02-configure.md +2 -2
- metadata +21 -1
|
@@ -20,6 +20,8 @@ module Rigor
|
|
|
20
20
|
# `cache = {}; [1, 2].find { |e| (cache[:first] ||= e) == 2 }` answered its own `2`, where Ruby keeps the
|
|
21
21
|
# first iteration's `1`, folded `find` to `2` and reported `found == 2` always-truthy. The pass marks the
|
|
22
22
|
# sites this module returns (`Scope#with_repeated_or_writes`), and the reading is withheld at a marked site.
|
|
23
|
+
# `StatementEvaluator` types a repeating body from one entry scope too — its statement pass and every ADR-56
|
|
24
|
+
# write-back pass — and marks the generic pass's sites there (issue #1336).
|
|
23
25
|
#
|
|
24
26
|
# The mark is per site, not per receiver, because nothing about the receiver's type can say that its slot
|
|
25
27
|
# was filled: `{}` widens to `Hash[Dynamic[top], Dynamic[top]]`, a bare `Hash.new` is not widened at all, and
|
|
@@ -149,10 +151,17 @@ module Rigor
|
|
|
149
151
|
positional_marks(block, found, stores, scope, element_types)
|
|
150
152
|
end
|
|
151
153
|
|
|
154
|
+
# {StraightLine.sites}: the sites of a straight-line body that an earlier store in it may have filled, with the
|
|
155
|
+
# root's kind read from `scope`, the body's entry scope.
|
|
156
|
+
def straight_line_sites(body, scope) = StraightLine.sites(body, scope)
|
|
157
|
+
|
|
152
158
|
# One source slice is far cheaper than the walk, and a body that spells no `||=` holds no such site. A
|
|
153
|
-
# heredoc's text lies outside its node's location, so a body that opens one is walked regardless.
|
|
159
|
+
# heredoc's text lies outside its node's location, so a body that opens one is walked regardless. A slice that
|
|
160
|
+
# is not valid in its encoding (an invalid byte in a comment) cannot be regex-matched, so it is walked too.
|
|
154
161
|
def may_hold_or_write?(body)
|
|
155
162
|
source = body.slice
|
|
163
|
+
return true unless source.valid_encoding?
|
|
164
|
+
|
|
156
165
|
source.include?("||=") || source.match?(Forms::HEREDOC_OPENER)
|
|
157
166
|
end
|
|
158
167
|
|
|
@@ -371,6 +380,412 @@ module Rigor
|
|
|
371
380
|
found.installs.none? { |(other_root, other_depth)| other_root == root && other_depth < depth }
|
|
372
381
|
end
|
|
373
382
|
|
|
383
|
+
# The index `||=` sites of a straight-line body — a method body, or a file's top level — whose slot a store that
|
|
384
|
+
# comes EARLIER in the body may have filled (issue #1885). A `class` / `module` body is typed from a scope of its
|
|
385
|
+
# own, which the top level's marks do not reach, so its sites are never marked; its stores are still walked, since
|
|
386
|
+
# one into a constant or a global reaches a later top-level site. Whether that store ran is not something the
|
|
387
|
+
# reading at the later site can see: a conditional store's slot narrowing does not survive the join with the arm
|
|
388
|
+
# that skipped it, and a store in a block (`tap`, `then`) leaves none behind, so the later site's `[]` read was a
|
|
389
|
+
# lone `Dynamic` again and the memoization reading answered its own rvalue —
|
|
390
|
+
# `c[:k] ||= :e if cond; (c[:k] ||= :f)` typed `:f` where Ruby answers `:e` when `cond` held. Only a store
|
|
391
|
+
# through the site's own variable counts ({RepeatedOrWrites.receiver_path}); an alias (`d = c`), a block
|
|
392
|
+
# parameter and a helper method's store are not seen, which is the cost the memoization reading already
|
|
393
|
+
# accepts. A store reaches the site's slot when it is
|
|
394
|
+
#
|
|
395
|
+
# - at the site's depth, with a key that may name the same slot (a literal that `eql?`s the site's, or one that is
|
|
396
|
+
# no literal; a mutator such as `merge!` or `insert` writes any slot);
|
|
397
|
+
# - above it, installing anything but an empty `{}` / `[]` / `Hash.new` / `Array.new` (`c[:a] = { k: :e }` above
|
|
398
|
+
# `c[:a][:k] ||= :f`);
|
|
399
|
+
# - below it, through the site's slot (`c[:a][:k] = 1` cannot run unless `c[:a]` holds an object).
|
|
400
|
+
#
|
|
401
|
+
# Two relations reach the slot only when the root object is not a `Hash` ({Analysis#unless_hash}): a store that
|
|
402
|
+
# only removes or reorders (`delete`, `shift`, `compact!`), which on an `Array` moves an element into the slot,
|
|
403
|
+
# and a literal key that differs from the site's but that an `Array` reads as the same index or an unknown `[]`
|
|
404
|
+
# may normalise (`:k` and `"k"`, `1` and `1.0`, `-1` and `2`). The statement evaluator and the user-method return
|
|
405
|
+
# re-walk lay the sites at the body's entry (`Scope#with_repeated_or_writes`), the same mark the repeating-block
|
|
406
|
+
# passes lay, deciding the root's kind from the entry scope and the body's writes.
|
|
407
|
+
module StraightLine
|
|
408
|
+
# `marked` is marked whatever the root is; `unless_hash` maps a site to the root the entry scope decides on;
|
|
409
|
+
# `root_writes` maps each root the body writes to `:hash` when every write stores a new `Hash`, else `:other`.
|
|
410
|
+
Analysis = Data.define(:marked, :unless_hash, :root_writes)
|
|
411
|
+
NOTHING = Analysis.new(marked: NO_SITES, unless_hash: {}.compare_by_identity.freeze, root_writes: {}.freeze)
|
|
412
|
+
|
|
413
|
+
# The stores at one `[root, depth]`, or the deeper stores through one of its slots. `by_key` groups the ones
|
|
414
|
+
# with a literal key by that key (`eql?`, as a `Hash` compares), and `by_tag` the same groups by
|
|
415
|
+
# {.key_tag}; `wild` holds the ones with no literal key, `removers` the remove-or-reorder calls, and
|
|
416
|
+
# `installers` the ones that may put a non-empty object into a slot of this object.
|
|
417
|
+
Bucket = Struct.new(:by_key, :by_tag, :wild, :removers, :installers) do
|
|
418
|
+
def self.fresh = new({}, Hash.new { |table, tag| table[tag] = [] }, [], [], [])
|
|
419
|
+
|
|
420
|
+
def add_keyed(value, store)
|
|
421
|
+
group = by_key[value]
|
|
422
|
+
unless group
|
|
423
|
+
group = by_key[value] = []
|
|
424
|
+
by_tag[StraightLine.key_tag(value)] << value
|
|
425
|
+
end
|
|
426
|
+
group << store
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# The tags under which two different literal keys name different slots of any receiver.
|
|
431
|
+
DISTINCT_TAGS = Set[:symbol, :string, :index].freeze
|
|
432
|
+
|
|
433
|
+
# The main Ractor's {.analysis} memo, keyed on the body node by identity; a pool worker computes it every
|
|
434
|
+
# time.
|
|
435
|
+
@memo = ObjectSpace::WeakMap.new
|
|
436
|
+
|
|
437
|
+
module_function
|
|
438
|
+
|
|
439
|
+
def sites(body, scope)
|
|
440
|
+
found = analysis(body)
|
|
441
|
+
return found.marked if found.unless_hash.empty?
|
|
442
|
+
|
|
443
|
+
found.marked + found.unless_hash.filter_map { |site, root| site unless hash_root?(root, found, scope) }
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def analysis(body)
|
|
447
|
+
return NOTHING unless body.is_a?(Prism::Node) && RepeatedOrWrites.may_hold_or_write?(body)
|
|
448
|
+
return compute(body) unless Ractor.main?
|
|
449
|
+
|
|
450
|
+
@memo[body] ||= compute(body)
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def compute(body)
|
|
454
|
+
walked = RepeatedOrWrites::Walk.new([], {}.compare_by_identity, [], Set.new, {})
|
|
455
|
+
RepeatedOrWrites.walk(body, true, walked)
|
|
456
|
+
return NOTHING if walked.or_writes.empty?
|
|
457
|
+
|
|
458
|
+
Scan.new(body, walked).run
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def key_tag(value)
|
|
462
|
+
case value
|
|
463
|
+
when Symbol then :symbol
|
|
464
|
+
when String then :string
|
|
465
|
+
when Integer then value.negative? ? :negative : :index
|
|
466
|
+
else :float
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def literal_key_value(node)
|
|
471
|
+
case node
|
|
472
|
+
when Prism::SymbolNode then node.unescaped.to_sym
|
|
473
|
+
when Prism::StringNode then node.unescaped
|
|
474
|
+
when Prism::IntegerNode, Prism::FloatNode then node.value
|
|
475
|
+
end
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
# Whether the entry scope and the body's own writes show the root is a `Hash`: a local the entry scope
|
|
479
|
+
# binds (a parameter) or an instance variable by its entry type, a local only the body binds by its writes;
|
|
480
|
+
# either way no write in the body may store anything else.
|
|
481
|
+
def hash_root?(root, found, scope)
|
|
482
|
+
writes = found.root_writes[root]
|
|
483
|
+
return false if writes == :other
|
|
484
|
+
|
|
485
|
+
kind, name = root
|
|
486
|
+
case kind
|
|
487
|
+
when :local then scope.locals.key?(name) ? hash_type?(scope.local(name)) : writes == :hash
|
|
488
|
+
when :ivar then hash_type?(scope.ivar(name))
|
|
489
|
+
else false
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def hash_type?(type)
|
|
494
|
+
case type
|
|
495
|
+
when Type::HashShape then true
|
|
496
|
+
when Type::Nominal then type.class_name == "Hash"
|
|
497
|
+
else false
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
# An expression that evaluates to a new core `Hash`.
|
|
502
|
+
def new_hash?(node)
|
|
503
|
+
case node
|
|
504
|
+
when Prism::HashNode then true
|
|
505
|
+
when Prism::CallNode then node.name == :new && constant_named?(node.receiver, :Hash)
|
|
506
|
+
else false
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
# An expression that evaluates to a new, empty `Hash` or `Array`, which holds no slot a later site reads.
|
|
511
|
+
def empty_fresh?(node)
|
|
512
|
+
case node
|
|
513
|
+
when Prism::HashNode, Prism::ArrayNode then node.elements.empty?
|
|
514
|
+
when Prism::CallNode
|
|
515
|
+
node.name == :new && node.arguments.nil? && node.block.nil? &&
|
|
516
|
+
(constant_named?(node.receiver, :Hash) || constant_named?(node.receiver, :Array))
|
|
517
|
+
else false
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def constant_named?(node, name)
|
|
522
|
+
case node
|
|
523
|
+
when Prism::ConstantReadNode then node.name == name
|
|
524
|
+
when Prism::ConstantPathNode then node.parent.nil? && node.name == name
|
|
525
|
+
else false
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
# One body's stores, bucketed by object so that each site compares only against the stores that can reach
|
|
530
|
+
# it: a site whose key is a literal looks up its own key's group, the stores with no literal key, and the
|
|
531
|
+
# groups of the tags that do not tell its key apart.
|
|
532
|
+
class Scan
|
|
533
|
+
def initialize(body, walked)
|
|
534
|
+
@body = body
|
|
535
|
+
@walked = walked
|
|
536
|
+
@buckets = Hash.new { |table, at| table[at] = Bucket.fresh }
|
|
537
|
+
@deeper = Hash.new { |table, at| table[at] = Bucket.fresh }
|
|
538
|
+
@parents = nil
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
def run
|
|
542
|
+
@walked.stores.each { |store| index(store) }
|
|
543
|
+
marked = []
|
|
544
|
+
unless_hash = {}.compare_by_identity
|
|
545
|
+
@walked.or_writes.each do |site|
|
|
546
|
+
case verdict(site)
|
|
547
|
+
when :marked then marked << site
|
|
548
|
+
when :unless_hash then unless_hash[site] = RepeatedOrWrites.receiver_path(site.receiver, 0).first
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
Analysis.new(marked: marked.freeze, unless_hash: unless_hash.freeze,
|
|
552
|
+
root_writes: unless_hash.empty? ? NOTHING.root_writes : root_writes)
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
private
|
|
556
|
+
|
|
557
|
+
def index(store)
|
|
558
|
+
return if store.path.nil?
|
|
559
|
+
|
|
560
|
+
root, depth = store.path
|
|
561
|
+
bucket = @buckets[store.path]
|
|
562
|
+
node = store.node
|
|
563
|
+
if node.is_a?(Prism::CallNode) && UnknownStoreWidening::VALUE_PRESERVING.include?(node.name)
|
|
564
|
+
bucket.removers << store
|
|
565
|
+
else
|
|
566
|
+
add(bucket, StraightLine.literal_key_value(store_key(node)), store)
|
|
567
|
+
bucket.installers << store unless empty_value?(node)
|
|
568
|
+
end
|
|
569
|
+
chain_keys(node.receiver).each_with_index do |key, at|
|
|
570
|
+
break if at >= depth
|
|
571
|
+
|
|
572
|
+
add(@deeper[[root, at]], StraightLine.literal_key_value(key), store)
|
|
573
|
+
end
|
|
574
|
+
end
|
|
575
|
+
|
|
576
|
+
def add(bucket, value, store)
|
|
577
|
+
value.nil? ? bucket.wild << store : bucket.add_keyed(value, store)
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
# The key a store writes, or nil when it may write any slot (a mutator such as `merge!` or `<<`).
|
|
581
|
+
def store_key(node)
|
|
582
|
+
return FreshKeys.sole_key(node) unless node.is_a?(Prism::CallNode)
|
|
583
|
+
|
|
584
|
+
arguments = node.arguments&.arguments
|
|
585
|
+
node.name == :[]= && arguments&.size == 2 ? arguments.first : nil
|
|
586
|
+
end
|
|
587
|
+
|
|
588
|
+
# Whether the store puts nothing a deeper site could read: an index write of an empty new collection.
|
|
589
|
+
def empty_value?(node)
|
|
590
|
+
value = case node
|
|
591
|
+
when Prism::CallNode
|
|
592
|
+
return false unless node.name == :[]=
|
|
593
|
+
|
|
594
|
+
node.arguments&.arguments&.last
|
|
595
|
+
when Prism::IndexTargetNode then nil
|
|
596
|
+
else node.value
|
|
597
|
+
end
|
|
598
|
+
!value.nil? && StraightLine.empty_fresh?(value)
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# The key of each element read between the store's root and its receiver, root first; nil for a key that is
|
|
602
|
+
# no single argument.
|
|
603
|
+
def chain_keys(node)
|
|
604
|
+
keys = []
|
|
605
|
+
loop do
|
|
606
|
+
case node
|
|
607
|
+
when Prism::ParenthesesNode
|
|
608
|
+
statements = node.body
|
|
609
|
+
break unless statements.is_a?(Prism::StatementsNode) && statements.body.size == 1
|
|
610
|
+
|
|
611
|
+
node = statements.body.first
|
|
612
|
+
when Prism::CallNode
|
|
613
|
+
break unless node.name == :[] && node.receiver
|
|
614
|
+
|
|
615
|
+
keys.unshift(FreshKeys.sole_key(node))
|
|
616
|
+
node = node.receiver
|
|
617
|
+
when *IndexWriteWidening::NODE_CLASSES
|
|
618
|
+
keys.unshift(FreshKeys.sole_key(node))
|
|
619
|
+
node = node.receiver
|
|
620
|
+
else break
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
keys
|
|
624
|
+
end
|
|
625
|
+
|
|
626
|
+
# `:marked`, `:unless_hash` or nil for one site. A literal key told apart only on a `Hash` is decided by the
|
|
627
|
+
# root's kind for a site on the root alone; a deeper object's kind is not known.
|
|
628
|
+
def verdict(site)
|
|
629
|
+
path = RepeatedOrWrites.receiver_path(site.receiver, 0)
|
|
630
|
+
return nil if path.nil?
|
|
631
|
+
|
|
632
|
+
same = at_depth(path, site)
|
|
633
|
+
return :marked if same == :marked || (same && path.last.positive?)
|
|
634
|
+
|
|
635
|
+
above = above_depth(path, site)
|
|
636
|
+
return :marked if above == :marked
|
|
637
|
+
|
|
638
|
+
:unless_hash if same || above
|
|
639
|
+
end
|
|
640
|
+
|
|
641
|
+
# The stores at the site's depth, and the deeper ones through its object: `:marked`, `:weak` (reaching
|
|
642
|
+
# unless the object is a `Hash`) or nil.
|
|
643
|
+
def at_depth(path, site)
|
|
644
|
+
value = StraightLine.literal_key_value(FreshKeys.sole_key(site))
|
|
645
|
+
weak = false
|
|
646
|
+
[@buckets.fetch(path, nil), @deeper.fetch(path, nil)].compact.each do |bucket|
|
|
647
|
+
strong, loose = candidates(bucket, value)
|
|
648
|
+
return :marked if reaches?(strong, site)
|
|
649
|
+
|
|
650
|
+
weak ||= reaches?(loose, site)
|
|
651
|
+
end
|
|
652
|
+
weak ? :weak : nil
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# The stores into a container above the site's object. A remover there moves objects between slots only
|
|
656
|
+
# on an `Array`, which the root's kind decides for the root alone.
|
|
657
|
+
def above_depth(path, site)
|
|
658
|
+
root, depth = path
|
|
659
|
+
weak = false
|
|
660
|
+
(0...depth).each do |level|
|
|
661
|
+
bucket = @buckets.fetch([root, level], nil) or next
|
|
662
|
+
return :marked if reaches?([bucket.installers], site)
|
|
663
|
+
next unless reaches?([bucket.removers], site)
|
|
664
|
+
return :marked unless level.zero?
|
|
665
|
+
|
|
666
|
+
weak = true
|
|
667
|
+
end
|
|
668
|
+
weak ? :weak : nil
|
|
669
|
+
end
|
|
670
|
+
|
|
671
|
+
# The store groups of `bucket` that reach a site keyed `value` on any receiver, and those that reach it
|
|
672
|
+
# unless the receiver is a `Hash`.
|
|
673
|
+
def candidates(bucket, value)
|
|
674
|
+
return [[bucket.wild, *bucket.by_key.values], [bucket.removers]] if value.nil?
|
|
675
|
+
|
|
676
|
+
strong = [bucket.wild]
|
|
677
|
+
own = bucket.by_key[value]
|
|
678
|
+
strong << own if own
|
|
679
|
+
tag = StraightLine.key_tag(value)
|
|
680
|
+
loose = [bucket.removers]
|
|
681
|
+
bucket.by_tag.each do |other_tag, values|
|
|
682
|
+
next if other_tag == tag && DISTINCT_TAGS.include?(tag)
|
|
683
|
+
|
|
684
|
+
values.each { |other| loose << bucket.by_key[other] unless other.eql?(value) }
|
|
685
|
+
end
|
|
686
|
+
[strong, loose]
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
# Whether a store in `groups` ends before `site` starts and may have run first. A store that encloses the
|
|
690
|
+
# site (`(c[:a] ||= []) << x` around `c[:a] ||= []`) stores after evaluating it, so it is not earlier.
|
|
691
|
+
def reaches?(groups, site)
|
|
692
|
+
start = site.location.start_offset
|
|
693
|
+
groups.any? do |stores|
|
|
694
|
+
stores.any? do |store|
|
|
695
|
+
node = store.node
|
|
696
|
+
node.location.end_offset <= start &&
|
|
697
|
+
!ExclusiveArms.exclusive?(node, site, @body, @parents ||= ExclusiveArms.parents(@body))
|
|
698
|
+
end
|
|
699
|
+
end
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# Each variable root the body writes, `:hash` when every write stores a new `Hash`. A nested `def` is skipped;
|
|
703
|
+
# a `class` / `module` body is not, so a same-named variable written there only makes the answer `:other`,
|
|
704
|
+
# the side that keeps the mark.
|
|
705
|
+
def root_writes
|
|
706
|
+
table = {}
|
|
707
|
+
collect_writes(@body, table)
|
|
708
|
+
table.freeze
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
def collect_writes(node, table)
|
|
712
|
+
return if node.is_a?(Prism::DefNode)
|
|
713
|
+
|
|
714
|
+
kind = Forms::VARIABLE_KINDS[node.class]
|
|
715
|
+
if kind && !Forms::READ_NODES.include?(node.class)
|
|
716
|
+
fresh_hash = Forms::VALUE_WRITE_NODES.include?(node.class) && StraightLine.new_hash?(node.value)
|
|
717
|
+
root = [kind, node.name]
|
|
718
|
+
table[root] = table.fetch(root, :hash) == :hash && fresh_hash ? :hash : :other
|
|
719
|
+
end
|
|
720
|
+
node.rigor_each_child { |child| collect_writes(child, table) }
|
|
721
|
+
end
|
|
722
|
+
end
|
|
723
|
+
|
|
724
|
+
# Whether a store and a later site sit in different arms of one `if` / `unless` (a ternary included), so that
|
|
725
|
+
# the store never runs before the site in the same run of the body. That holds when no loop or lambda between
|
|
726
|
+
# that conditional and the body runs both arms in turn. For a receiver that outlives the call — an instance
|
|
727
|
+
# variable, a constant, a global — an earlier call of the same method can have run the other arm; the exemption
|
|
728
|
+
# follows the memoization reading's existing optimism about what other calls stored. A block is not counted: one
|
|
729
|
+
# its call may run more than once is the repeating-body marks' case ({.sites}), and one that runs at most once
|
|
730
|
+
# (`tap`) repeats nothing. Any other relation — a `case` (whose `when` conditions run before a later arm),
|
|
731
|
+
# `&&` / `||`, a `begin` and its `rescue` — counts as one where the store may have run first.
|
|
732
|
+
module ExclusiveArms
|
|
733
|
+
LOOP_NODES = Set[Prism::LambdaNode, Prism::WhileNode, Prism::UntilNode, Prism::ForNode].freeze
|
|
734
|
+
|
|
735
|
+
module_function
|
|
736
|
+
|
|
737
|
+
# The parent of every node under `body`, by identity.
|
|
738
|
+
def parents(body)
|
|
739
|
+
table = {}.compare_by_identity
|
|
740
|
+
pending = [body]
|
|
741
|
+
until pending.empty?
|
|
742
|
+
node = pending.pop
|
|
743
|
+
node.rigor_each_child do |child|
|
|
744
|
+
table[child] = node
|
|
745
|
+
pending << child
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
table
|
|
749
|
+
end
|
|
750
|
+
|
|
751
|
+
def exclusive?(store, site, body, parents)
|
|
752
|
+
chain = ancestors(store, body, parents)
|
|
753
|
+
child = site
|
|
754
|
+
node = parents[site]
|
|
755
|
+
until node.nil? || chain.key?(node)
|
|
756
|
+
child = node
|
|
757
|
+
node = parents[node]
|
|
758
|
+
end
|
|
759
|
+
return false if node.nil? || !different_arms?(node, chain[node], child)
|
|
760
|
+
|
|
761
|
+
ancestors(node, body, parents).each_key.none? { |above| LOOP_NODES.include?(above.class) }
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
# `node` and its ancestors up to `body`, each mapped to the child the walk came up through.
|
|
765
|
+
def ancestors(node, body, parents)
|
|
766
|
+
chain = {}.compare_by_identity
|
|
767
|
+
child = nil
|
|
768
|
+
until node.nil?
|
|
769
|
+
chain[node] = child
|
|
770
|
+
break if node.equal?(body)
|
|
771
|
+
|
|
772
|
+
child = node
|
|
773
|
+
node = parents[node]
|
|
774
|
+
end
|
|
775
|
+
chain
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
def different_arms?(node, left, right)
|
|
779
|
+
arms = case node
|
|
780
|
+
when Prism::IfNode then [node.statements, node.subsequent]
|
|
781
|
+
when Prism::UnlessNode then [node.statements, node.else_clause]
|
|
782
|
+
else return false
|
|
783
|
+
end
|
|
784
|
+
[left, right].all? { |arm| arms.any? { |candidate| candidate.equal?(arm) } }
|
|
785
|
+
end
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
|
|
374
789
|
# Whether an index `||=` site's key differs, position by position, from every earlier position's key under
|
|
375
790
|
# the per-element fold.
|
|
376
791
|
module FreshKeys
|