rigortype 0.4.4 → 0.4.6
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/docs/handbook/11-sig-gen.md +100 -18
- data/docs/manual/02-cli-reference.md +85 -16
- data/docs/manual/03-configuration.md +12 -9
- data/docs/manual/04-diagnostics.md +17 -6
- data/docs/manual/05-inspecting-types.md +2 -2
- data/docs/manual/06-baseline.md +1 -1
- data/docs/manual/07-plugins.md +2 -1
- data/docs/manual/08-skills.md +7 -0
- data/docs/manual/10-mcp-server.md +17 -2
- data/docs/manual/12-caching.md +8 -1
- data/docs/manual/18-removing-dead-code.md +18 -7
- data/docs/manual/plugins/rigor-actionmailer.md +30 -0
- data/docs/manual/plugins/rigor-actionpack.md +30 -0
- data/docs/manual/plugins/rigor-devise.md +15 -0
- data/docs/manual/plugins/rigor-rspec.md +102 -1
- data/lib/rigor/analysis/buffer_binding.rb +14 -0
- data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
- data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
- data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
- data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
- data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
- data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
- data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
- data/lib/rigor/analysis/check_rules.rb +319 -21
- data/lib/rigor/analysis/dependency_recorder.rb +11 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/edit_replay.rb +67 -0
- data/lib/rigor/analysis/incremental.rb +86 -0
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
- data/lib/rigor/analysis/incremental_session.rb +336 -46
- data/lib/rigor/analysis/path_expansion.rb +44 -1
- data/lib/rigor/analysis/reachability/graph.rb +90 -13
- data/lib/rigor/analysis/reachability/scan.rb +279 -39
- data/lib/rigor/analysis/require_relative_closure.rb +100 -0
- data/lib/rigor/analysis/rule_catalog.rb +22 -5
- data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
- data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
- data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
- data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
- data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
- data/lib/rigor/analysis/runner.rb +222 -111
- data/lib/rigor/analysis/severity_stamp.rb +11 -0
- data/lib/rigor/analysis/worker_session.rb +24 -1
- data/lib/rigor/cache/descriptor.rb +40 -7
- data/lib/rigor/cache/engine_source.rb +3 -2
- data/lib/rigor/cache/incremental_snapshot.rb +49 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +4 -3
- data/lib/rigor/cli/check_command.rb +93 -4
- data/lib/rigor/cli/command.rb +3 -1
- data/lib/rigor/cli/diff_command.rb +2 -1
- data/lib/rigor/cli/docs_command.rb +3 -2
- data/lib/rigor/cli/doctor_command.rb +1 -1
- data/lib/rigor/cli/explain_command.rb +3 -2
- data/lib/rigor/cli/lens_command.rb +5 -5
- data/lib/rigor/cli/mcp_command.rb +2 -1
- data/lib/rigor/cli/options.rb +20 -3
- data/lib/rigor/cli/plugin_command.rb +2 -1
- data/lib/rigor/cli/plugins_command.rb +7 -1
- data/lib/rigor/cli/plugins_renderer.rb +24 -2
- data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
- data/lib/rigor/cli/sig_gen_command.rb +73 -13
- data/lib/rigor/cli/skill_command.rb +2 -1
- data/lib/rigor/cli/skill_describe.rb +3 -2
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/type_of_project_scope.rb +27 -0
- data/lib/rigor/cli/unused_command.rb +10 -7
- data/lib/rigor/cli.rb +3 -2
- data/lib/rigor/config_audit.rb +72 -16
- data/lib/rigor/configuration/severity_profile.rb +20 -2
- data/lib/rigor/configuration.rb +43 -7
- data/lib/rigor/effects/signature_sources.rb +3 -1
- data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
- data/lib/rigor/environment/gated_signature_guard.rb +2 -1
- data/lib/rigor/environment/gated_signature_plan.rb +2 -1
- data/lib/rigor/environment/installed_gem_set.rb +2 -1
- data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
- data/lib/rigor/environment/rbs_loader.rb +136 -7
- data/lib/rigor/environment/required_features.rb +136 -29
- data/lib/rigor/environment/ruby_library_roots.rb +122 -0
- data/lib/rigor/environment.rb +4 -2
- data/lib/rigor/inference/block_call_timing.rb +7 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
- data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
- data/lib/rigor/inference/captured_locals.rb +44 -13
- data/lib/rigor/inference/clause_widening.rb +90 -0
- data/lib/rigor/inference/deep_erasure.rb +138 -0
- data/lib/rigor/inference/def_return_typer.rb +159 -9
- data/lib/rigor/inference/definer_resolution.rb +101 -20
- data/lib/rigor/inference/error_info.rb +17 -3
- data/lib/rigor/inference/expression_typer.rb +97 -18
- data/lib/rigor/inference/global_write_census.rb +36 -1
- data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
- data/lib/rigor/inference/ivar_writer_census.rb +195 -0
- data/lib/rigor/inference/loop_value_record.rb +53 -0
- data/lib/rigor/inference/macro_block_self_type.rb +20 -6
- data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
- data/lib/rigor/inference/method_dispatcher.rb +113 -19
- data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
- data/lib/rigor/inference/module_function_state.rb +149 -42
- data/lib/rigor/inference/mutation_widening.rb +5 -2
- data/lib/rigor/inference/narrowing.rb +116 -11
- data/lib/rigor/inference/object_mixins.rb +13 -1
- data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
- data/lib/rigor/inference/project_patched_methods.rb +168 -4
- data/lib/rigor/inference/project_patched_mixins.rb +162 -0
- data/lib/rigor/inference/project_patched_scanner.rb +51 -12
- data/lib/rigor/inference/project_patched_visibility.rb +214 -0
- data/lib/rigor/inference/project_seed_tables.rb +121 -0
- data/lib/rigor/inference/project_yielder.rb +128 -0
- data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
- data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
- data/lib/rigor/inference/return_barrier.rb +16 -0
- data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
- data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
- data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
- data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
- data/lib/rigor/inference/scope_indexer.rb +1144 -197
- data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
- data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
- data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
- data/lib/rigor/inference/statement_evaluator.rb +1088 -213
- data/lib/rigor/inference/synthetic_method_index.rb +23 -0
- data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
- data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
- data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
- data/lib/rigor/language_server/buffer_table.rb +6 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
- data/lib/rigor/language_server/project_context.rb +59 -3
- data/lib/rigor/language_server/server.rb +5 -3
- data/lib/rigor/lens/contract_reader.rb +45 -2
- data/lib/rigor/lens/document.rb +2 -1
- data/lib/rigor/lens/file_reader.rb +12 -2
- data/lib/rigor/lens/members.rb +16 -2
- data/lib/rigor/lens/run.rb +34 -7
- data/lib/rigor/mcp/server.rb +11 -3
- data/lib/rigor/plugin/base.rb +29 -13
- data/lib/rigor/plugin/io_boundary.rb +42 -6
- data/lib/rigor/plugin/isolation.rb +4 -3
- data/lib/rigor/plugin/manifest.rb +29 -3
- data/lib/rigor/plugin/registry.rb +38 -0
- data/lib/rigor/protection/discovery_seed.rb +64 -36
- data/lib/rigor/protection/mutation_cache.rb +8 -2
- data/lib/rigor/rooted_glob.rb +36 -0
- data/lib/rigor/scope/discovery_index.rb +5 -0
- data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
- data/lib/rigor/scope/resolution_chain.rb +40 -5
- data/lib/rigor/scope.rb +59 -7
- data/lib/rigor/sig_gen/alias_index.rb +2 -1
- data/lib/rigor/sig_gen/classification.rb +4 -1
- data/lib/rigor/sig_gen/generator.rb +465 -49
- data/lib/rigor/sig_gen/layout_index.rb +5 -2
- data/lib/rigor/sig_gen/observation_collector.rb +141 -12
- data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
- data/lib/rigor/sig_gen/renderer.rb +16 -155
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
- data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
- data/lib/rigor/sig_gen/write_reporter.rb +203 -0
- data/lib/rigor/sig_gen/write_result.rb +3 -1
- data/lib/rigor/sig_gen/writer.rb +5 -3
- data/lib/rigor/signature_path_audit.rb +4 -2
- data/lib/rigor/type/combinator.rb +4 -3
- data/lib/rigor/type/constant.rb +54 -3
- data/lib/rigor/type/hash_shape.rb +14 -9
- data/lib/rigor/type/struct_instance.rb +3 -2
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +3 -2
- data/sig/rigor/effects/file_collection.rbs +5 -6
- data/sig/rigor/effects/registry.rbs +8 -6
- data/sig/rigor/effects/summary.rbs +6 -5
- data/sig/rigor/environment.rbs +5 -3
- data/sig/rigor/inference/optimistic_origin.rbs +11 -0
- data/sig/rigor/inference.rbs +6 -0
- data/sig/rigor/plugin/additional_initializer.rbs +5 -5
- data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
- data/sig/rigor/plugin/effect_attribution.rbs +25 -10
- data/sig/rigor/plugin/effect_edge.rbs +12 -5
- data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
- data/sig/rigor/plugin/io_boundary.rbs +5 -2
- data/sig/rigor/plugin/manifest.rbs +16 -18
- data/sig/rigor/plugin/protocol_contract.rbs +10 -9
- data/sig/rigor/plugin/registry.rbs +1 -1
- data/sig/rigor/rooted_glob.rbs +7 -0
- data/sig/rigor/scope.rbs +34 -52
- data/sig/rigor/type.rbs +0 -6
- data/skills/rigor-doctor/references/01-checks.md +23 -11
- data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
- metadata +32 -1
|
@@ -4,6 +4,8 @@ require "prism"
|
|
|
4
4
|
|
|
5
5
|
require_relative "project_patched_methods"
|
|
6
6
|
require_relative "project_patched_definedness_guard"
|
|
7
|
+
require_relative "project_patched_visibility"
|
|
8
|
+
require_relative "project_patched_mixins"
|
|
7
9
|
require_relative "../analysis/dependency_source_inference/return_type_heuristic"
|
|
8
10
|
require_relative "../source/alias_names"
|
|
9
11
|
require_relative "../source/constant_path"
|
|
@@ -20,7 +22,7 @@ module Rigor
|
|
|
20
22
|
# only needs `class C; def m; end; end` shape recognition, not full inference. Parse errors degrade
|
|
21
23
|
# to a fail-soft `:warning` `pre-eval.parse-error` diagnostic accumulated alongside the registry;
|
|
22
24
|
# per ADR-17 § "Failure modes" a parse failure in a pre-eval file MUST NOT abort the rest of the run.
|
|
23
|
-
module ProjectPatchedScanner
|
|
25
|
+
module ProjectPatchedScanner # rubocop:disable Metrics/ModuleLength
|
|
24
26
|
# Frozen scan outcome carrying the populated registry and the per-file warnings the runner emits
|
|
25
27
|
# at run start.
|
|
26
28
|
class Result < Data.define(:registry, :diagnostics)
|
|
@@ -57,10 +59,13 @@ module Rigor
|
|
|
57
59
|
diagnostics = []
|
|
58
60
|
census = Set.new
|
|
59
61
|
paths.each { |path| scan_file(path, collected, diagnostics, buffer, census) }
|
|
60
|
-
|
|
62
|
+
pending_mixins, collected = collected.partition { |item| item.is_a?(Mixins::Pending) }
|
|
63
|
+
overrides = {}
|
|
64
|
+
entries = resolve_aliases(collected, overrides)
|
|
61
65
|
diagnostics.concat(duplicate_declaration_diagnostics(entries))
|
|
62
66
|
Result.new(
|
|
63
|
-
registry: ProjectPatchedMethods.new(entries: entries, write_census: census.freeze
|
|
67
|
+
registry: ProjectPatchedMethods.new(entries: entries, write_census: census.freeze, overrides: overrides,
|
|
68
|
+
mixins: Mixins.resolve(pending_mixins, entries)),
|
|
64
69
|
diagnostics: diagnostics
|
|
65
70
|
)
|
|
66
71
|
end
|
|
@@ -105,32 +110,50 @@ module Rigor
|
|
|
105
110
|
# idiom (`def foo`, `alias_method :foo_without_x, :foo`, `alias_method :foo, :foo_with_x`) leaves `foo` the
|
|
106
111
|
# alias. Bindings of one name in DIFFERENT files are what `pre-eval.duplicate-declaration` reports, and the
|
|
107
112
|
# registry keeps the last of those, as Ruby does.
|
|
108
|
-
|
|
113
|
+
#
|
|
114
|
+
# A {Visibility::Pending} (issue #1708) changes the visibility of the binding its name has when it runs, in the
|
|
115
|
+
# file that bound it, so it is not a second declaration; a name no earlier patch binds on the class is one it
|
|
116
|
+
# inherits, recorded in `overrides` ({Visibility.apply}). `module_function :x` also binds the public singleton
|
|
117
|
+
# copy.
|
|
118
|
+
def resolve_aliases(collected, overrides = {})
|
|
109
119
|
bound = {}
|
|
120
|
+
homes = {}
|
|
110
121
|
per_file = collected.group_by(&:source_path).values.map do |items|
|
|
111
122
|
final = {}
|
|
112
123
|
items.each do |item|
|
|
124
|
+
if item.is_a?(Visibility::Pending)
|
|
125
|
+
next Visibility.apply(item, bound, homes, overrides) { |copy| bind(copy, bound, homes, final) }
|
|
126
|
+
end
|
|
127
|
+
|
|
113
128
|
entry = item.is_a?(PendingAlias) ? alias_entry(item, bound) : item
|
|
114
129
|
entry = entry.entry if entry.is_a?(GuardedDef)
|
|
115
130
|
key = [entry.class_name, entry.method_name, entry.kind]
|
|
116
131
|
next if item.is_a?(GuardedDef) && bound.key?(key)
|
|
117
132
|
|
|
118
|
-
bound
|
|
119
|
-
final.delete(key)
|
|
120
|
-
final[key] = entry
|
|
133
|
+
bind(entry, bound, homes, final)
|
|
121
134
|
end
|
|
122
|
-
final
|
|
135
|
+
final
|
|
123
136
|
end
|
|
124
|
-
per_file.
|
|
137
|
+
per_file.flat_map(&:values)
|
|
125
138
|
end
|
|
126
139
|
private_class_method :resolve_aliases
|
|
127
140
|
|
|
141
|
+
def bind(entry, bound, homes, final)
|
|
142
|
+
key = [entry.class_name, entry.method_name, entry.kind]
|
|
143
|
+
bound[key] = entry
|
|
144
|
+
homes[key] = final
|
|
145
|
+
final.delete(key)
|
|
146
|
+
final[key] = entry
|
|
147
|
+
end
|
|
148
|
+
private_class_method :bind
|
|
149
|
+
|
|
128
150
|
def alias_entry(item, bound)
|
|
129
151
|
target = bound[[item.class_name, item.old_name, item.kind]]
|
|
130
152
|
ProjectPatchedMethods::Entry.new(
|
|
131
153
|
class_name: item.class_name, method_name: item.new_name, kind: item.kind,
|
|
132
154
|
source_path: item.source_path, source_line: item.source_line,
|
|
133
|
-
return_type: target&.return_type, alias_of: target ? target.alias_of : item.old_name
|
|
155
|
+
return_type: target&.return_type, alias_of: target ? target.alias_of : item.old_name,
|
|
156
|
+
visibility: target ? target.visibility : :public
|
|
134
157
|
)
|
|
135
158
|
end
|
|
136
159
|
private_class_method :alias_entry
|
|
@@ -148,8 +171,7 @@ module Rigor
|
|
|
148
171
|
return
|
|
149
172
|
end
|
|
150
173
|
|
|
151
|
-
|
|
152
|
-
walk_node(parse_result.value, [], false, path, entries)
|
|
174
|
+
walk_file(parse_result.value, path, entries)
|
|
153
175
|
# Issue #1367 — the file's `global.*` write facts: a patch file may alias a special or give a class the
|
|
154
176
|
# method a setter asks for, as any project file may.
|
|
155
177
|
census.merge(GlobalWriteCensus.scan(parse_result.value))
|
|
@@ -164,9 +186,19 @@ module Rigor
|
|
|
164
186
|
)
|
|
165
187
|
ensure
|
|
166
188
|
Thread.current[:rigor_guarded_defs] = nil
|
|
189
|
+
Thread.current[:rigor_def_visibilities] = nil
|
|
167
190
|
end
|
|
168
191
|
private_class_method :scan_file
|
|
169
192
|
|
|
193
|
+
# Walks one parsed file with its definedness guards and `def` visibilities (issue #1708) in reach of
|
|
194
|
+
# {record_def_node}.
|
|
195
|
+
def walk_file(root, path, entries)
|
|
196
|
+
Thread.current[:rigor_guarded_defs] = DefinednessGuard.defs(root)
|
|
197
|
+
Thread.current[:rigor_def_visibilities] = Visibility.defs(root)
|
|
198
|
+
walk_node(root, [], false, path, entries)
|
|
199
|
+
end
|
|
200
|
+
private_class_method :walk_file
|
|
201
|
+
|
|
170
202
|
def parse_error_diagnostic(path, errors)
|
|
171
203
|
first = errors.first
|
|
172
204
|
line = first.respond_to?(:location) ? first.location&.start_line || 1 : 1
|
|
@@ -205,6 +237,11 @@ module Rigor
|
|
|
205
237
|
when Prism::CallNode
|
|
206
238
|
record_alias(node, Source::AliasNames.alias_method_call_names(node), qualified_prefix,
|
|
207
239
|
in_singleton_class, source_path, entries)
|
|
240
|
+
if Thread.current[:rigor_def_visibilities]&.[](node).equal?(Visibility::STATEMENT)
|
|
241
|
+
pending = Visibility.pending(node, qualified_prefix, in_singleton_class, source_path)
|
|
242
|
+
entries << pending if pending
|
|
243
|
+
entries.concat(Mixins.pending(node, qualified_prefix, in_singleton_class))
|
|
244
|
+
end
|
|
208
245
|
walk_children(node, qualified_prefix, in_singleton_class, source_path, entries)
|
|
209
246
|
else
|
|
210
247
|
walk_children(node, qualified_prefix, in_singleton_class, source_path, entries)
|
|
@@ -251,7 +288,9 @@ module Rigor
|
|
|
251
288
|
source_path: source_path, source_line: line,
|
|
252
289
|
return_type: return_type
|
|
253
290
|
)
|
|
291
|
+
entry, *copies = Visibility.applied(entry, Thread.current[:rigor_def_visibilities]&.[](node) || :public)
|
|
254
292
|
entries << (Thread.current[:rigor_guarded_defs]&.key?(node) ? GuardedDef.new(entry: entry) : entry)
|
|
293
|
+
entries.concat(copies)
|
|
255
294
|
end
|
|
256
295
|
private_class_method :record_def_node
|
|
257
296
|
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../source/literals"
|
|
6
|
+
require_relative "../source/node_children"
|
|
7
|
+
require_relative "module_function_state"
|
|
8
|
+
require_relative "project_patched_mixins"
|
|
9
|
+
|
|
10
|
+
module Rigor
|
|
11
|
+
module Inference
|
|
12
|
+
module ProjectPatchedScanner
|
|
13
|
+
# Issue #1708 — the visibility a `pre_eval:` file gives each `def` it writes in a class, module or `class <<`
|
|
14
|
+
# body, so a private patch does not answer a call with an explicit receiver. Ruby 4.0.5:
|
|
15
|
+
# `module Kernel; private def kp = 1; end; 1.kp` raises NoMethodError (private method 'kp' called for an
|
|
16
|
+
# instance of Integer), while a bare `kp` runs.
|
|
17
|
+
#
|
|
18
|
+
# The readings, over each body's direct statements in order:
|
|
19
|
+
#
|
|
20
|
+
# - a bare `private`, `protected`, `public` or `module_function` (receiverless or on `self`) sets the default
|
|
21
|
+
# for the `def`s after it in that body;
|
|
22
|
+
# - `private def x`, `protected def x`, `public def x`, `module_function def x` set that `def`'s, and
|
|
23
|
+
# `private_class_method def self.x` / `public_class_method def self.x` a singleton `def`'s;
|
|
24
|
+
# - a `def self.x` is public whatever the default says, as in Ruby;
|
|
25
|
+
# - a `def` nested in a statement (an `if`, a `begin`) takes the default, unless the statement holds a bare
|
|
26
|
+
# call of its own, which may or may not run: then it and every `def` after it are public, the reading that
|
|
27
|
+
# answers more calls.
|
|
28
|
+
#
|
|
29
|
+
# A `def` this does not reach (one in a block, a `rescue` clause, or a body nested in a conditional, a block or a
|
|
30
|
+
# method, which may not run) is public. A named call with Symbol arguments (`private :x`) is the scanner's to
|
|
31
|
+
# apply, since it may name a `def` of an earlier file ({.pending}).
|
|
32
|
+
module Visibility
|
|
33
|
+
# The receiverless calls that set an instance `def`'s visibility, and what they set. `module_function` makes
|
|
34
|
+
# the instance method private and adds a public singleton copy.
|
|
35
|
+
INSTANCE_CALLS = {
|
|
36
|
+
private: :private, protected: :protected, public: :public, module_function: :module_function
|
|
37
|
+
}.freeze
|
|
38
|
+
# The calls that set a singleton `def`'s visibility.
|
|
39
|
+
SINGLETON_CALLS = { private_class_method: :private, public_class_method: :public }.freeze
|
|
40
|
+
|
|
41
|
+
# The nodes a nested `def` does not take the body's default under: another body, or a block.
|
|
42
|
+
OPAQUE = [
|
|
43
|
+
Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode, Prism::DefNode, Prism::BlockNode,
|
|
44
|
+
Prism::LambdaNode
|
|
45
|
+
].freeze
|
|
46
|
+
private_constant :OPAQUE
|
|
47
|
+
|
|
48
|
+
# The bodies {.read_body} reads when they are a direct statement of a body it reads.
|
|
49
|
+
BODIES = [Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
50
|
+
private_constant :BODIES
|
|
51
|
+
|
|
52
|
+
# A named visibility call (`private :x`, `module_function :x`, `private_class_method :x`) in a body, which
|
|
53
|
+
# {ProjectPatchedScanner.resolve_aliases} applies to the binding its name has when it runs. `visibility` is
|
|
54
|
+
# `:private`, `:protected`, `:public` or `:module_function`.
|
|
55
|
+
Pending = Data.define(:class_name, :names, :kind, :visibility, :source_path, :source_line)
|
|
56
|
+
|
|
57
|
+
module_function
|
|
58
|
+
|
|
59
|
+
# The {Pending} change a named visibility call in a body makes, or nil. The `def`-argument form
|
|
60
|
+
# (`private def x`) is {.defs}'s; a computed name is skipped, leaving the method public.
|
|
61
|
+
def pending(node, qualified_prefix, in_singleton_class, source_path)
|
|
62
|
+
return nil if qualified_prefix.empty?
|
|
63
|
+
|
|
64
|
+
visibility, side = call(node)
|
|
65
|
+
return nil if visibility.nil? || node.arguments.nil?
|
|
66
|
+
|
|
67
|
+
names = node.arguments.arguments.filter_map { |arg| Source::Literals.symbol_or_string(arg) }
|
|
68
|
+
return nil if names.empty?
|
|
69
|
+
|
|
70
|
+
Pending.new(
|
|
71
|
+
class_name: qualified_prefix.join("::"), names: names,
|
|
72
|
+
kind: side == :singleton || in_singleton_class ? :singleton : :instance, visibility: visibility,
|
|
73
|
+
source_path: source_path, source_line: node.location&.start_line || 1
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The value {.defs} records for a call written as a direct statement of a body or of the file's top level, the
|
|
78
|
+
# only calls whose named visibility change ({.pending}) or mixin edge ({Mixins.pending}) the scanner applies to
|
|
79
|
+
# the body's class. A call nested in a conditional, a block, a method body or a `Class.new` may not run, or
|
|
80
|
+
# runs on another class, so it changes nothing: the reading that answers more calls.
|
|
81
|
+
STATEMENT = Object.new.freeze
|
|
82
|
+
|
|
83
|
+
# @return the visibility of each `def` (by identity) the file's bodies give one other than public:
|
|
84
|
+
# `:private`, `:protected` or `:module_function`; and {STATEMENT} for each call that is a direct statement,
|
|
85
|
+
# and for the include an idempotence guard runs ({Mixins.guarded}).
|
|
86
|
+
def defs(root)
|
|
87
|
+
found = {}.compare_by_identity
|
|
88
|
+
# The file's top level, and through {.read_body} every class, module or `class <<` body written as a direct
|
|
89
|
+
# statement of it or of another body so read: the bodies that run whenever the file loads. A body nested in
|
|
90
|
+
# a conditional, a block or a method may not run, so none of its calls or toggles is read and its `def`s
|
|
91
|
+
# are public.
|
|
92
|
+
read_body(root.statements, found, top_level: true) if root.is_a?(Prism::ProgramNode)
|
|
93
|
+
found
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Applies a {Pending} change to the binding each of its names has: `bound` maps `[class_name, name, kind]` to
|
|
97
|
+
# that binding and `homes` to the per-file table holding it, which is updated in place, so the change is not a
|
|
98
|
+
# second declaration. A name the class does not bind is one it inherits (`public :kp` in `class Integer` over
|
|
99
|
+
# `module Kernel; private def kp`), which Ruby copies onto the class with the new visibility: `overrides` gets
|
|
100
|
+
# `key => visibility` for {ProjectPatchedMethods#lookup_through_ancestors} to apply to the body it finds farther
|
|
101
|
+
# on. Yields each copy that binds (`module_function :x`'s singleton), at the call's line, for the caller to
|
|
102
|
+
# bind.
|
|
103
|
+
def apply(pending, bound, homes, overrides)
|
|
104
|
+
pending.names.each do |name|
|
|
105
|
+
key = [pending.class_name, name, pending.kind]
|
|
106
|
+
entry = bound[key]
|
|
107
|
+
next overrides[key] = pending.visibility if entry.nil?
|
|
108
|
+
|
|
109
|
+
changed, *copies = applied(entry, pending.visibility)
|
|
110
|
+
bound[key] = homes[key][key] = changed
|
|
111
|
+
copies.each { |copy| yield copy.with(source_path: pending.source_path, source_line: pending.source_line) }
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# `entry` as `visibility` leaves it, then any copy that binds: `module_function` makes an instance method
|
|
116
|
+
# private and binds a public singleton copy, as Ruby does.
|
|
117
|
+
def applied(entry, visibility)
|
|
118
|
+
return [entry.with(visibility: visibility)] unless visibility == :module_function
|
|
119
|
+
|
|
120
|
+
copies = [entry.with(visibility: :private)]
|
|
121
|
+
copies << entry.with(kind: :singleton, visibility: :public) if entry.kind == :instance
|
|
122
|
+
copies
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# The visibility call `node` is, as `[visibility, side]`, or nil: `side` is `:instance` for the
|
|
126
|
+
# {INSTANCE_CALLS} and `:singleton` for the {SINGLETON_CALLS}.
|
|
127
|
+
def call(node)
|
|
128
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
129
|
+
return nil unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
|
|
130
|
+
|
|
131
|
+
if (visibility = INSTANCE_CALLS[node.name])
|
|
132
|
+
[visibility, :instance]
|
|
133
|
+
elsif (visibility = SINGLETON_CALLS[node.name])
|
|
134
|
+
[visibility, :singleton]
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# The include an idempotence guard runs (`X.include(M) unless X.include?(M)`) is read as a direct statement.
|
|
139
|
+
def mark_guarded(stmt, found, top_level)
|
|
140
|
+
guarded = Mixins.guarded(stmt, top_level)
|
|
141
|
+
found[guarded] = STATEMENT if guarded
|
|
142
|
+
end
|
|
143
|
+
private_class_method :mark_guarded
|
|
144
|
+
|
|
145
|
+
# `top_level` is true for the file's own statements only; a body it reads is a class or module body.
|
|
146
|
+
def read_body(body, found, top_level: false) # rubocop:disable Metrics/PerceivedComplexity
|
|
147
|
+
default = :public
|
|
148
|
+
direct_statements(body).each do |stmt|
|
|
149
|
+
found[stmt] = STATEMENT if stmt.is_a?(Prism::CallNode)
|
|
150
|
+
visibility, side = call(stmt)
|
|
151
|
+
if visibility && ModuleFunctionState.bare?(stmt)
|
|
152
|
+
default = visibility if side == :instance
|
|
153
|
+
elsif visibility
|
|
154
|
+
mark_arguments(stmt, visibility, side, found)
|
|
155
|
+
elsif stmt.is_a?(Prism::DefNode)
|
|
156
|
+
mark(stmt, default, found) if stmt.receiver.nil?
|
|
157
|
+
elsif BODIES.any? { |kind| stmt.is_a?(kind) }
|
|
158
|
+
read_body(stmt.body, found)
|
|
159
|
+
else
|
|
160
|
+
mark_guarded(stmt, found, top_level)
|
|
161
|
+
default = :public if bare_call_nested?(stmt)
|
|
162
|
+
mark_nested(stmt, default, found)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
private_class_method :read_body
|
|
167
|
+
|
|
168
|
+
def direct_statements(body)
|
|
169
|
+
body = body.statements if body.is_a?(Prism::BeginNode)
|
|
170
|
+
body.is_a?(Prism::StatementsNode) ? body.body : []
|
|
171
|
+
end
|
|
172
|
+
private_class_method :direct_statements
|
|
173
|
+
|
|
174
|
+
def mark_arguments(call, visibility, side, found)
|
|
175
|
+
call.arguments&.arguments&.each do |arg|
|
|
176
|
+
next unless arg.is_a?(Prism::DefNode)
|
|
177
|
+
next unless (side == :singleton) == arg.receiver.is_a?(Prism::SelfNode)
|
|
178
|
+
|
|
179
|
+
mark(arg, visibility, found)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
private_class_method :mark_arguments
|
|
183
|
+
|
|
184
|
+
def mark(def_node, visibility, found)
|
|
185
|
+
found[def_node] = visibility unless visibility == :public
|
|
186
|
+
end
|
|
187
|
+
private_class_method :mark
|
|
188
|
+
|
|
189
|
+
def mark_nested(node, default, found)
|
|
190
|
+
return unless node.is_a?(Prism::Node)
|
|
191
|
+
return if OPAQUE.any? { |kind| node.is_a?(kind) } && !node.is_a?(Prism::DefNode)
|
|
192
|
+
|
|
193
|
+
if node.is_a?(Prism::DefNode)
|
|
194
|
+
mark(node, default, found) if node.receiver.nil?
|
|
195
|
+
return
|
|
196
|
+
end
|
|
197
|
+
node.rigor_each_child { |child| mark_nested(child, default, found) }
|
|
198
|
+
end
|
|
199
|
+
private_class_method :mark_nested
|
|
200
|
+
|
|
201
|
+
def bare_call_nested?(node)
|
|
202
|
+
return false unless node.is_a?(Prism::Node)
|
|
203
|
+
return false if OPAQUE.any? { |kind| node.is_a?(kind) }
|
|
204
|
+
|
|
205
|
+
visibility, = call(node)
|
|
206
|
+
return true if visibility && ModuleFunctionState.bare?(node)
|
|
207
|
+
|
|
208
|
+
node.compact_child_nodes.any? { |child| bare_call_nested?(child) }
|
|
209
|
+
end
|
|
210
|
+
private_class_method :bare_call_nested?
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "scope_indexer"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Inference
|
|
7
|
+
# Issue #1614 — the cross-file discovery seed the paths that type a project WITHOUT a `check` run hand their
|
|
8
|
+
# per-file scopes: {Protection::DiscoverySeed} (the protection scan, `rigor coverage`) and
|
|
9
|
+
# {ParameterInferenceCollector} (the ADR-67 call-site pre-pass, which `check` itself runs under
|
|
10
|
+
# `parameter_inference:`). Each used to keep its own list of tables, and each dropped some that
|
|
11
|
+
# `Analysis::Runner#project_scope_seed_tables` carries, so a cross-file constant read `30` under `check` and
|
|
12
|
+
# `Dynamic` under coverage, and a parameter whose call sites pass it went uninferred.
|
|
13
|
+
#
|
|
14
|
+
# This module is the one list both paths read. {Analysis::Runner#project_scope_seed_tables} stays the
|
|
15
|
+
# reference; `spec/rigor/inference/project_seed_tables_spec.rb` compares the two over a project that fills
|
|
16
|
+
# every table, and requires each table the runner seeds and this one does not to be in {NOT_SEEDED}, with its
|
|
17
|
+
# reason.
|
|
18
|
+
module ProjectSeedTables
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# The def-index slots that seed a per-file scope as they are, each with the {Scope::DiscoveryIndex} member it
|
|
22
|
+
# fills.
|
|
23
|
+
SLOTS = {
|
|
24
|
+
def_nodes: :discovered_def_nodes, def_nestings: :discovered_def_nestings,
|
|
25
|
+
singleton_def_nodes: :discovered_singleton_def_nodes, def_sources: :discovered_def_sources,
|
|
26
|
+
singleton_def_sources: :discovered_singleton_def_sources, superclasses: :discovered_superclasses,
|
|
27
|
+
header_nestings: :discovered_header_nestings, includes: :discovered_includes,
|
|
28
|
+
prepends: :discovered_prepends, extends: :discovered_extends, unpositioned_mixins: :unpositioned_mixins,
|
|
29
|
+
class_sources: :discovered_class_sources, method_visibilities: :discovered_method_visibilities,
|
|
30
|
+
methods: :discovered_methods, parameter_envelopes: :discovered_parameter_envelopes,
|
|
31
|
+
data_member_layouts: :data_member_layouts, struct_member_layouts: :struct_member_layouts,
|
|
32
|
+
deferred_ranges: :discovered_deferred_ranges, refinements: :discovered_refinements,
|
|
33
|
+
global_write_census: :discovered_global_write_census
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# The tables `Runner#project_scope_seed_tables` can seed that {.from_index} leaves out, and why. Parity is over
|
|
37
|
+
# the table LIST: the runner's opt-in additions to a table's content are not reproduced here.
|
|
38
|
+
# `discovered_refinements` is the project's alone (the runner unions in the refinements of gems under
|
|
39
|
+
# `dependencies.source_inference:`), and no `discovery_seed:` base is merged in (the runner can start from one).
|
|
40
|
+
NOT_SEEDED = {
|
|
41
|
+
run_generation: "the identity token of a check run's return memo; these paths run no check",
|
|
42
|
+
constant_sources: "read by dependency recording and the #663 pre_eval retraction; a seed carries it only " \
|
|
43
|
+
"when asked (DiscoverySeed.tables_for_buffer(constant_sources: true), the editor publish)",
|
|
44
|
+
param_inferred_types: "the parameter-inference output itself; each caller adds its own " \
|
|
45
|
+
"(DiscoverySeed.build, coverage under parameter_inference:, the collector's last round)"
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
# The seed tables over `paths`, from one parse of each file.
|
|
49
|
+
def for_paths(paths)
|
|
50
|
+
from_index(ScopeIndexer.discovered_project_index_for_paths(paths))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Maps a `{ classes:, def_index: }` discovery index onto {Scope::DiscoveryIndex} member names. Empty tables are
|
|
54
|
+
# dropped so an empty seed stays `{}`; a paired member and its sibling are dropped only together.
|
|
55
|
+
#
|
|
56
|
+
# `in_source_constants` is the project's literal constant table alone: the `pre_eval:` publication a `check` run
|
|
57
|
+
# joins to it types each listed file's writes against the RBS environment, which these paths do not build first.
|
|
58
|
+
def from_index(index)
|
|
59
|
+
def_index = index.fetch(:def_index)
|
|
60
|
+
tables = { discovered_classes: index.fetch(:classes) }
|
|
61
|
+
SLOTS.each { |slot, member| tables[member] = def_index.fetch(slot) }
|
|
62
|
+
tables.merge!(constant_tables(def_index.fetch(:constant_values), def_index.fetch(:constant_writes)))
|
|
63
|
+
tables.merge!(def_index.fetch(:siblings))
|
|
64
|
+
Scope::DiscoveryIndex.compact_pairs(
|
|
65
|
+
tables.reject { |name, table| !Scope::DiscoveryIndex.paired?(name) && (table.nil? || table.empty?) }
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def constant_tables(values, writes)
|
|
70
|
+
{
|
|
71
|
+
in_source_constants: values,
|
|
72
|
+
published_constant_names: published_constant_names(values),
|
|
73
|
+
constant_writers: constant_writers(writes),
|
|
74
|
+
constant_shadowers: constant_shadowers(writes)
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Issue #644 — the last segments of the published constant table, the run-wide half of
|
|
79
|
+
# `Scope#published_constant?`.
|
|
80
|
+
def published_constant_names(values)
|
|
81
|
+
values.keys.to_set { |name| name.split("::").last }.freeze
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Issue #617 — the censused names that bind, grouped by last segment (`Scope#bound_constant_names`). A name some
|
|
85
|
+
# write other than a memo `||=` touches always binds. A memo-only name binds once memos of its segment span two
|
|
86
|
+
# files: either may load first and set the constant the other then reads, so only a memo no other file shares
|
|
87
|
+
# keeps the idiom's reading. Files are counted expanded, because discovery can walk one file under two
|
|
88
|
+
# spellings (`lib/b.rb`, `./lib/b.rb`).
|
|
89
|
+
def constant_writers(writes)
|
|
90
|
+
memo_files = memo_files_by_segment(writes)
|
|
91
|
+
writes.each_with_object({}) do |(name, by_path), index|
|
|
92
|
+
segment = name.split("::").last
|
|
93
|
+
next if memo_only?(by_path) && memo_files.fetch(segment).size < 2
|
|
94
|
+
|
|
95
|
+
(index[segment] ||= []) << name
|
|
96
|
+
end.each_value(&:freeze).freeze
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Issue #1290 — the censused names some write other than a memo `||=` assigns, grouped by last segment
|
|
100
|
+
# (`Scope#shadowing_constant_names`): the ones that exist whether or not an outer constant of the name does, and
|
|
101
|
+
# so stop the lexical ladder above it. A memo-only name is left out however many files memoize it, unlike in
|
|
102
|
+
# {.constant_writers}: `X ||= v` resolves `X` through the very lookup whose lower rung the stop would replace,
|
|
103
|
+
# so wherever that rung answers, the memo finds it and never creates the name.
|
|
104
|
+
def constant_shadowers(writes)
|
|
105
|
+
writes.each_with_object({}) do |(name, by_path), index|
|
|
106
|
+
(index[name.split("::").last] ||= []) << name unless memo_only?(by_path)
|
|
107
|
+
end.each_value(&:freeze).freeze
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def memo_files_by_segment(writes)
|
|
111
|
+
writes.each_with_object({}) do |(name, by_path), files|
|
|
112
|
+
set = (files[name.split("::").last] ||= Set.new)
|
|
113
|
+
by_path.each { |path, descriptor| set << File.absolute_path(path) if memo_descriptor?(descriptor) }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def memo_only?(by_path) = by_path.each_value.all? { |descriptor| memo_descriptor?(descriptor) }
|
|
118
|
+
def memo_descriptor?(descriptor) = descriptor == ScopeIndexer::CONSTANT_MEMO
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "../type"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Inference
|
|
9
|
+
# Issue #2127 — whether a call to a PROJECT method may run the block it passes more than once, read off the
|
|
10
|
+
# callee's body. {BlockRepetition.may_repeat?} answers only for the core iterators {ClosureEscapeAnalyzer}
|
|
11
|
+
# catalogues, so `def yielder = (yield; yield)` or the idiomatic wrapper `def each = @items.each { |i| yield i }`
|
|
12
|
+
# counted as running the block once, and a `return` in the block that only a later run reaches (`each { return
|
|
13
|
+
# false if (seen += 1) > 3 }`) never joined the method's return type.
|
|
14
|
+
#
|
|
15
|
+
# Only the later-run `return` pass asks this (`StatementEvaluator#collect_repeated_block_returns`); the block's
|
|
16
|
+
# entry scopes and the ADR-56 write-back keep {BlockRepetition}'s rule. That pass only widens the method's
|
|
17
|
+
# return type, so the rule here errs toward "repeats". The callee repeats when its body, outside a nested `def`
|
|
18
|
+
# (a `yield` in a class body is a syntax error inside a method):
|
|
19
|
+
#
|
|
20
|
+
# - holds two or more sites that run the block (`yield`, or `call` / `yield` / `===` / `[]` on the `&blk`
|
|
21
|
+
# parameter), or one inside a loop, a block or a lambda, which may run it many times; or
|
|
22
|
+
# - hands the block on (`each(&blk)`, `each(&)`, `...` forwarding) or reads the `&blk` parameter any other way
|
|
23
|
+
# (stores it, passes it as an argument), so code the walk does not see may run it any number of times.
|
|
24
|
+
#
|
|
25
|
+
# A callee the analysis cannot resolve to a project `def` (an unknown receiver, a method defined by
|
|
26
|
+
# metaprogramming) answers false, as before.
|
|
27
|
+
module ProjectYielder
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
BLOCK_RUN_CALLS = Set[:call, :yield, :===, :[]].freeze
|
|
31
|
+
REPEATING_NODES = Set[Prism::WhileNode, Prism::UntilNode, Prism::ForNode, Prism::BlockNode,
|
|
32
|
+
Prism::LambdaNode].freeze
|
|
33
|
+
private_constant :BLOCK_RUN_CALLS, :REPEATING_NODES
|
|
34
|
+
|
|
35
|
+
def may_run_block_repeatedly?(call_node, receiver_type, scope)
|
|
36
|
+
def_node = callee_def(call_node, receiver_type, scope)
|
|
37
|
+
return false unless def_node.is_a?(Prism::DefNode) && def_node.body
|
|
38
|
+
|
|
39
|
+
repeats?(def_node)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Issue #2301 — whether the callee hands the block it is called with on to another call (`g(x, &)`,
|
|
45
|
+
# `g(x, &blk)`), outside a nested `def`. The block-return pass types such a forward's target `yield` as the
|
|
46
|
+
# caller's block ({ExpressionTyper#forwarded_block_return_type}), and the target may run it any number of
|
|
47
|
+
# times, so the caller's block is typed as one that may repeat. Answers false where the callee does not resolve.
|
|
48
|
+
def hands_block_on?(call_node, receiver_type, scope)
|
|
49
|
+
def_node = callee_def(call_node, receiver_type, scope)
|
|
50
|
+
return false unless def_node.is_a?(Prism::DefNode) && def_node.body
|
|
51
|
+
|
|
52
|
+
hand_off_in?(def_node.body, def_node.parameters&.block&.name)
|
|
53
|
+
rescue StandardError
|
|
54
|
+
false
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def hand_off_in?(node, block_name)
|
|
58
|
+
return false if node.is_a?(Prism::DefNode)
|
|
59
|
+
return true if node.is_a?(Prism::BlockArgumentNode) && forwards_block?(node, block_name)
|
|
60
|
+
|
|
61
|
+
node.compact_child_nodes.any? { |child| hand_off_in?(child, block_name) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# The `def` an instance call on a project class, a singleton call on one, or an implicit-self call to a
|
|
65
|
+
# top-level method resolves to.
|
|
66
|
+
def callee_def(call_node, receiver_type, scope)
|
|
67
|
+
name = call_node.name
|
|
68
|
+
found =
|
|
69
|
+
case receiver_type
|
|
70
|
+
when Type::Nominal then scope.user_def_through_ancestors(receiver_type.class_name, name).first
|
|
71
|
+
when Type::Singleton then scope.singleton_def_for(receiver_type.class_name, name)
|
|
72
|
+
end
|
|
73
|
+
found || (call_node.receiver.nil? ? scope.top_level_def_for(name) : nil)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def repeats?(def_node)
|
|
77
|
+
block_param = def_node.parameters&.block
|
|
78
|
+
counter = { sites: 0, repeats: false }
|
|
79
|
+
walk(def_node.body, block_param&.name, false, counter)
|
|
80
|
+
counter[:repeats] || counter[:sites] >= 2
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def walk(node, block_name, repeating, counter)
|
|
84
|
+
return if counter[:repeats] || node.is_a?(Prism::DefNode)
|
|
85
|
+
|
|
86
|
+
skipped = visit(node, block_name, repeating, counter)
|
|
87
|
+
inner = repeating || REPEATING_NODES.include?(node.class)
|
|
88
|
+
node.compact_child_nodes.each do |child|
|
|
89
|
+
walk(child, block_name, inner, counter) unless child.equal?(skipped)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Counts `node` and answers the child the walk must not visit: the `blk` receiver of a `blk.call`, which is a
|
|
94
|
+
# run, not a hand-off.
|
|
95
|
+
def visit(node, block_name, repeating, counter)
|
|
96
|
+
case node
|
|
97
|
+
when Prism::YieldNode then run_site(repeating, counter)
|
|
98
|
+
when Prism::ForwardingArgumentsNode then counter[:repeats] = true
|
|
99
|
+
when Prism::BlockArgumentNode then counter[:repeats] = true if forwards_block?(node, block_name)
|
|
100
|
+
when Prism::CallNode
|
|
101
|
+
return unless block_run_call?(node, block_name)
|
|
102
|
+
|
|
103
|
+
run_site(repeating, counter)
|
|
104
|
+
node.receiver
|
|
105
|
+
when Prism::LocalVariableReadNode then counter[:repeats] = true if block_name && node.name == block_name
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def run_site(repeating, counter)
|
|
110
|
+
counter[:sites] += 1
|
|
111
|
+
counter[:repeats] = true if repeating
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# `blk.call` and its spellings.
|
|
115
|
+
def block_run_call?(node, block_name)
|
|
116
|
+
receiver = node.receiver
|
|
117
|
+
!block_name.nil? && receiver.is_a?(Prism::LocalVariableReadNode) && receiver.name == block_name &&
|
|
118
|
+
BLOCK_RUN_CALLS.include?(node.name)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# `&blk` or an anonymous `&` handed to another call.
|
|
122
|
+
def forwards_block?(node, block_name)
|
|
123
|
+
expression = node.expression
|
|
124
|
+
expression.nil? || (expression.is_a?(Prism::LocalVariableReadNode) && expression.name == block_name)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|