rigortype 0.3.5 → 0.3.7
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 +5 -5
- data/data/builtins/ruby_core/date.yml +6 -3
- data/data/builtins/ruby_core/enumerable.yml +1 -0
- data/data/builtins/ruby_core/exception.yml +2 -1
- data/data/builtins/ruby_core/file.yml +28 -14
- data/data/builtins/ruby_core/hash.yml +5 -2
- data/data/builtins/ruby_core/io.yml +33 -16
- data/data/builtins/ruby_core/random.yml +2 -1
- data/data/builtins/ruby_core/re.yml +6 -3
- data/data/builtins/ruby_core/struct.yml +2 -0
- data/data/builtins/ruby_core/time.yml +2 -1
- data/data/gem_overlay/activesupport/core_ext.rbs +442 -11
- data/docs/handbook/02-everyday-types.md +1 -1
- data/docs/handbook/04-tuples-and-shapes.md +1 -1
- data/docs/handbook/08-understanding-errors.md +1 -1
- data/docs/handbook/09-plugins.md +2 -2
- data/docs/handbook/10-sorbet.md +1 -1
- data/docs/handbook/README.md +2 -2
- data/docs/handbook/appendix-go.md +1 -1
- data/docs/handbook/appendix-java-csharp.md +2 -2
- data/docs/handbook/appendix-mypy.md +1 -1
- data/docs/handbook/appendix-protocols-and-structural-typing.md +2 -2
- data/docs/handbook/appendix-rust.md +1 -1
- data/docs/handbook/appendix-type-theory.md +2 -2
- data/docs/handbook/appendix-typescript.md +4 -4
- data/docs/manual/02-cli-reference.md +29 -16
- data/docs/manual/03-configuration.md +1 -1
- data/docs/manual/04-diagnostics.md +12 -0
- data/docs/manual/05-inspecting-types.md +20 -8
- data/docs/manual/07-plugins.md +4 -0
- data/docs/manual/08-skills.md +1 -1
- data/docs/manual/11-ci.md +1 -1
- data/docs/manual/12-caching.md +1 -1
- data/docs/manual/15-type-protection-coverage.md +1 -1
- data/docs/manual/18-removing-dead-code.md +13 -4
- data/docs/manual/19-effect-labels.md +19 -12
- data/docs/manual/README.md +2 -2
- data/docs/manual/plugins/rigor-actionmailer.md +4 -4
- data/docs/manual/plugins/rigor-actionpack.md +61 -0
- data/docs/manual/plugins/rigor-activejob.md +3 -3
- data/docs/manual/plugins/rigor-activerecord.md +78 -5
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
- data/docs/manual/plugins/rigor-rails-i18n.md +5 -5
- data/docs/manual/plugins/rigor-rspec.md +6 -2
- data/docs/manual/plugins/rigor-sidekiq.md +22 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
- data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
- data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
- data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
- data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
- data/lib/rigor/analysis/check_rules.rb +383 -48
- data/lib/rigor/analysis/crash_signature.rb +116 -0
- data/lib/rigor/analysis/dependency_recorder.rb +18 -3
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/diagnostic.rb +17 -6
- data/lib/rigor/analysis/effects_cache_probe.rb +132 -0
- data/lib/rigor/analysis/incremental.rb +28 -0
- data/lib/rigor/analysis/incremental_session.rb +44 -9
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
- data/lib/rigor/analysis/reachability/graph.rb +44 -16
- data/lib/rigor/analysis/reachability/plugin_roots.rb +11 -8
- data/lib/rigor/analysis/reachability/scan.rb +27 -7
- data/lib/rigor/analysis/reachability/scan_cache.rb +130 -0
- data/lib/rigor/analysis/result.rb +32 -0
- data/lib/rigor/analysis/run_cache_key.rb +53 -1
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
- data/lib/rigor/analysis/runner/effect_envelope_pass.rb +14 -8
- data/lib/rigor/analysis/runner/pool_coordinator.rb +34 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
- data/lib/rigor/analysis/runner/run_snapshots.rb +7 -2
- data/lib/rigor/analysis/runner.rb +298 -38
- data/lib/rigor/analysis/worker_session.rb +10 -1
- data/lib/rigor/bleeding_edge.rb +7 -6
- data/lib/rigor/builtins/hkt_builtins.rb +45 -6
- data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
- data/lib/rigor/builtins/static_return_refinements.rb +20 -1
- data/lib/rigor/cache/descriptor.rb +42 -2
- data/lib/rigor/cache/file_digest.rb +20 -2
- data/lib/rigor/cache/incremental_snapshot.rb +40 -3
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +31 -8
- data/lib/rigor/cache/store.rb +10 -1
- data/lib/rigor/cli/check_command.rb +67 -48
- data/lib/rigor/cli/coverage_command.rb +28 -24
- data/lib/rigor/cli/coverage_mutation.rb +13 -17
- data/lib/rigor/cli/coverage_scan.rb +47 -6
- data/lib/rigor/cli/doc_links.rb +100 -0
- data/lib/rigor/cli/docs_command.rb +32 -2
- data/lib/rigor/cli/effects_command.rb +27 -2
- data/lib/rigor/cli/effects_diff_renderer.rb +82 -12
- data/lib/rigor/cli/effects_explain_renderer.rb +6 -3
- data/lib/rigor/cli/effects_snapshot_command.rb +52 -7
- data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
- data/lib/rigor/cli/fused_protection_report.rb +23 -2
- data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
- data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
- data/lib/rigor/cli/mutation_protection_report.rb +31 -2
- data/lib/rigor/cli/sig_gen_command.rb +18 -0
- data/lib/rigor/cli/type_of_command.rb +188 -41
- data/lib/rigor/cli/type_of_renderer.rb +72 -3
- data/lib/rigor/cli/type_scan_command.rb +10 -1
- data/lib/rigor/cli/unused_command.rb +65 -16
- data/lib/rigor/cli.rb +22 -9
- data/lib/rigor/configuration.rb +7 -4
- data/lib/rigor/effects/definition_lines.rb +100 -0
- data/lib/rigor/effects/envelope_check.rb +18 -1
- data/lib/rigor/effects/liskov_check.rb +17 -8
- data/lib/rigor/effects/plugin_facts.rb +1 -1
- data/lib/rigor/effects/scanner.rb +2 -3
- data/lib/rigor/effects/signature_sources.rb +13 -2
- data/lib/rigor/effects/snapshot.rb +53 -21
- data/lib/rigor/effects/snapshot_diff.rb +26 -3
- data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
- data/lib/rigor/environment/lockfile_resolver.rb +15 -5
- data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
- data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
- data/lib/rigor/environment/rbs_loader.rb +525 -46
- data/lib/rigor/environment.rb +38 -15
- data/lib/rigor/inference/acceptance.rb +172 -20
- data/lib/rigor/inference/captured_locals.rb +62 -0
- data/lib/rigor/inference/content_join.rb +347 -0
- data/lib/rigor/inference/def_handle.rb +15 -3
- data/lib/rigor/inference/def_node_resolver.rb +62 -3
- data/lib/rigor/inference/expression_typer.rb +1020 -141
- data/lib/rigor/inference/fork_map.rb +6 -1
- data/lib/rigor/inference/hkt_reducer.rb +12 -1
- data/lib/rigor/inference/hkt_registry.rb +46 -0
- data/lib/rigor/inference/hkt_sugar_translator.rb +93 -0
- data/lib/rigor/inference/index_write_widening.rb +48 -0
- data/lib/rigor/inference/indexed_narrowing.rb +14 -0
- data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
- data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
- data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
- data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
- data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
- data/lib/rigor/inference/method_dispatcher.rb +155 -14
- data/lib/rigor/inference/method_parameter_binder.rb +3 -3
- data/lib/rigor/inference/mutation_widening.rb +244 -174
- data/lib/rigor/inference/narrowing.rb +331 -27
- data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
- data/lib/rigor/inference/pre_eval_constants.rb +15 -0
- data/lib/rigor/inference/precision_scanner.rb +5 -2
- data/lib/rigor/inference/project_patched_scanner.rb +2 -1
- data/lib/rigor/inference/rbs_type_translator.rb +89 -39
- data/lib/rigor/inference/scope_indexer.rb +1493 -140
- data/lib/rigor/inference/statement_evaluator.rb +276 -91
- data/lib/rigor/inference/struct_fold_safety.rb +186 -27
- data/lib/rigor/inference/synthetic_method_scanner.rb +7 -0
- data/lib/rigor/inference/version_guard.rb +229 -0
- data/lib/rigor/language_server/selection_range_provider.rb +1 -1
- data/lib/rigor/plugin/base.rb +3 -2
- data/lib/rigor/plugin/inflector.rb +14 -5
- data/lib/rigor/plugin/io_boundary.rb +107 -4
- data/lib/rigor/plugin/loader.rb +14 -0
- data/lib/rigor/protection/analysis_guard.rb +60 -0
- data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
- data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
- data/lib/rigor/protection/discovery_seed.rb +1 -1
- data/lib/rigor/protection/measurement_integrity.rb +36 -0
- data/lib/rigor/protection/mutation_scanner.rb +61 -6
- data/lib/rigor/protection/mutator.rb +121 -18
- data/lib/rigor/rbs_extended/envelope_scanner.rb +9 -0
- data/lib/rigor/reflection.rb +182 -30
- data/lib/rigor/scope/discovery_index.rb +39 -1
- data/lib/rigor/scope.rb +385 -4
- data/lib/rigor/sig_gen/classification.rb +12 -1
- data/lib/rigor/sig_gen/generator.rb +254 -5
- data/lib/rigor/sig_gen/renderer.rb +1 -6
- data/lib/rigor/sig_gen/writer.rb +3 -0
- data/lib/rigor/source/constant_path.rb +79 -2
- data/lib/rigor/triage/catalogue.rb +1 -1
- data/lib/rigor/type/combinator.rb +10 -0
- data/lib/rigor/type/maybe.rb +47 -0
- data/lib/rigor/type/refined.rb +1 -2
- data/lib/rigor/type/result.rb +53 -0
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
- data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
- data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
- data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
- data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
- data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
- data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
- data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
- data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
- data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
- data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
- data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
- data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
- data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
- data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
- data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
- data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
- data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
- data/sig/rigor/analysis/baseline.rbs +1 -1
- data/sig/rigor/environment.rbs +3 -0
- data/sig/rigor/inference.rbs +4 -1
- data/sig/rigor/plugin/io_boundary.rbs +2 -0
- data/sig/rigor/reflection.rbs +3 -1
- data/sig/rigor/scope.rbs +25 -1
- data/sig/rigor/type.rbs +95 -1
- data/sig/rigor.rbs +16 -0
- data/skills/rigor-ci-setup/SKILL.md +2 -2
- data/skills/rigor-editor-setup/SKILL.md +2 -2
- data/skills/rigor-mcp-setup/SKILL.md +2 -2
- data/skills/rigor-monkeypatch-resolve/SKILL.md +1 -1
- data/skills/rigor-plugin-review/SKILL.md +3 -3
- metadata +73 -3
|
@@ -15,8 +15,16 @@ module Rigor
|
|
|
15
15
|
# The analysis is a conservative ALLOW-LIST, not a deny-list: a local is fold-safe only when *every* read of it is
|
|
16
16
|
# the receiver of a known-pure read call. Anything the scan does not recognise as a pure read — a setter, an `[]=` /
|
|
17
17
|
# operator-write, an argument / alias / container store / return (escape), or an unknown method call (which could
|
|
18
|
-
# mutate `self` internally) — disqualifies the local.
|
|
19
|
-
#
|
|
18
|
+
# mutate `self` internally) — disqualifies the local.
|
|
19
|
+
#
|
|
20
|
+
# A missed case is USUALLY only over-conservative (no fold), but the direction is not guaranteed, and an earlier
|
|
21
|
+
# version of this header claimed it was. The counting identity below is about the LOCAL; it says nothing about what
|
|
22
|
+
# happens to a member read's RESULT. `s.x << v` mutates the container `s.x` returns while `s.x` itself is a
|
|
23
|
+
# textbook pure read, so the identity held and the local stayed fold-safe while its member's VALUE changed
|
|
24
|
+
# underneath — `s = Point.new([5]); s.x << "a"; s.x.last` folded to `5` and drew `undefined method 'upcase' for 5`
|
|
25
|
+
# on correct code. That is why {#count_uses} disqualifies a local whose member-read result is itself a receiver
|
|
26
|
+
# (see there). Any future extension must ask the same question: does this shape reach the member's value through
|
|
27
|
+
# something other than the local?
|
|
20
28
|
#
|
|
21
29
|
# Soundness rests on a counting identity: a local `n` is fold-safe iff *every* `LocalVariableReadNode(n)` is the
|
|
22
30
|
# receiver of a pure-read call. Equivalently `total_reads(n) == pure_receiver_reads(n)`. Any other occurrence — `n`
|
|
@@ -59,10 +67,12 @@ module Rigor
|
|
|
59
67
|
collect_struct_locals(root, layout_lookup, members, writes)
|
|
60
68
|
return EMPTY if members.empty?
|
|
61
69
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
tally = Tally.new(total: Hash.new(0), safe_uses: Hash.new(0), deferred_setter: {}, chained: {})
|
|
71
|
+
count_uses(root, members, tally, false)
|
|
72
|
+
total = tally.total
|
|
73
|
+
safe_uses = tally.safe_uses
|
|
74
|
+
deferred_setter = tally.deferred_setter
|
|
75
|
+
chained = tally.chained
|
|
66
76
|
|
|
67
77
|
# A local is fold-safe iff every read of it is a safe use — a pure read OR (ADR-48 slice 4) a
|
|
68
78
|
# straight-line member setter (`n.x = v`) that the setter write-back re-types the binding for.
|
|
@@ -71,7 +81,7 @@ module Rigor
|
|
|
71
81
|
# stale binding for the fold to read).
|
|
72
82
|
safe = members.each_key.select do |name|
|
|
73
83
|
writes[name] == 1 && total[name].positive? &&
|
|
74
|
-
total[name] == safe_uses[name] && !deferred_setter[name]
|
|
84
|
+
total[name] == safe_uses[name] && !deferred_setter[name] && !chained[name]
|
|
75
85
|
end
|
|
76
86
|
safe.empty? ? EMPTY : safe.to_set
|
|
77
87
|
end
|
|
@@ -95,30 +105,63 @@ module Rigor
|
|
|
95
105
|
# Pass 2 — count, per recorded struct local, total reads vs. SAFE-use reads (the receiver of a pure-read call,
|
|
96
106
|
# or of a straight-line member setter). `deferred` is true inside a loop / block / lambda; a member setter seen
|
|
97
107
|
# there marks the local's `deferred_setter` so it is excluded (its write-back cannot be modelled statically).
|
|
98
|
-
|
|
99
|
-
|
|
108
|
+
# The four per-local accumulators pass 2 fills, bundled so the walk stays one recursive call rather than a
|
|
109
|
+
# seven-argument one.
|
|
110
|
+
Tally = Struct.new(:total, :safe_uses, :deferred_setter, :chained, keyword_init: true)
|
|
100
111
|
|
|
101
|
-
|
|
112
|
+
def count_uses(node, members, tally, deferred)
|
|
113
|
+
return if node.nil?
|
|
102
114
|
|
|
103
|
-
if node.is_a?(Prism::
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
member_set = members[receiver.name]
|
|
107
|
-
if pure_read_call?(node, member_set)
|
|
108
|
-
safe_uses[receiver.name] += 1
|
|
109
|
-
elsif member_setter_call?(node, member_set)
|
|
110
|
-
safe_uses[receiver.name] += 1
|
|
111
|
-
deferred_setter[receiver.name] = true if deferred
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
+
tally.total[node.name] += 1 if node.is_a?(Prism::LocalVariableReadNode) && members.key?(node.name)
|
|
116
|
+
chained_local_of(node, members)&.then { |name| tally.chained[name] = true }
|
|
117
|
+
count_receiver_use(node, members, tally, deferred)
|
|
115
118
|
|
|
116
119
|
child_deferred = deferred || deferred_boundary?(node)
|
|
117
120
|
each_local_scope_child(node) do |child|
|
|
118
|
-
count_uses(child, members,
|
|
121
|
+
count_uses(child, members, tally, child_deferred)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The `<local>.<call>` arm: a pure read or a member setter counts as a SAFE use of the local, and a setter
|
|
126
|
+
# seen inside a loop / block / lambda additionally marks `deferred_setter`.
|
|
127
|
+
def count_receiver_use(node, members, tally, deferred)
|
|
128
|
+
return unless node.is_a?(Prism::CallNode)
|
|
129
|
+
|
|
130
|
+
receiver = node.receiver
|
|
131
|
+
return unless receiver.is_a?(Prism::LocalVariableReadNode) && members.key?(receiver.name)
|
|
132
|
+
|
|
133
|
+
member_set = members[receiver.name]
|
|
134
|
+
if pure_read_call?(node, member_set)
|
|
135
|
+
tally.safe_uses[receiver.name] += 1
|
|
136
|
+
elsif member_setter_call?(node, member_set)
|
|
137
|
+
tally.safe_uses[receiver.name] += 1
|
|
138
|
+
tally.deferred_setter[receiver.name] = true if deferred
|
|
119
139
|
end
|
|
120
140
|
end
|
|
121
141
|
|
|
142
|
+
# The tracked local behind `<local>.<member>` when THAT read is itself the receiver of a further call —
|
|
143
|
+
# `s.x << v`, `s.x.push(v)`, `row.cells.size`. The counting identity above cannot see these: `s.x` is a
|
|
144
|
+
# pure read of `s` by every measure it applies, and the mutation lands on the object `s.x` RETURNS. The
|
|
145
|
+
# local stays fold-safe, the fold serves the materialisation value, and a correct program is told its
|
|
146
|
+
# accumulated container still holds only what it was built with.
|
|
147
|
+
#
|
|
148
|
+
# Any call on the result disqualifies, with no allow-list of its own. `s.x.to_s` is provably harmless and
|
|
149
|
+
# loses precision here for nothing — but an allow-list is what produced this bug in the first place, and
|
|
150
|
+
# the failure mode of being too broad is a `Dynamic[top]` where a constant would do. Reads that do NOT
|
|
151
|
+
# chain (`dump_type(p.x)`, `assert_type("1", stored.foo)`, a bare `s.x`) are arguments or values, not
|
|
152
|
+
# receivers, and keep folding.
|
|
153
|
+
def chained_local_of(node, members)
|
|
154
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
155
|
+
|
|
156
|
+
inner = node.receiver
|
|
157
|
+
return nil unless inner.is_a?(Prism::CallNode)
|
|
158
|
+
|
|
159
|
+
local = inner.receiver
|
|
160
|
+
return nil unless local.is_a?(Prism::LocalVariableReadNode) && members.key?(local.name)
|
|
161
|
+
|
|
162
|
+
local.name
|
|
163
|
+
end
|
|
164
|
+
|
|
122
165
|
# A call is a pure read of the receiver when its name is a fixed Struct read or one of the receiver's member
|
|
123
166
|
# readers. Setters (`:x=`), `:[]=`, and any unknown method are excluded.
|
|
124
167
|
def pure_read_call?(call_node, member_set)
|
|
@@ -137,9 +180,123 @@ module Rigor
|
|
|
137
180
|
(call_node.arguments&.arguments&.size || 0) == 1
|
|
138
181
|
end
|
|
139
182
|
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
183
|
+
# Self-calls that cannot change a member, on top of the receiver's own member READERS. Same list the
|
|
184
|
+
# stored-local scan trusts, so the two halves of the gate cannot drift on what "pure" means. `:class`
|
|
185
|
+
# is added because `self.class.new(...)` is the ordinary way a struct method builds a sibling.
|
|
186
|
+
SELF_PURE_READS = (FIXED_READS + %i[class]).freeze
|
|
187
|
+
|
|
188
|
+
# Issue #525 — whether a method body may be granted the `:self` fold-safety sentinel, i.e. whether the
|
|
189
|
+
# caller's member map is still the struct's state at every member read the body performs.
|
|
190
|
+
#
|
|
191
|
+
# Three shapes refuse, and the third is what makes the grant closed under the calls the body makes:
|
|
192
|
+
#
|
|
193
|
+
# 1. a member setter or `[]=` on self (`self.text = v`) — a later read would fold the STALE
|
|
194
|
+
# construction value, the wrong-type family this gate exists to prevent;
|
|
195
|
+
# 2. a bare `self` anywhere but as a call receiver — passed, stored or returned, `self` reaches code
|
|
196
|
+
# that can mutate it;
|
|
197
|
+
# 3. any other self-call that is not a member reader or a fixed pure read. `def shout; reset!;
|
|
198
|
+
# text.upcase; end` over a sibling `def reset!; self.text = ""; end` has no setter of its OWN, so a
|
|
199
|
+
# guard that only looked for (1) would grant it and fold `text` to the construction value while the
|
|
200
|
+
# runtime read is `""`.
|
|
201
|
+
#
|
|
202
|
+
# (3) is what makes the grant CLOSED under the calls the body makes. A caller that can resolve sibling
|
|
203
|
+
# methods passes a block: it receives each otherwise-unrecognised self-call name and answers whether
|
|
204
|
+
# that sibling is itself self-fold-safe, which is how `def outer; shout; end` keeps the grant while
|
|
205
|
+
# `def go; reset!; text; end` loses it. Without the block — or for a name the block cannot resolve —
|
|
206
|
+
# the call refuses, so the AST-only answer stays the conservative one.
|
|
207
|
+
#
|
|
208
|
+
# Nested `def` / `class` / `module` bodies are skipped: their statements do not run during THIS body's
|
|
209
|
+
# evaluation. Blocks are descended into — they share `self`.
|
|
210
|
+
#
|
|
211
|
+
# @param body [Prism::Node, nil]
|
|
212
|
+
# @param member_names [Enumerable<Symbol>] the receiver carrier's member names.
|
|
213
|
+
# @yieldparam name [Symbol] an unrecognised self-call selector.
|
|
214
|
+
# @yieldreturn [Boolean] whether that sibling method is itself self-fold-safe.
|
|
215
|
+
def self_fold_safe_body?(body, member_names, &sibling_pure)
|
|
216
|
+
return false if body.nil?
|
|
217
|
+
|
|
218
|
+
self_uses_pure?(body, member_names.to_set(&:to_sym), sibling_pure)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Recursive worker for {self_fold_safe_body?}. A `SelfNode` reached here came through a non-receiver
|
|
222
|
+
# edge (a call's own `self` receiver is skipped below), so it is an escape.
|
|
223
|
+
def self_uses_pure?(node, members, sibling_pure)
|
|
224
|
+
return true if node.nil? || scope_boundary?(node)
|
|
225
|
+
return false if node.is_a?(Prism::SelfNode)
|
|
226
|
+
# `super` / `super(...)` runs an ancestor's body against this same `self`, and the resolver
|
|
227
|
+
# `sibling_pure` walks owns-class defs only, so nothing here can vouch for what it does. Latent
|
|
228
|
+
# while struct carriers have no subclass ancestry to reach, closed before they do.
|
|
229
|
+
return false if node.is_a?(Prism::SuperNode) || node.is_a?(Prism::ForwardingSuperNode)
|
|
230
|
+
|
|
231
|
+
if node.is_a?(Prism::CallNode)
|
|
232
|
+
return false unless self_call_pure?(node, members, sibling_pure)
|
|
233
|
+
|
|
234
|
+
return call_children_pure?(node, members, sibling_pure)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
children_pure?(node, members, sibling_pure)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# A call is pure for this scan unless it targets `self` (explicitly or implicitly) under a name that is
|
|
241
|
+
# neither one of the receiver's member readers nor a fixed pure read. `self.text = v` arrives as a
|
|
242
|
+
# `:text=` call and `self[:text] = v` as `:[]=`; neither is in either set, so both refuse — and neither
|
|
243
|
+
# is offered to `sibling_pure`, since a name that WRITES is not made safe by where it is defined.
|
|
244
|
+
def self_call_pure?(call_node, members, sibling_pure)
|
|
245
|
+
receiver = call_node.receiver
|
|
246
|
+
return true unless receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
247
|
+
|
|
248
|
+
name = call_node.name
|
|
249
|
+
return true if members.include?(name) || SELF_PURE_READS.include?(name)
|
|
250
|
+
return false if writer_selector?(name)
|
|
251
|
+
|
|
252
|
+
!sibling_pure.nil? && sibling_pure.call(name)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# A selector that assigns: `text=`, `[]=`. Never delegated to `sibling_pure` — a writer is unsafe on
|
|
256
|
+
# its face, and asking the resolver about it would let a struct with a hand-written `def text=(v)` that
|
|
257
|
+
# the resolver walks into read as pure.
|
|
258
|
+
def writer_selector?(name)
|
|
259
|
+
text = name.to_s
|
|
260
|
+
text.end_with?("=") && !%w[== != <= >= ===].include?(text)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Children of a call, skipping an explicit `self` RECEIVER — that occurrence is the call's own target
|
|
264
|
+
# and was already judged by {self_call_pure?}, not an escape.
|
|
265
|
+
def call_children_pure?(call_node, members, sibling_pure)
|
|
266
|
+
call_node.rigor_each_child do |child|
|
|
267
|
+
next if child.is_a?(Prism::SelfNode) && child.equal?(call_node.receiver)
|
|
268
|
+
return false unless self_uses_pure?(child, members, sibling_pure)
|
|
269
|
+
end
|
|
270
|
+
true
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def children_pure?(node, members, sibling_pure)
|
|
274
|
+
node.rigor_each_child do |child|
|
|
275
|
+
return false unless self_uses_pure?(child, members, sibling_pure)
|
|
276
|
+
end
|
|
277
|
+
true
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Constructs a member setter's effect cannot reach the continuation soundly, so a setter inside one
|
|
281
|
+
# disqualifies the local.
|
|
282
|
+
#
|
|
283
|
+
# A block or lambda body is evaluated for its own scope and its bindings do NOT leak, so
|
|
284
|
+
# `[1].each { s.x = v }; s.x` would read the construction value — verified unsound on #525.
|
|
285
|
+
#
|
|
286
|
+
# `for` shares the enclosing locals rather than opening a scope, so the block rationale does not apply
|
|
287
|
+
# to it; it is a boundary for the same reason `while` is (below), and this comment previously gave the
|
|
288
|
+
# wrong reason for it.
|
|
289
|
+
#
|
|
290
|
+
# `while` / `until` are boundaries because a single static pass cannot model a member setter across
|
|
291
|
+
# ITERATIONS. Issue #597 tried to lift this: `eval_loop` does join the body's exit scope with the
|
|
292
|
+
# pre-loop scope, which looks like the per-iteration summary the gate stands in for. It is not one.
|
|
293
|
+
# That join is a single unrolling, and it is sound only under four side conditions nothing checks —
|
|
294
|
+
# the setter's RHS must not depend on loop-carried member state (`p.x = p.y; p.y = 5` reads `1 | 2`
|
|
295
|
+
# where the runtime holds 5), the member must be read only AFTER the loop, the loop must exit through
|
|
296
|
+
# its predicate rather than a `break`, and no later setter may overwrite what the join recorded.
|
|
297
|
+
# Details and the probes: ADR-56 § WD2.8.
|
|
298
|
+
#
|
|
299
|
+
# Straight-line conditionals (`if` / `unless` / `case`) are NOT boundaries — their branch scopes join
|
|
143
300
|
# soundly, so a setter in one branch is fine.
|
|
144
301
|
def deferred_boundary?(node)
|
|
145
302
|
node.is_a?(Prism::WhileNode) || node.is_a?(Prism::UntilNode) ||
|
|
@@ -171,7 +328,9 @@ module Rigor
|
|
|
171
328
|
return nil unless meta_constant?(call_node.receiver, :Struct)
|
|
172
329
|
|
|
173
330
|
args = call_node.arguments&.arguments || []
|
|
174
|
-
|
|
331
|
+
# `[0..-2]` cannot return nil for a start of 0, but `Array#[](Range) -> Array[T]?` says it can;
|
|
332
|
+
# the `|| []` keeps the read well-typed on that worst case.
|
|
333
|
+
positional = args.last.is_a?(Prism::KeywordHashNode) ? (args[0..-2] || []) : args
|
|
175
334
|
positional = positional[1..] if positional.first.is_a?(Prism::StringNode)
|
|
176
335
|
return nil if positional.nil? || positional.empty?
|
|
177
336
|
return nil unless positional.all?(Prism::SymbolNode)
|
|
@@ -65,6 +65,13 @@ module Rigor
|
|
|
65
65
|
registries = collect_trait_registries(plugin_registry)
|
|
66
66
|
nested_templates = collect_nested_class_templates(plugin_registry)
|
|
67
67
|
return SyntheticMethodIndex::EMPTY if templates.empty? && registries.empty? && nested_templates.empty?
|
|
68
|
+
# Tier B alone cannot emit without an environment: every trait entry — the direct route and the
|
|
69
|
+
# concern-re-targeted one — funnels through `module_instance_method_names`, whose first line
|
|
70
|
+
# answers `[]` for a nil environment. The production pre-pass passes `environment: nil` (#476),
|
|
71
|
+
# so a project whose only contributing plugin registers trait registries (rigor-devise on a
|
|
72
|
+
# Rails app) would otherwise pay a whole-project parse to build a provably empty index. This
|
|
73
|
+
# gate mirrors that nil guard and MUST be removed in the change that threads a real environment.
|
|
74
|
+
return SyntheticMethodIndex::EMPTY if environment.nil? && templates.empty? && nested_templates.empty?
|
|
68
75
|
|
|
69
76
|
asts = parse_paths(paths, buffer: buffer)
|
|
70
77
|
hierarchy = build_hierarchy(asts)
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../source/constant_path"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Inference
|
|
9
|
+
# ADR-47 WD5 — decides the arm of a **version guard**: an `if` / `unless` predicate that compares the
|
|
10
|
+
# Ruby (or a bundled default gem's) version against a literal, both sides readable at analysis time.
|
|
11
|
+
#
|
|
12
|
+
# Multi-version gems select an API generation with these guards routinely:
|
|
13
|
+
#
|
|
14
|
+
# if Gem::Version.new(Psych::VERSION) >= Gem::Version.new("3.1.0.pre1")
|
|
15
|
+
# ::YAML.safe_load(yaml, permitted_classes: permitted_classes)
|
|
16
|
+
# else
|
|
17
|
+
# ::YAML.safe_load(yaml, permitted_classes) # Psych < 3.1 positional form
|
|
18
|
+
# end
|
|
19
|
+
#
|
|
20
|
+
# The dead arm is honest code for an older Ruby, but it never runs on the Ruby the user is checking
|
|
21
|
+
# with — so a diagnostic inside it is a false positive ("the program works" outranks the worst-case
|
|
22
|
+
# static reading, issue #627). {.verdict} answers `:truthy` / `:falsey` for a decidable guard, and
|
|
23
|
+
# `StatementEvaluator#eval_if` / `#eval_unless` then take the *exact* path they already take for
|
|
24
|
+
# `if false`: only the live arm is evaluated, so the dead arm contributes neither diagnostics nor
|
|
25
|
+
# writes to the post-`if` scope.
|
|
26
|
+
#
|
|
27
|
+
# ## The reference Ruby
|
|
28
|
+
#
|
|
29
|
+
# The version compared against is the **analyzer's own** `RUBY_VERSION` / `RUBY_ENGINE`. That is the
|
|
30
|
+
# premise Rigor already runs on: {Builtins::PredefinedConstantRefinements} resolves these constants
|
|
31
|
+
# through the analyzer's runtime, and the core / stdlib RBS the engine reads is the one shipped with
|
|
32
|
+
# the interpreter running `rigor`. `Configuration#target_ruby` is deliberately NOT consulted — it is a
|
|
33
|
+
# Prism *parse* version (it decides which syntax is accepted), it is not threaded to the inference
|
|
34
|
+
# layer, and its default (`"4.0"`) is a default rather than a user statement about the runtime.
|
|
35
|
+
#
|
|
36
|
+
# ## What is folded (and what is deliberately not)
|
|
37
|
+
#
|
|
38
|
+
# * `RUBY_VERSION <cmp> "x.y.z"` — compared with **String** semantics, because that is what runs.
|
|
39
|
+
# Ruby compares strings lexically, so `RUBY_VERSION >= "3.10"` is famously false on 3.10; folding
|
|
40
|
+
# with String semantics reproduces the program's real behaviour rather than an idealised one.
|
|
41
|
+
# * `Gem::Version.new(a) <cmp> Gem::Version.new(b)` — both sides wrapped, compared with
|
|
42
|
+
# `Gem::Version` semantics. A *mixed* comparison (one side wrapped, the other a bare String) is
|
|
43
|
+
# never folded: `Gem::Version#<=>` returns nil for a non-Version operand, so `Comparable` raises at
|
|
44
|
+
# runtime and there is no arm to pick.
|
|
45
|
+
# * `RUBY_ENGINE == / != "…"` — equality only. An *ordering* comparison on an engine name is not a
|
|
46
|
+
# version guard, so it is left alone.
|
|
47
|
+
# * `X::VERSION` — only for the curated {VERSION_CONSTANTS} set: constants of **default gems shipped
|
|
48
|
+
# with the running Ruby**, where "the analyzer's Ruby is the target's Ruby" already covers them.
|
|
49
|
+
# A third-party gem's `VERSION` is not read: its version is the project's `Gemfile.lock`'s to say,
|
|
50
|
+
# and the lockfile is not visible from the inference layer.
|
|
51
|
+
# * `RUBY_PLATFORM` — **never** folded. Every comparison against it is by construction
|
|
52
|
+
# platform-dependent, and the machine running `rigor` need not be the machine running the program.
|
|
53
|
+
# * `<=>` — never folded (it yields -1/0/1, not a branch verdict), and neither are `defined?(Ractor)`
|
|
54
|
+
# style capability probes, `!` / `&&` / `||` compositions, or `case` subjects. Each would need its
|
|
55
|
+
# own argument; an unfoldable guard simply keeps both arms live, which is the pre-existing
|
|
56
|
+
# behaviour.
|
|
57
|
+
# * Two bare String literals (`if "a" < "b"`) — declined. That is a constant comparison, not a version
|
|
58
|
+
# guard; the existing constant-fold tier already answers it, and widening this rule to cover it would
|
|
59
|
+
# buy scope without buying an FP fix. At least one side must be a readable constant or a
|
|
60
|
+
# `Gem::Version`.
|
|
61
|
+
module VersionGuard
|
|
62
|
+
module_function
|
|
63
|
+
|
|
64
|
+
# Comparison operators whose result is a branch verdict. `<=>` is excluded on purpose.
|
|
65
|
+
ORDERING = Set[:<, :<=, :>, :>=].freeze
|
|
66
|
+
EQUALITY = Set[:==, :!=].freeze
|
|
67
|
+
private_constant :ORDERING, :EQUALITY
|
|
68
|
+
|
|
69
|
+
# Predefined constants read from the analyzer's runtime, mapped to the operand kind they produce.
|
|
70
|
+
# `:string` operands accept every comparison; `:engine` operands accept equality only.
|
|
71
|
+
PREDEFINED = { RUBY_VERSION: :string, RUBY_ENGINE: :engine }.freeze
|
|
72
|
+
private_constant :PREDEFINED
|
|
73
|
+
|
|
74
|
+
# Operand kinds that carry a plain Ruby String, i.e. the ones `Gem::Version.new` may wrap and the
|
|
75
|
+
# ones that compare with each other.
|
|
76
|
+
STRING_KINDS = Set[:literal_string, :string, :engine].freeze
|
|
77
|
+
private_constant :STRING_KINDS
|
|
78
|
+
|
|
79
|
+
# Qualified `X::VERSION` constants that may be read from the analyzer's runtime.
|
|
80
|
+
#
|
|
81
|
+
# Admission criterion: the constant belongs to a **default gem of the running Ruby**, so its value
|
|
82
|
+
# is fixed by the same interpreter choice that already fixes `RUBY_VERSION` and the core RBS. Add an
|
|
83
|
+
# entry only when that holds; a gem the project resolves through its own `Gemfile.lock` does not
|
|
84
|
+
# qualify, because the analyzer's copy and the project's can differ.
|
|
85
|
+
VERSION_CONSTANTS = Set["Psych::VERSION"].freeze
|
|
86
|
+
private_constant :VERSION_CONSTANTS
|
|
87
|
+
|
|
88
|
+
GEM_VERSION_PATH = "Gem::Version"
|
|
89
|
+
private_constant :GEM_VERSION_PATH
|
|
90
|
+
|
|
91
|
+
# @param node [Prism::Node, nil] an `if` / `unless` predicate
|
|
92
|
+
# @return [Symbol, nil] `:truthy` / `:falsey` when the guard is decidable on the analyzer's Ruby,
|
|
93
|
+
# otherwise nil (both arms stay live)
|
|
94
|
+
def verdict(node)
|
|
95
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
96
|
+
|
|
97
|
+
operator = node.name
|
|
98
|
+
return nil unless ORDERING.include?(operator) || EQUALITY.include?(operator)
|
|
99
|
+
return nil if node.block || node.receiver.nil?
|
|
100
|
+
|
|
101
|
+
arguments = node.arguments&.arguments
|
|
102
|
+
return nil unless arguments && arguments.length == 1
|
|
103
|
+
|
|
104
|
+
left = read_operand(node.receiver)
|
|
105
|
+
right = read_operand(arguments.first)
|
|
106
|
+
return nil if left.nil? || right.nil?
|
|
107
|
+
|
|
108
|
+
decide(left, right, operator)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Reads one side of the comparison into `[kind, value]`, or nil when it is not readable.
|
|
112
|
+
#
|
|
113
|
+
# @return [Array(Symbol, Object), nil]
|
|
114
|
+
def read_operand(node)
|
|
115
|
+
case node
|
|
116
|
+
when Prism::StringNode then [:literal_string, node.unescaped]
|
|
117
|
+
when Prism::ConstantReadNode then read_predefined(node)
|
|
118
|
+
when Prism::ConstantPathNode then read_version_constant(node)
|
|
119
|
+
when Prism::CallNode then read_gem_version(node)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
private_class_method :read_operand
|
|
123
|
+
|
|
124
|
+
def read_predefined(node)
|
|
125
|
+
kind = PREDEFINED[node.name]
|
|
126
|
+
return nil unless kind
|
|
127
|
+
|
|
128
|
+
value = runtime_value(node.name.to_s)
|
|
129
|
+
value && [kind, value]
|
|
130
|
+
end
|
|
131
|
+
private_class_method :read_predefined
|
|
132
|
+
|
|
133
|
+
def read_version_constant(node)
|
|
134
|
+
path = Source::ConstantPath.qualified_name_or_nil(node)
|
|
135
|
+
return nil unless path && VERSION_CONSTANTS.include?(path)
|
|
136
|
+
|
|
137
|
+
value = runtime_value(path)
|
|
138
|
+
value && [:string, value]
|
|
139
|
+
end
|
|
140
|
+
private_class_method :read_version_constant
|
|
141
|
+
|
|
142
|
+
# `Gem::Version.new(<readable>)`. The inner operand must be a plain version String — an engine name
|
|
143
|
+
# is not a version, and `Gem::Version.new` raises on anything `Gem::Version.correct?` rejects, so a
|
|
144
|
+
# malformed literal keeps both arms live instead of folding a guard the program cannot even reach.
|
|
145
|
+
def read_gem_version(node)
|
|
146
|
+
return nil unless node.name == :new && node.block.nil?
|
|
147
|
+
return nil unless Source::ConstantPath.qualified_name_or_nil(node.receiver) == GEM_VERSION_PATH
|
|
148
|
+
|
|
149
|
+
arguments = node.arguments&.arguments
|
|
150
|
+
return nil unless arguments && arguments.length == 1
|
|
151
|
+
|
|
152
|
+
inner = read_operand(arguments.first)
|
|
153
|
+
return nil unless inner && STRING_KINDS.include?(inner.first)
|
|
154
|
+
return nil unless ::Gem::Version.correct?(inner.last)
|
|
155
|
+
|
|
156
|
+
[:gem_version, ::Gem::Version.new(inner.last)]
|
|
157
|
+
end
|
|
158
|
+
private_class_method :read_gem_version
|
|
159
|
+
|
|
160
|
+
# Resolves a constant path in the analyzer's own runtime without triggering `const_missing` or an
|
|
161
|
+
# autoload. The path is always one of {PREDEFINED} / {VERSION_CONSTANTS} — Rigor's own source,
|
|
162
|
+
# never a name read out of the analysed program — but the walk is guarded the same way
|
|
163
|
+
# {Builtins::PredefinedConstantRefinements} is, for the same reason: `const_defined?` answers true
|
|
164
|
+
# for a REGISTERED-BUT-NOT-YET-TRIGGERED autoload, so it does not mean "already in memory" and
|
|
165
|
+
# `const_get` would execute the target file inside the analyzer. `Module#autoload?` is what
|
|
166
|
+
# separates the two ([#680](https://github.com/rigortype/rigor/issues/680)).
|
|
167
|
+
#
|
|
168
|
+
# @return [String, nil] the constant's value when it is a non-empty String
|
|
169
|
+
def runtime_value(path)
|
|
170
|
+
mod = ::Object
|
|
171
|
+
path.split("::").each do |part|
|
|
172
|
+
return nil unless mod.is_a?(::Module) && mod.const_defined?(part, false)
|
|
173
|
+
return nil if mod.autoload?(part)
|
|
174
|
+
|
|
175
|
+
mod = mod.const_get(part, false)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
mod.is_a?(::String) && !mod.empty? ? mod : nil
|
|
179
|
+
rescue ::StandardError, ::ScriptError, ::SystemExit
|
|
180
|
+
# Wider than `NameError` / `TypeError` / `LoadError`: folding a version guard is a precision
|
|
181
|
+
# win, and nothing it can hit justifies stopping the run. `SystemExit` is not a `StandardError`
|
|
182
|
+
# and escaped every rescue in the analyzer when a resolution executed third-party code (#680).
|
|
183
|
+
# `Interrupt`, `SignalException` and `NoMemoryError` stay uncaught on purpose.
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
private_class_method :runtime_value
|
|
187
|
+
|
|
188
|
+
# Applies the operator with the semantics that actually run. Operands must be the same kind, and an
|
|
189
|
+
# engine name only ever answers equality.
|
|
190
|
+
def decide(left, right, operator)
|
|
191
|
+
left_kind, left_value = left
|
|
192
|
+
right_kind, right_value = right
|
|
193
|
+
return nil unless comparable_kinds?(left_kind, right_kind)
|
|
194
|
+
return nil if (left_kind == :engine || right_kind == :engine) && !EQUALITY.include?(operator)
|
|
195
|
+
|
|
196
|
+
result = apply(left_value, right_value, operator)
|
|
197
|
+
return nil if result.nil?
|
|
198
|
+
|
|
199
|
+
result ? :truthy : :falsey
|
|
200
|
+
end
|
|
201
|
+
private_class_method :decide
|
|
202
|
+
|
|
203
|
+
# `:gem_version` only ever compares with another `:gem_version` — `Gem::Version#<=>` answers nil for
|
|
204
|
+
# a String operand, so the mixed spelling raises at runtime and has no live arm to pick. The String
|
|
205
|
+
# kinds compare with each other, EXCEPT two bare literals: that is a constant comparison, not a
|
|
206
|
+
# version guard.
|
|
207
|
+
def comparable_kinds?(left_kind, right_kind)
|
|
208
|
+
return left_kind == right_kind if left_kind == :gem_version || right_kind == :gem_version
|
|
209
|
+
|
|
210
|
+
!(left_kind == :literal_string && right_kind == :literal_string)
|
|
211
|
+
end
|
|
212
|
+
private_class_method :comparable_kinds?
|
|
213
|
+
|
|
214
|
+
def apply(left, right, operator)
|
|
215
|
+
case operator
|
|
216
|
+
when :< then left < right
|
|
217
|
+
when :<= then left <= right
|
|
218
|
+
when :> then left > right
|
|
219
|
+
when :>= then left >= right
|
|
220
|
+
when :== then left == right
|
|
221
|
+
when :!= then left != right
|
|
222
|
+
end
|
|
223
|
+
rescue ::ArgumentError, ::TypeError, ::NoMethodError
|
|
224
|
+
nil
|
|
225
|
+
end
|
|
226
|
+
private_class_method :apply
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
end
|
data/lib/rigor/plugin/base.rb
CHANGED
|
@@ -435,11 +435,10 @@ module Rigor
|
|
|
435
435
|
# Frozen snapshot of the declared narrowing-facts rules. Memoised for the same reason as
|
|
436
436
|
# {dynamic_returns} — consulted per plugin per dispatch, over an array fixed at class-definition
|
|
437
437
|
# time.
|
|
438
|
-
# rubocop:disable Naming/MemoizedInstanceVariableName -- see dynamic_returns
|
|
438
|
+
# rubocop:disable-next Naming/MemoizedInstanceVariableName -- see dynamic_returns
|
|
439
439
|
def narrowing_facts_rules
|
|
440
440
|
@narrowing_facts_rules_snapshot ||= (@narrowing_facts_rules || []).dup.freeze
|
|
441
441
|
end
|
|
442
|
-
# rubocop:enable Naming/MemoizedInstanceVariableName
|
|
443
442
|
end
|
|
444
443
|
|
|
445
444
|
attr_reader :services, :config
|
|
@@ -841,6 +840,8 @@ module Rigor
|
|
|
841
840
|
def dynamic_return_receiver_class_name(receiver_type)
|
|
842
841
|
case receiver_type
|
|
843
842
|
when Rigor::Type::Nominal, Rigor::Type::Singleton then receiver_type.class_name
|
|
843
|
+
when Rigor::Type::Result then "Dry::Monads::Result"
|
|
844
|
+
when Rigor::Type::Maybe then "Dry::Monads::Maybe"
|
|
844
845
|
end
|
|
845
846
|
end
|
|
846
847
|
|
|
@@ -32,9 +32,17 @@ module Rigor
|
|
|
32
32
|
# § "safety harness"). The set is fixed and greppable — never a dynamic `public_send`.
|
|
33
33
|
#
|
|
34
34
|
# `tableize` is deliberately NOT delegated: `ActiveSupport::Inflector.tableize("Admin::User")` returns
|
|
35
|
-
# `"admin/users"`,
|
|
36
|
-
#
|
|
37
|
-
#
|
|
35
|
+
# `"admin/users"`, a slash-separated path, never a valid SQL table name. {.tableize} composes the
|
|
36
|
+
# AS-backed `underscore` / `pluralize` with a `::`→`_` flatten instead, so a caller already holding a
|
|
37
|
+
# BARE (non-namespaced) name gets a plain identifier back rather than a path.
|
|
38
|
+
#
|
|
39
|
+
# This is NOT ActiveRecord's actual table-name algorithm for a namespaced model — Rails demodulizes
|
|
40
|
+
# (drops the enclosing module entirely) rather than flattening it into the name, and prepends /
|
|
41
|
+
# appends `table_name_prefix` / `table_name_suffix` only when the enclosing module declares one.
|
|
42
|
+
# `rigor-activerecord`'s `ModelIndex.inflected_table_name` does that demodulize-then-decorate
|
|
43
|
+
# sequence and calls here only with the already-demodulized (so already namespace-free) local class
|
|
44
|
+
# name — the `::`→`_` flatten below never actually fires from that caller (#623 fixed a version of
|
|
45
|
+
# this method that WAS called with the full namespaced path).
|
|
38
46
|
ALLOWED_METHODS = %i[underscore camelize singularize pluralize classify].freeze
|
|
39
47
|
|
|
40
48
|
# The target library + the constant the allow-listed methods are called on. Passed to {Isolation} so the
|
|
@@ -81,8 +89,9 @@ module Rigor
|
|
|
81
89
|
invoke(:classify, table_name)
|
|
82
90
|
end
|
|
83
91
|
|
|
84
|
-
# `BlogPost` → `blog_posts`; `Admin::User` → `admin_users`. Composed (not delegated)
|
|
85
|
-
#
|
|
92
|
+
# `BlogPost` → `blog_posts`; `Admin::User` → `admin_users`. Composed (not delegated) — see
|
|
93
|
+
# {ALLOWED_METHODS} — to flatten a namespaced String into a plain SQL identifier. This is NOT what
|
|
94
|
+
# ActiveRecord's table-name computation does with a namespace: see {ALLOWED_METHODS}'s comment above.
|
|
86
95
|
def tableize(class_name)
|
|
87
96
|
underscored = underscore(class_name.to_s.gsub("::", "/")).tr("/", "_")
|
|
88
97
|
pluralize(underscored)
|