rigortype 0.3.6 → 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 +1 -1
- 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 +409 -11
- data/docs/manual/02-cli-reference.md +16 -7
- data/docs/manual/05-inspecting-types.md +20 -8
- data/docs/manual/07-plugins.md +4 -0
- data/docs/manual/plugins/rigor-actionpack.md +61 -0
- data/docs/manual/plugins/rigor-activerecord.md +74 -1
- data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
- 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/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 +14 -5
- data/lib/rigor/analysis/reachability/scan.rb +7 -3
- data/lib/rigor/analysis/result.rb +32 -0
- data/lib/rigor/analysis/run_cache_key.rb +41 -1
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +93 -1
- 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 +147 -10
- 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/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/coverage_command.rb +23 -18
- data/lib/rigor/cli/coverage_mutation.rb +13 -17
- data/lib/rigor/cli/coverage_scan.rb +47 -6
- 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.rb +1 -1
- data/lib/rigor/configuration.rb +7 -4
- data/lib/rigor/effects/plugin_facts.rb +1 -1
- data/lib/rigor/effects/scanner.rb +2 -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/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/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
- metadata +69 -3
|
@@ -9,6 +9,7 @@ require_relative "../source/node_children"
|
|
|
9
9
|
require_relative "../cache/file_digest"
|
|
10
10
|
require_relative "anonymous_meta_class"
|
|
11
11
|
require_relative "def_handle"
|
|
12
|
+
require_relative "index_write_widening"
|
|
12
13
|
require_relative "mutation_widening"
|
|
13
14
|
require_relative "narrowing"
|
|
14
15
|
require_relative "statement_evaluator"
|
|
@@ -37,8 +38,14 @@ module Rigor
|
|
|
37
38
|
# Nodes that are not part of the program subtree (e.g. synthesised virtual nodes that the caller looks up after the
|
|
38
39
|
# fact) yield the `default_scope`. The returned Hash is mutable in principle but callers MUST treat it as read-only;
|
|
39
40
|
# the indexer itself never exposes a way to update it past construction.
|
|
40
|
-
# rubocop:disable Metrics/ModuleLength
|
|
41
|
+
# rubocop:disable-next Metrics/ModuleLength
|
|
41
42
|
module ScopeIndexer
|
|
43
|
+
# Issue #644 — the census descriptor for a constant write that publishes no value: an unfoldable or
|
|
44
|
+
# non-literal rvalue, an operator / multi-assign / dynamic-path form, or a name this file writes twice.
|
|
45
|
+
# A Symbol, so it is distinguishable from a `[literal]` descriptor by class alone and rides the ADR-85
|
|
46
|
+
# seed bundle through `Marshal` unchanged.
|
|
47
|
+
CONSTANT_UNPUBLISHABLE = :unpublishable
|
|
48
|
+
|
|
42
49
|
module_function
|
|
43
50
|
|
|
44
51
|
# Build the scope index for a Prism program subtree.
|
|
@@ -78,7 +85,14 @@ module Rigor
|
|
|
78
85
|
# Slice 7 phase 6. Same pre-pass shape for cvars (per class) and globals (program-wide). Globals are also
|
|
79
86
|
# materialised into the top-level scope's `globals` map so reads at the top level (and in CLI probes that do not
|
|
80
87
|
# enter a method body) observe the precise type without consulting the accumulator on every lookup.
|
|
81
|
-
|
|
88
|
+
# Issue #540 — a literal-shape carrier assigned to a constant or class variable is only as good as
|
|
89
|
+
# the file's OWN mutations of it: `ISPELL_STATUS = {}` with a sibling method writing
|
|
90
|
+
# `ISPELL_STATUS[:key] = param` must not fold reads through the closed empty shape. One census walk
|
|
91
|
+
# collects the mutated names; the two accumulators below widen their entries to the Dynamic-wrapped
|
|
92
|
+
# form so reads stay honest without licensing the negative rules.
|
|
93
|
+
literal_mutations = collect_literal_receiver_mutations(root)
|
|
94
|
+
|
|
95
|
+
class_cvars = widen_mutated_cvars(build_class_cvar_index(root, seeded_scope), literal_mutations[:cvars])
|
|
82
96
|
seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(class_cvars: class_cvars))
|
|
83
97
|
program_globals = build_program_global_index(root, seeded_scope)
|
|
84
98
|
seeded_scope = seeded_scope.with_discovery(seeded_scope.discovery.with(program_globals: program_globals))
|
|
@@ -92,9 +106,10 @@ module Rigor
|
|
|
92
106
|
# `pre_eval:` constant seed `Runner#project_scope_seed_tables` applies). Same-file declarations are the
|
|
93
107
|
# most specific authority, exactly as `merged_classes` above resolves the same collision. Without the
|
|
94
108
|
# merge, the assignment below would silently drop the project seed on every file.
|
|
95
|
-
in_source_constants =
|
|
96
|
-
|
|
97
|
-
|
|
109
|
+
in_source_constants = widen_mutated_constants(
|
|
110
|
+
build_in_source_constants(root, seeded_scope), literal_mutations[:constants]
|
|
111
|
+
)
|
|
112
|
+
seeded_scope = seed_constant_tables(seeded_scope, default_scope, in_source_constants, root)
|
|
98
113
|
|
|
99
114
|
# Slice 7 phase 12. In-source method discovery. Walks every class/module body for `Prism::DefNode` and
|
|
100
115
|
# recognised `define_method` calls and records the introduced method names. `rigor check` consults the table to
|
|
@@ -158,15 +173,11 @@ module Rigor
|
|
|
158
173
|
# is merged UNDER the cross-file `discovered_def_index_for_paths` seed carried on `default_scope` — same-file
|
|
159
174
|
# declarations win per entry, the cross-file seed supplies sibling-file ancestors.
|
|
160
175
|
def merge_project_method_indexes(seeded_scope, default_scope, root, file_def_nodes)
|
|
161
|
-
def_nodes = default_scope
|
|
162
|
-
file_def_nodes
|
|
163
|
-
) { |_class, cross_file, per_file| cross_file.merge(per_file) }
|
|
176
|
+
def_nodes, def_nestings = merge_def_node_tables(default_scope, root, file_def_nodes)
|
|
164
177
|
singleton_def_nodes = default_scope.discovered_singleton_def_nodes.merge(
|
|
165
178
|
build_discovered_singleton_def_nodes(root)
|
|
166
179
|
) { |_class, cross_file, per_file| cross_file.merge(per_file) }
|
|
167
|
-
superclasses = default_scope
|
|
168
|
-
build_discovered_superclasses(root, default_scope.source_path)
|
|
169
|
-
)
|
|
180
|
+
superclasses, header_nestings = merge_ancestry_tables(default_scope, root)
|
|
170
181
|
includes = default_scope.discovered_includes.merge(
|
|
171
182
|
build_discovered_includes(root)
|
|
172
183
|
) { |_class, cross_file, per_file| (cross_file + per_file).uniq }
|
|
@@ -179,11 +190,19 @@ module Rigor
|
|
|
179
190
|
# authoritative).
|
|
180
191
|
data_member_layouts, struct_member_layouts = merge_member_layouts(default_scope, root)
|
|
181
192
|
|
|
193
|
+
# #526 — this file's extends folded against the MERGED instance def-nodes (so `extend M` sees a
|
|
194
|
+
# sibling-file M through the cross-file seed). The project-wide fold in {#finalize_def_index}
|
|
195
|
+
# covers cross-file CONSUMERS; this covers the file that declares the extend.
|
|
196
|
+
methods_table = fold_per_file_extends(root, def_nodes, singleton_def_nodes, seeded_scope)
|
|
197
|
+
|
|
182
198
|
seeded_scope.with_discovery(
|
|
183
199
|
seeded_scope.discovery.with(
|
|
200
|
+
discovered_methods: methods_table,
|
|
184
201
|
discovered_def_nodes: def_nodes,
|
|
202
|
+
discovered_def_nestings: def_nestings,
|
|
185
203
|
discovered_singleton_def_nodes: singleton_def_nodes,
|
|
186
204
|
discovered_superclasses: superclasses,
|
|
205
|
+
discovered_header_nestings: header_nestings,
|
|
187
206
|
discovered_includes: includes,
|
|
188
207
|
discovered_method_visibilities: method_visibilities,
|
|
189
208
|
data_member_layouts: data_member_layouts,
|
|
@@ -192,6 +211,54 @@ module Rigor
|
|
|
192
211
|
)
|
|
193
212
|
end
|
|
194
213
|
|
|
214
|
+
# The as-written superclass table and its issue #682 header-nesting twin, each merged over the cross-file
|
|
215
|
+
# seed. Returned as a pair for the same reason {#merge_def_node_tables} is: both come from ONE walk of the
|
|
216
|
+
# file, so a caller cannot pair a superclass name with a nesting recorded by a different parse.
|
|
217
|
+
def merge_ancestry_tables(default_scope, root)
|
|
218
|
+
file_superclasses, file_header_nestings = build_superclass_tables(root, default_scope.source_path)
|
|
219
|
+
[default_scope.discovered_superclasses.merge(file_superclasses),
|
|
220
|
+
merge_header_nestings(default_scope.discovery.discovered_header_nestings.dup,
|
|
221
|
+
file_header_nestings).freeze]
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# The instance-side def-node table and its issue #681 nesting twin, each merged over the cross-file seed.
|
|
225
|
+
# Returned as a pair so the two stay written together: a node the merge keeps must be the same object the
|
|
226
|
+
# nesting table keys, and they are only that if both halves take the same file's walk.
|
|
227
|
+
def merge_def_node_tables(default_scope, root, file_def_nodes)
|
|
228
|
+
def_nodes = default_scope.discovered_def_nodes.merge(
|
|
229
|
+
file_def_nodes
|
|
230
|
+
) { |_class, cross_file, per_file| cross_file.merge(per_file) }
|
|
231
|
+
[def_nodes,
|
|
232
|
+
merge_def_nestings(default_scope.discovery.discovered_def_nestings, build_def_nestings(root))]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Issue #681 — the per-file nesting table over the cross-file seed. Both are keyed by node identity, so
|
|
236
|
+
# a same-file declaration and its cross-file twin are distinct keys and the merge order is immaterial;
|
|
237
|
+
# the copy exists only so the seed stays frozen. Skipped when the file declares no `def` at all — since
|
|
238
|
+
# issue #716 a top-level `def` records its empty chain, so a file of plain top-level helpers no longer
|
|
239
|
+
# takes that path and pays one copy of the seed, the same as any file that declares a method.
|
|
240
|
+
def merge_def_nestings(seed, file_nestings)
|
|
241
|
+
return seed if file_nestings.empty?
|
|
242
|
+
return file_nestings if seed.empty?
|
|
243
|
+
|
|
244
|
+
merged = {}.compare_by_identity
|
|
245
|
+
merged.merge!(seed)
|
|
246
|
+
merged.merge!(file_nestings)
|
|
247
|
+
merged.freeze
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The per-file half of the #526 fold: mutable copies of the merged tables take the extends, and the
|
|
251
|
+
# existence table (already seeded onto the scope) is rebuilt only when the fold touched it.
|
|
252
|
+
def fold_per_file_extends(root, def_nodes, singleton_def_nodes, seeded_scope)
|
|
253
|
+
extends = build_discovered_extends(root)
|
|
254
|
+
methods_table = seeded_scope.discovered_methods
|
|
255
|
+
return methods_table if extends.empty?
|
|
256
|
+
|
|
257
|
+
mutable_methods = methods_table.transform_values(&:dup)
|
|
258
|
+
fold_extends_into_singleton_tables(extends, def_nodes, singleton_def_nodes, mutable_methods)
|
|
259
|
+
mutable_methods
|
|
260
|
+
end
|
|
261
|
+
|
|
195
262
|
# ADR-48 — the per-file Data + Struct member-layout tables, each merged OVER the cross-file seed so a same-file
|
|
196
263
|
# declaration wins for its own classes. Returned as a pair to keep {#merge_project_method_indexes} under the
|
|
197
264
|
# method-size budget.
|
|
@@ -220,6 +287,7 @@ module Rigor
|
|
|
220
287
|
method_assign_effects = build_method_assign_effects(root)
|
|
221
288
|
walk_class_ivars(root, [], default_scope, accumulator, mutated_ivars,
|
|
222
289
|
read_before_write, init_writes, method_assign_effects)
|
|
290
|
+
record_aliased_ivar_mutations!(root, mutated_ivars)
|
|
223
291
|
widen_mutated_ivar_entries!(accumulator, mutated_ivars)
|
|
224
292
|
contribute_read_before_write_nil!(accumulator, read_before_write, init_writes)
|
|
225
293
|
accumulator.transform_values(&:freeze).freeze
|
|
@@ -261,6 +329,119 @@ module Rigor
|
|
|
261
329
|
end
|
|
262
330
|
end
|
|
263
331
|
|
|
332
|
+
# An ivar a method RETURNS leaves the object, and every mutation of the returned reference mutates the ivar:
|
|
333
|
+
# `(bucket_for(entry)[key] ||= {})[name] = row` fills whichever of `@self_rows` / `@path_rows` / `@result_rows`
|
|
334
|
+
# `bucket_for` handed back. {record_ivar_mutator_call} cannot see it — the mutation's receiver is a `CallNode`,
|
|
335
|
+
# not an `InstanceVariableReadNode` — so those ivars kept the empty `HashShape` their `initialize` gave them and
|
|
336
|
+
# `@path_rows.empty?` folded to `Constant[true]` on a hash the class fills. Rigor's own
|
|
337
|
+
# `lib/rigor/effects/plugin_facts.rb` is the worked site.
|
|
338
|
+
#
|
|
339
|
+
# Scope is deliberately narrow: the mutation receiver must be a SELF-call (an explicit receiver is a different
|
|
340
|
+
# object), the callee must be an instance method of the same class, and only ivars in the callee's RETURN
|
|
341
|
+
# position count — an ivar merely read in its body (`@rows[owner]` returns the value, not the hash) does not
|
|
342
|
+
# escape. Over-recording here would widen a shape carrier that nothing mutates, which costs precision on every
|
|
343
|
+
# reader in the class.
|
|
344
|
+
#
|
|
345
|
+
# Runs after the main walk so the returned-ivar summary is complete regardless of definition order, and gated on
|
|
346
|
+
# a class actually having a returning method — the common class contributes nothing and pays one `defs` lookup.
|
|
347
|
+
def record_aliased_ivar_mutations!(root, mutated_ivars)
|
|
348
|
+
defs = collect_class_method_defs(root)
|
|
349
|
+
returned = returned_ivars_by_method(defs)
|
|
350
|
+
return if returned.empty?
|
|
351
|
+
|
|
352
|
+
defs.each do |class_name, methods|
|
|
353
|
+
per_class = returned[class_name]
|
|
354
|
+
next if per_class.nil?
|
|
355
|
+
|
|
356
|
+
methods.each_value do |def_node|
|
|
357
|
+
gather_aliased_mutations(def_node.body, class_name, per_class, mutated_ivars)
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# `{class_name => {method_name => Set<ivar name>}}` for methods that hand an ivar back, dropping the empty sets
|
|
363
|
+
# so the caller's `next if per_class.nil?` skips a whole class in one lookup.
|
|
364
|
+
def returned_ivars_by_method(defs)
|
|
365
|
+
defs.each_with_object({}) do |(class_name, methods), acc|
|
|
366
|
+
methods.each do |method_name, def_node|
|
|
367
|
+
ivars = returned_ivars(def_node)
|
|
368
|
+
(acc[class_name] ||= {})[method_name] = ivars unless ivars.empty?
|
|
369
|
+
end
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
# The ivars `def_node` can hand back: an explicit `return @x` anywhere in the body, and the body's tail
|
|
374
|
+
# expression when it is (or resolves through branch nodes to) a bare ivar read. A tail that computes something
|
|
375
|
+
# FROM an ivar (`@rows[k]`, `@rows.dup`) hands back a different object and is not an escape.
|
|
376
|
+
def returned_ivars(def_node)
|
|
377
|
+
body = def_node.body
|
|
378
|
+
return EMPTY_IVAR_SET if body.nil?
|
|
379
|
+
|
|
380
|
+
found = Set.new
|
|
381
|
+
collect_explicit_return_ivars(body, found)
|
|
382
|
+
tail_value_nodes(body).each { |node| found << node.name if node.is_a?(Prism::InstanceVariableReadNode) }
|
|
383
|
+
found
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def collect_explicit_return_ivars(node, found)
|
|
387
|
+
return unless node.is_a?(Prism::Node)
|
|
388
|
+
return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
|
|
389
|
+
|
|
390
|
+
if node.is_a?(Prism::ReturnNode)
|
|
391
|
+
args = node.arguments&.arguments
|
|
392
|
+
found << args.first.name if args && args.size == 1 && args.first.is_a?(Prism::InstanceVariableReadNode)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
node.rigor_each_child { |c| collect_explicit_return_ivars(c, found) }
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
# The expressions a body can evaluate to, resolving through the branch nodes whose value is a sub-expression.
|
|
399
|
+
# Depth-capped for the same reason every other walk here is: a pathologically nested tail is not worth unbounded
|
|
400
|
+
# recursion, and stopping early only under-collects (fewer escapes recorded, never more).
|
|
401
|
+
def tail_value_nodes(node, depth = 0)
|
|
402
|
+
return [] unless node.is_a?(Prism::Node)
|
|
403
|
+
return [] if depth >= TAIL_VALUE_DEPTH_CAP
|
|
404
|
+
|
|
405
|
+
children = tail_value_children(node)
|
|
406
|
+
return [node] if children.nil?
|
|
407
|
+
|
|
408
|
+
children.flat_map { |child| tail_value_nodes(child, depth + 1) }
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
# The children a node's own value comes from, or nil when the node IS the value. Split out from
|
|
412
|
+
# {tail_value_nodes} so the recursion stays one line and this stays a lookup table.
|
|
413
|
+
def tail_value_children(node)
|
|
414
|
+
case node
|
|
415
|
+
when Prism::StatementsNode then [node.body.last]
|
|
416
|
+
when Prism::ParenthesesNode then [node.body]
|
|
417
|
+
when Prism::BeginNode, Prism::ElseNode, Prism::WhenNode, Prism::InNode then [node.statements]
|
|
418
|
+
when Prism::IfNode then [node.statements, node.subsequent]
|
|
419
|
+
when Prism::UnlessNode then [node.statements, node.else_clause]
|
|
420
|
+
when Prism::CaseNode then node.conditions + [node.else_clause]
|
|
421
|
+
when Prism::OrNode, Prism::AndNode then [node.left, node.right]
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Records every mutation in `node` whose receiver is a self-call to one of `per_class`'s returning methods.
|
|
426
|
+
def gather_aliased_mutations(node, class_name, per_class, mutated_ivars)
|
|
427
|
+
return unless node.is_a?(Prism::Node)
|
|
428
|
+
|
|
429
|
+
mutator, receiver = mutation_target(node)
|
|
430
|
+
escaped_ivars_for_receiver(receiver, per_class)&.each do |ivar_name|
|
|
431
|
+
per_ivar = ((mutated_ivars[class_name] ||= {})[ivar_name] ||= Set.new)
|
|
432
|
+
per_ivar << mutator
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
node.rigor_each_child { |c| gather_aliased_mutations(c, class_name, per_class, mutated_ivars) }
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# The ivars a mutation receiver can be, when the receiver is a same-class self-call. Nil for every other shape.
|
|
439
|
+
def escaped_ivars_for_receiver(receiver, per_class)
|
|
440
|
+
return nil unless receiver.is_a?(Prism::CallNode) && receiver.receiver.nil?
|
|
441
|
+
|
|
442
|
+
per_class[receiver.name]
|
|
443
|
+
end
|
|
444
|
+
|
|
264
445
|
# Walks the post-collected accumulator and widens any Tuple / HashShape entry for an ivar that observed a mutator
|
|
265
446
|
# call anywhere in the same class body. The mutation evidence comes from `gather_ivar_writes` recording every
|
|
266
447
|
# `@ivar.<method>(...)` call whose method is in `MutationWidening::ARRAY_MUTATORS` or `HASH_MUTATORS`.
|
|
@@ -317,15 +498,64 @@ module Rigor
|
|
|
317
498
|
end
|
|
318
499
|
end
|
|
319
500
|
|
|
501
|
+
# Issue #681 — the scope a census pre-pass types an rvalue under. The census walks never enter a body
|
|
502
|
+
# through `StatementEvaluator`, so nothing stamped the declaration's `Module.nesting` on the scope they
|
|
503
|
+
# build and `Reflection.lexical_nesting_chain` fell back to peeling `self_type`'s qualified name. That
|
|
504
|
+
# peel cannot tell a compact `class Admin::Census` (nesting `[Admin::Census]`, so a bare `Post` names
|
|
505
|
+
# `::Post`) from the nested spelling (nesting `[Admin::Census, Admin]`, where it names `Admin::Post`),
|
|
506
|
+
# so `@post = Post.new` and `DEFAULT = Post` were recorded under the wrong class and every later read
|
|
507
|
+
# answered it. The prefix these walks already thread IS the chain — `["Admin::Census"]` for the compact
|
|
508
|
+
# form, `["Admin", "Census"]` for the nested one — so the fact was in hand and only the stamp was
|
|
509
|
+
# missing.
|
|
510
|
+
def census_body_scope(default_scope, qualified_prefix, self_type)
|
|
511
|
+
default_scope.with_self_type(self_type)
|
|
512
|
+
.with_lexical_nesting(census_nesting(default_scope, qualified_prefix))
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# The chain a census walk answers `Module.nesting` with: the one {#scope_entering_declaration} threaded
|
|
516
|
+
# on the scope when a header was entered, and only failing that the prefix-derived approximation below.
|
|
517
|
+
def census_nesting(scope, qualified_prefix)
|
|
518
|
+
scope&.lexical_nesting || lexical_nesting_for_prefix(qualified_prefix)
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
# The scope a census walk threads into the body a `class` / `module` header opens: the scope it already
|
|
522
|
+
# threads, carrying that body's real `Module.nesting`.
|
|
523
|
+
#
|
|
524
|
+
# Issue #708 — the qualified prefix stopped being sufficient to DERIVE that chain. A rooted header
|
|
525
|
+
# RESETS the prefix (`class ::Rooted` inside `module Outer` prefixes `["Rooted"]`), so `Outer` is absent
|
|
526
|
+
# from it entirely and no function of the prefix alone recovers it, while Ruby keeps `Outer` on the
|
|
527
|
+
# ladder as a live rung. `Source::ConstantPath.pushed_nesting` — the one function both declaration walks
|
|
528
|
+
# already push with — has that case right, so the chain travels on the scope these walks thread anyway
|
|
529
|
+
# rather than through every walk's parameter list, and stays a single owner ([#652](https://github.com/rigortype/rigor/issues/652)).
|
|
530
|
+
def scope_entering_declaration(default_scope, constant_path)
|
|
531
|
+
return default_scope if default_scope.nil?
|
|
532
|
+
|
|
533
|
+
chain = Source::ConstantPath.pushed_nesting(default_scope.lexical_nesting || EMPTY_NESTING,
|
|
534
|
+
constant_path)
|
|
535
|
+
chain ? default_scope.with_lexical_nesting(chain) : default_scope
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Ruby's `Module.nesting` for a body enclosed by `qualified_prefix`, innermost first: each entry is the
|
|
539
|
+
# prefix truncated at one declaration, joined, so the compact spelling contributes exactly one entry and
|
|
540
|
+
# the nested spelling one per `class` / `module` keyword.
|
|
541
|
+
#
|
|
542
|
+
# **This is an approximation, and only correct when no ROOTED header encloses the body** — a rooted
|
|
543
|
+
# header resets the prefix without resetting Ruby's nesting, so the entries beneath the reset are not
|
|
544
|
+
# in the prefix to be truncated out of it (#708). It remains the answer for a caller that threaded no
|
|
545
|
+
# chain, where it is what the code did before and never a new firing; the walks that reach a body
|
|
546
|
+
# thread the real chain via {#scope_entering_declaration}.
|
|
547
|
+
def lexical_nesting_for_prefix(qualified_prefix)
|
|
548
|
+
(1..qualified_prefix.size).map { |n| qualified_prefix.first(n).join("::") }.reverse.freeze
|
|
549
|
+
end
|
|
550
|
+
|
|
320
551
|
def walk_class_ivars(node, qualified_prefix, default_scope, accumulator, mutated_ivars, # rubocop:disable Metrics/ParameterLists
|
|
321
552
|
read_before_write = nil, init_writes = nil, method_assign_effects = nil)
|
|
322
553
|
return unless node.is_a?(Prism::Node)
|
|
323
554
|
|
|
324
555
|
case node
|
|
325
556
|
when Prism::ClassNode, Prism::ModuleNode
|
|
326
|
-
|
|
327
|
-
if
|
|
328
|
-
child_prefix = qualified_prefix + [name]
|
|
557
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
558
|
+
if child_prefix
|
|
329
559
|
if node.body
|
|
330
560
|
# Class-body level `@x = nil` writes don't initialise instance ivars at runtime (the class's own singleton
|
|
331
561
|
# ivars and the instance's ivars are separate stores), but they signal "the author KNOWS @x could be nil"
|
|
@@ -335,7 +565,8 @@ module Rigor
|
|
|
335
565
|
# class-body level then writes `@x = SomeClass.new` inside an instance method gains an unjustified nil
|
|
336
566
|
# widening at every read.
|
|
337
567
|
collect_class_body_ivar_writes(node.body, child_prefix.join("::"), init_writes) if init_writes
|
|
338
|
-
walk_class_ivars(node.body, child_prefix,
|
|
568
|
+
walk_class_ivars(node.body, child_prefix,
|
|
569
|
+
scope_entering_declaration(default_scope, node.constant_path), accumulator,
|
|
339
570
|
mutated_ivars, read_before_write, init_writes, method_assign_effects)
|
|
340
571
|
end
|
|
341
572
|
return
|
|
@@ -372,7 +603,7 @@ module Rigor
|
|
|
372
603
|
else
|
|
373
604
|
Type::Combinator.nominal_of(class_name)
|
|
374
605
|
end
|
|
375
|
-
body_scope = default_scope
|
|
606
|
+
body_scope = census_body_scope(default_scope, qualified_prefix, self_type)
|
|
376
607
|
|
|
377
608
|
# C2 — transient `@x = nil` dead-write elimination. When a method body opens with an unconditional `@x = nil`
|
|
378
609
|
# (defensive init) and then *definitely* reassigns `@x` to a non-nil value on every completing path (a later
|
|
@@ -404,7 +635,7 @@ module Rigor
|
|
|
404
635
|
|
|
405
636
|
class_name = qualified_prefix.join("::")
|
|
406
637
|
self_type = Type::Combinator.nominal_of(class_name)
|
|
407
|
-
body_scope = default_scope
|
|
638
|
+
body_scope = census_body_scope(default_scope, qualified_prefix, self_type)
|
|
408
639
|
|
|
409
640
|
gather_ivar_writes(block_node.body, body_scope, class_name, accumulator,
|
|
410
641
|
EMPTY_GUARDED_IVARS, mutated_ivars)
|
|
@@ -576,6 +807,14 @@ module Rigor
|
|
|
576
807
|
private_constant :IVAR_BARRIER_NODES
|
|
577
808
|
|
|
578
809
|
EMPTY_GUARDED_IVARS = Set.new.freeze
|
|
810
|
+
|
|
811
|
+
# Shared empty result for a def that returns no ivar — the overwhelmingly common case.
|
|
812
|
+
EMPTY_IVAR_SET = Set.new.freeze
|
|
813
|
+
private_constant :EMPTY_IVAR_SET
|
|
814
|
+
|
|
815
|
+
# Branch nesting a tail expression is followed through. Stopping early only UNDER-collects escapes.
|
|
816
|
+
TAIL_VALUE_DEPTH_CAP = 8
|
|
817
|
+
private_constant :TAIL_VALUE_DEPTH_CAP
|
|
579
818
|
private_constant :EMPTY_GUARDED_IVARS
|
|
580
819
|
|
|
581
820
|
def gather_ivar_writes(node, scope, class_name, accumulator, guarded_ivars = EMPTY_GUARDED_IVARS,
|
|
@@ -597,7 +836,7 @@ module Rigor
|
|
|
597
836
|
# records — an unanalyzable multi-write means unknown, not nil).
|
|
598
837
|
record_multi_write_ivars(node, scope, class_name, accumulator)
|
|
599
838
|
|
|
600
|
-
record_ivar_mutator_call(node, class_name, mutated_ivars) if mutated_ivars
|
|
839
|
+
record_ivar_mutator_call(node, class_name, mutated_ivars) if mutated_ivars
|
|
601
840
|
|
|
602
841
|
# Don't recurse into nested defs, classes, or modules; their ivars belong to their own enclosing class.
|
|
603
842
|
return if IVAR_BARRIER_NODES.any? { |klass| node.is_a?(klass) }
|
|
@@ -618,14 +857,33 @@ module Rigor
|
|
|
618
857
|
# {.widen_mutated_ivar_entries!}). Always-safe to over- collect: any name that the widening primitive declines is
|
|
619
858
|
# ignored at finalization.
|
|
620
859
|
def record_ivar_mutator_call(node, class_name, mutated_ivars)
|
|
621
|
-
receiver = node
|
|
860
|
+
method_name, receiver = mutation_target(node)
|
|
861
|
+
return if method_name.nil?
|
|
622
862
|
return unless receiver.is_a?(Prism::InstanceVariableReadNode)
|
|
623
|
-
return unless MutationWidening::ARRAY_MUTATORS.include?(
|
|
624
|
-
MutationWidening::HASH_MUTATORS.include?(
|
|
863
|
+
return unless MutationWidening::ARRAY_MUTATORS.include?(method_name) ||
|
|
864
|
+
MutationWidening::HASH_MUTATORS.include?(method_name)
|
|
625
865
|
|
|
626
866
|
per_class = (mutated_ivars[class_name] ||= {})
|
|
627
867
|
per_ivar = (per_class[receiver.name] ||= Set.new)
|
|
628
|
-
per_ivar <<
|
|
868
|
+
per_ivar << method_name
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# `[mutator name, receiver node]` for a node that mutates its receiver, `nil` otherwise.
|
|
872
|
+
#
|
|
873
|
+
# `@h[k] ||= v` and its `&&=` / `+=` siblings store through `[]=` but are not `[]=` CallNodes; missing them left
|
|
874
|
+
# an `@h = {}` mutated only that way carrying its empty `HashShape` into every sibling method, so `@h.empty?`
|
|
875
|
+
# folded to `Constant[true]` on a hash the class fills. `IndexWriteWidening::NODE_CLASSES` owns that list; it is
|
|
876
|
+
# spelled out here as an explicit disjunction because that is what narrows `node` to something with a
|
|
877
|
+
# `#receiver` — `NODE_CLASSES.any? { … }` reads as a call on `Prism::Node` and Rigor rejects it, correctly.
|
|
878
|
+
def mutation_target(node)
|
|
879
|
+
return [node.name, node.receiver] if node.is_a?(Prism::CallNode)
|
|
880
|
+
|
|
881
|
+
if node.is_a?(Prism::IndexOrWriteNode) || node.is_a?(Prism::IndexAndWriteNode) ||
|
|
882
|
+
node.is_a?(Prism::IndexOperatorWriteNode)
|
|
883
|
+
return [IndexWriteWidening::MUTATOR, node.receiver]
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
nil
|
|
629
887
|
end
|
|
630
888
|
|
|
631
889
|
# Walk an `IfNode` / `UnlessNode` so writes inside the THEN body that look like defensive ivar initialisation gain
|
|
@@ -771,11 +1029,8 @@ module Rigor
|
|
|
771
1029
|
|
|
772
1030
|
case root
|
|
773
1031
|
when Prism::ClassNode, Prism::ModuleNode
|
|
774
|
-
|
|
775
|
-
if
|
|
776
|
-
child = prefix + [name]
|
|
777
|
-
collect_class_method_defs(root.body, child, acc)
|
|
778
|
-
end
|
|
1032
|
+
child = Source::ConstantPath.declaration_prefix(prefix, root.constant_path)
|
|
1033
|
+
collect_class_method_defs(root.body, child, acc) if child && root.body
|
|
779
1034
|
return acc
|
|
780
1035
|
when Prism::DefNode
|
|
781
1036
|
(acc[prefix.join("::")] ||= {})[root.name] = root unless prefix.empty? || root.receiver
|
|
@@ -1135,10 +1390,12 @@ module Rigor
|
|
|
1135
1390
|
|
|
1136
1391
|
case node
|
|
1137
1392
|
when Prism::ClassNode, Prism::ModuleNode
|
|
1138
|
-
|
|
1139
|
-
if
|
|
1140
|
-
|
|
1141
|
-
|
|
1393
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
1394
|
+
if child_prefix
|
|
1395
|
+
if node.body
|
|
1396
|
+
walk_class_cvars(node.body, child_prefix,
|
|
1397
|
+
scope_entering_declaration(default_scope, node.constant_path), accumulator)
|
|
1398
|
+
end
|
|
1142
1399
|
return
|
|
1143
1400
|
end
|
|
1144
1401
|
when Prism::DefNode
|
|
@@ -1155,7 +1412,8 @@ module Rigor
|
|
|
1155
1412
|
return if def_node.body.nil? || qualified_prefix.empty?
|
|
1156
1413
|
|
|
1157
1414
|
class_name = qualified_prefix.join("::")
|
|
1158
|
-
body_scope = default_scope
|
|
1415
|
+
body_scope = census_body_scope(default_scope, qualified_prefix,
|
|
1416
|
+
Type::Combinator.nominal_of(class_name))
|
|
1159
1417
|
gather_cvar_writes(def_node.body, body_scope, class_name, accumulator)
|
|
1160
1418
|
end
|
|
1161
1419
|
|
|
@@ -1202,58 +1460,422 @@ module Rigor
|
|
|
1202
1460
|
# Slice 7 phase 9 — in-source constant value pre-pass. Walks the entire program (top-level AND inside class /
|
|
1203
1461
|
# module / def bodies) for `Prism::ConstantWriteNode` and `Prism::ConstantPathWriteNode`, types each rvalue, and
|
|
1204
1462
|
# accumulates by qualified name. Constants defined inside a class body are qualified with the surrounding class
|
|
1205
|
-
# path;
|
|
1463
|
+
# path; a constant written via a path resolves its own namespace through the enclosing nesting first
|
|
1464
|
+
# ({#constant_path_write_key}) — which is where this walk and the
|
|
1465
|
+
# [#644](https://github.com/rigortype/rigor/issues/644) publication census below stopped agreeing about
|
|
1466
|
+
# a path write's name.
|
|
1206
1467
|
def build_in_source_constants(root, default_scope)
|
|
1207
1468
|
accumulator = {}
|
|
1208
1469
|
walk_constant_writes(root, [], default_scope, accumulator)
|
|
1209
1470
|
accumulator.freeze
|
|
1210
1471
|
end
|
|
1211
1472
|
|
|
1473
|
+
# Issue #540 — the mutation census behind the two wideners. Walks the whole tree once, tracking the
|
|
1474
|
+
# lexical class/module prefix, and records every node that MUTATES a constant-read or
|
|
1475
|
+
# class-variable-read receiver: the `Index{Or,And,Operator}Write` family, and a CallNode whose name
|
|
1476
|
+
# is a known in-place mutator (`MutationWidening`'s tables) or a plain attribute/index writer
|
|
1477
|
+
# (`name=` / `[]=`). Non-mutating reads never register, so `VERSION`-style constants keep their fold.
|
|
1478
|
+
#
|
|
1479
|
+
# @return [Hash{Symbol => Object}] `:constants` — a Set of candidate qualified names (each mutation
|
|
1480
|
+
# contributes every lexical-resolution candidate, `A::B::C` outward to bare `C`, mirroring how the
|
|
1481
|
+
# reads resolve); `:cvars` — `{class_name => Set[Symbol]}`.
|
|
1482
|
+
def collect_literal_receiver_mutations(root)
|
|
1483
|
+
census = { constants: Set.new, cvars: Hash.new { |h, k| h[k] = Set.new } }
|
|
1484
|
+
walk_literal_receiver_mutations(root, [], census, EMPTY_NESTING)
|
|
1485
|
+
census
|
|
1486
|
+
end
|
|
1487
|
+
|
|
1488
|
+
def walk_literal_receiver_mutations(node, qualified_prefix, census, nesting = EMPTY_NESTING)
|
|
1489
|
+
return unless node.is_a?(Prism::Node)
|
|
1490
|
+
|
|
1491
|
+
case node
|
|
1492
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
1493
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
1494
|
+
if child_prefix
|
|
1495
|
+
if node.body
|
|
1496
|
+
walk_literal_receiver_mutations(node.body, child_prefix, census,
|
|
1497
|
+
Source::ConstantPath.pushed_nesting(nesting,
|
|
1498
|
+
node.constant_path) || nesting)
|
|
1499
|
+
end
|
|
1500
|
+
return
|
|
1501
|
+
end
|
|
1502
|
+
else
|
|
1503
|
+
record_literal_receiver_mutation(node, qualified_prefix, nesting, census)
|
|
1504
|
+
end
|
|
1505
|
+
|
|
1506
|
+
node.rigor_each_child { |child| walk_literal_receiver_mutations(child, qualified_prefix, census, nesting) }
|
|
1507
|
+
end
|
|
1508
|
+
|
|
1509
|
+
# Two parameters because the two arms key on two DIFFERENT TABLES, not because the values differ:
|
|
1510
|
+
# `nesting.first` and `qualified_prefix.join("::")` are in fact always equal, since
|
|
1511
|
+
# `.declaration_prefix` and `.pushed_nesting` branch on the same `rooted?` and qualify against the same
|
|
1512
|
+
# parent. `qualified_prefix` is what a class variable must key on because it is the join key with
|
|
1513
|
+
# `build_class_cvar_index`, which derives its own key from the same `declaration_prefix` — so the two
|
|
1514
|
+
# tables agree by construction. `nesting` is what a constant name must resolve through because the
|
|
1515
|
+
# ladder walks every rung, not just the innermost, and a rooted header drops the outer rungs from the
|
|
1516
|
+
# prefix while Ruby keeps them ([#708](https://github.com/rigortype/rigor/issues/708)).
|
|
1517
|
+
def record_literal_receiver_mutation(node, qualified_prefix, nesting, census)
|
|
1518
|
+
receiver = mutating_receiver_of(node)
|
|
1519
|
+
return if receiver.nil?
|
|
1520
|
+
|
|
1521
|
+
case receiver
|
|
1522
|
+
when Prism::ConstantReadNode
|
|
1523
|
+
constant_mutation_candidates(receiver.name.to_s, nesting, census[:constants])
|
|
1524
|
+
when Prism::ConstantPathNode
|
|
1525
|
+
# The lexical candidates a PATH spelling reaches, for the same reason the bare arm above takes
|
|
1526
|
+
# them: `Holder::TABLE[:k] = 1` inside `module Admin` mutates whatever `Holder::TABLE` resolves
|
|
1527
|
+
# to there, and since [#690](https://github.com/rigortype/rigor/issues/690) the write accumulator
|
|
1528
|
+
# keys that entry `Admin::Holder::TABLE`. Recording only the as-written name left the mutated
|
|
1529
|
+
# constant matched by nothing and its closed empty shape intact — the very fold #540 exists to
|
|
1530
|
+
# retract. Over-recording only widens, which is the direction this census is allowed to err in.
|
|
1531
|
+
full = Source::ConstantPath.qualified_name_or_nil(receiver)
|
|
1532
|
+
constant_mutation_candidates(full, nesting, census[:constants]) if full
|
|
1533
|
+
when Prism::ClassVariableReadNode
|
|
1534
|
+
census[:cvars][qualified_prefix.join("::")] << receiver.name unless qualified_prefix.empty?
|
|
1535
|
+
end
|
|
1536
|
+
end
|
|
1537
|
+
|
|
1538
|
+
# The receiver a node mutates, or nil when the node is not a mutation.
|
|
1539
|
+
def mutating_receiver_of(node)
|
|
1540
|
+
case node
|
|
1541
|
+
when Prism::IndexOrWriteNode, Prism::IndexAndWriteNode, Prism::IndexOperatorWriteNode
|
|
1542
|
+
node.receiver
|
|
1543
|
+
when Prism::CallNode
|
|
1544
|
+
return nil if node.receiver.nil?
|
|
1545
|
+
return node.receiver if node.attribute_write?
|
|
1546
|
+
return node.receiver if MutationWidening::ARRAY_MUTATORS.include?(node.name) ||
|
|
1547
|
+
MutationWidening::HASH_MUTATORS.include?(node.name)
|
|
1548
|
+
|
|
1549
|
+
nil
|
|
1550
|
+
end
|
|
1551
|
+
end
|
|
1552
|
+
|
|
1553
|
+
# Every lexical-resolution candidate for a bare constant name written at `nesting`, innermost first
|
|
1554
|
+
# — `["A::B", "A"]` + `C` yields `A::B::C`, `A::C`, `C` — so the widener matches whichever form the
|
|
1555
|
+
# write accumulator recorded.
|
|
1556
|
+
#
|
|
1557
|
+
# Issue #708 — this reads Ruby's `Module.nesting`, which the enclosing qualified prefix used to be a
|
|
1558
|
+
# faithful stand-in for. It stopped being one when a rooted header gained the power to RESET that
|
|
1559
|
+
# prefix: a bare `TABLE[:k] = 1` inside `class ::Rooted` in `module Outer` mutates `Outer::TABLE`, and
|
|
1560
|
+
# candidates built from the reset prefix never name it, so the closed shape was never widened and
|
|
1561
|
+
# `Outer::TABLE.empty?` folded to `true` on a table the program fills.
|
|
1562
|
+
def constant_mutation_candidates(base_name, nesting, into)
|
|
1563
|
+
nesting.each { |entry| into << "#{entry}::#{base_name}" }
|
|
1564
|
+
into << base_name
|
|
1565
|
+
end
|
|
1566
|
+
|
|
1567
|
+
def widen_mutated_constants(accumulator, mutated_names)
|
|
1568
|
+
return accumulator if mutated_names.empty?
|
|
1569
|
+
|
|
1570
|
+
widened = accumulator.dup
|
|
1571
|
+
mutated_names.each do |name|
|
|
1572
|
+
existing = widened[name]
|
|
1573
|
+
next if existing.nil? || existing.is_a?(Type::Dynamic)
|
|
1574
|
+
|
|
1575
|
+
widened[name] = Type::Combinator.dynamic(existing)
|
|
1576
|
+
end
|
|
1577
|
+
widened.freeze
|
|
1578
|
+
end
|
|
1579
|
+
|
|
1580
|
+
def widen_mutated_cvars(accumulator, mutated_by_class)
|
|
1581
|
+
return accumulator if mutated_by_class.empty?
|
|
1582
|
+
|
|
1583
|
+
widened = accumulator.dup
|
|
1584
|
+
mutated_by_class.each do |class_name, names|
|
|
1585
|
+
table = widened[class_name]
|
|
1586
|
+
next if table.nil?
|
|
1587
|
+
|
|
1588
|
+
updated = table.dup
|
|
1589
|
+
names.each do |cvar|
|
|
1590
|
+
existing = updated[cvar]
|
|
1591
|
+
next if existing.nil? || existing.is_a?(Type::Dynamic)
|
|
1592
|
+
|
|
1593
|
+
updated[cvar] = Type::Combinator.dynamic(existing)
|
|
1594
|
+
end
|
|
1595
|
+
widened[class_name] = updated.freeze
|
|
1596
|
+
end
|
|
1597
|
+
widened.freeze
|
|
1598
|
+
end
|
|
1599
|
+
|
|
1212
1600
|
# Issue #352 — folds the project-wide `pre_eval:` constant seed under this file's own table. Returns the
|
|
1213
1601
|
# per-file table unchanged (same frozen object) when nothing was seeded, so a run without `pre_eval:`
|
|
1214
1602
|
# constants allocates and compares exactly what it did before.
|
|
1603
|
+
# Issue #644 — merges the per-file constant table OVER the project seed and records, alongside it,
|
|
1604
|
+
# which of those names THIS FILE declares (as last segments). The second table is the other half of
|
|
1605
|
+
# `Scope#published_constant?`: a project-published constant the analysed file also assigns is one its
|
|
1606
|
+
# author can see, so the truthiness rules keep firing on it. Both are seeded here, where the per-file
|
|
1607
|
+
# table is still separable from the project seed it is about to merge over.
|
|
1608
|
+
def seed_constant_tables(seeded_scope, default_scope, in_source_constants, root)
|
|
1609
|
+
merged = merge_seeded_constants(default_scope.in_source_constants, in_source_constants)
|
|
1610
|
+
seeded_scope.with_discovery(
|
|
1611
|
+
seeded_scope.discovery.with(in_source_constants: merged,
|
|
1612
|
+
local_constant_names: local_constant_name_set(root, default_scope))
|
|
1613
|
+
)
|
|
1614
|
+
end
|
|
1615
|
+
|
|
1616
|
+
# The QUALIFIED names this file assigns that the project also published, frozen. Read from the same
|
|
1617
|
+
# {#constant_writes_for_file} census the cross-file table is built from, not from the typed per-file
|
|
1618
|
+
# table: the typed walk cannot see a multi-assign / `self::` / operator-write target, so deriving the
|
|
1619
|
+
# exemption from it would be a second source of truth blind to exactly the forms the census exists to
|
|
1620
|
+
# catch. Gated on the project having published anything at all, so a project with no cross-file value
|
|
1621
|
+
# constants pays no extra walk and allocates nothing; the result is filtered to the published names, so
|
|
1622
|
+
# what it holds is bounded by what the exemption can ever be asked about.
|
|
1623
|
+
def local_constant_name_set(root, default_scope)
|
|
1624
|
+
published = default_scope.published_constant_names
|
|
1625
|
+
return Scope::DiscoveryIndex::EMPTY.local_constant_names if published.empty?
|
|
1626
|
+
|
|
1627
|
+
names = constant_writes_for_file(root).keys.select { |name| published.include?(name.split("::").last) }
|
|
1628
|
+
names.empty? ? Scope::DiscoveryIndex::EMPTY.local_constant_names : names.to_set.freeze
|
|
1629
|
+
end
|
|
1630
|
+
|
|
1215
1631
|
def merge_seeded_constants(seeded, per_file)
|
|
1216
1632
|
return per_file if seeded.nil? || seeded.empty?
|
|
1217
1633
|
|
|
1218
1634
|
seeded.merge(per_file).freeze
|
|
1219
1635
|
end
|
|
1220
1636
|
|
|
1221
|
-
|
|
1637
|
+
# Issue #705 — the block-taking calls that swap `self` for the RECEIVER: Ruby evaluates the block with
|
|
1638
|
+
# the receiver as `self` and leaves `Module.nesting` untouched, so `self::BAR = …` inside one names the
|
|
1639
|
+
# receiver's `BAR` while a bare `BAR = …` in the same block still names the enclosing namespace's.
|
|
1640
|
+
SELF_REBINDING_EVAL_CALLS = %i[class_eval module_eval class_exec module_exec].freeze
|
|
1641
|
+
private_constant :SELF_REBINDING_EVAL_CALLS
|
|
1642
|
+
|
|
1643
|
+
# …and the ones whose block `self` this walk cannot name. An `instance_eval` / `instance_exec` body
|
|
1644
|
+
# evaluates on the RECEIVER OBJECT, which is a class or module only when the receiver happens to be one
|
|
1645
|
+
# (`Klass.instance_eval { self::BAR = 1 }` defines `Klass::BAR`; `obj.instance_eval` raises `TypeError`),
|
|
1646
|
+
# and a `define_method` body on the receiver at call time. A syntactic walk cannot tell those apart, so
|
|
1647
|
+
# the write is declined — gradual for the legal case, and no guess for the rest.
|
|
1648
|
+
OPAQUE_SELF_BLOCK_CALLS = %i[instance_eval instance_exec define_method].freeze
|
|
1649
|
+
private_constant :OPAQUE_SELF_BLOCK_CALLS
|
|
1650
|
+
|
|
1651
|
+
# The sentinel for "`self` was rebound to something no class/module name reaches". A `self::BAR = …`
|
|
1652
|
+
# under it is DECLINED — the same answer a dynamic base takes, for the same reason.
|
|
1653
|
+
OPAQUE_SELF = :__rigor_opaque_self__
|
|
1654
|
+
private_constant :OPAQUE_SELF
|
|
1655
|
+
|
|
1656
|
+
# The owner whose constants the census spells BARE — `Object`'s constants are the top-level ones.
|
|
1657
|
+
OBJECT_OWNER = "Object"
|
|
1658
|
+
private_constant :OBJECT_OWNER
|
|
1659
|
+
|
|
1660
|
+
def walk_constant_writes(node, qualified_prefix, default_scope, accumulator, self_owner = nil)
|
|
1222
1661
|
return unless node.is_a?(Prism::Node)
|
|
1223
1662
|
|
|
1224
1663
|
case node
|
|
1225
1664
|
when Prism::ClassNode, Prism::ModuleNode
|
|
1226
|
-
|
|
1227
|
-
if
|
|
1228
|
-
|
|
1229
|
-
|
|
1665
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
1666
|
+
if child_prefix
|
|
1667
|
+
if node.body
|
|
1668
|
+
walk_constant_writes(node.body, child_prefix,
|
|
1669
|
+
scope_entering_declaration(default_scope, node.constant_path),
|
|
1670
|
+
accumulator)
|
|
1671
|
+
end
|
|
1230
1672
|
return
|
|
1231
1673
|
end
|
|
1232
1674
|
when Prism::ConstantWriteNode
|
|
1233
|
-
record_constant_write(node, qualified_prefix, default_scope, accumulator,
|
|
1675
|
+
record_constant_write(node, qualified_prefix, default_scope, accumulator,
|
|
1676
|
+
qualified_write_name(qualified_prefix, node.name.to_s), self_owner)
|
|
1234
1677
|
return
|
|
1235
1678
|
when Prism::ConstantPathWriteNode
|
|
1236
|
-
full =
|
|
1237
|
-
record_constant_write(node,
|
|
1679
|
+
full = constant_path_write_key(node.target, qualified_prefix, default_scope, self_owner)
|
|
1680
|
+
record_constant_write(node, qualified_prefix, default_scope, accumulator, full, self_owner) if full
|
|
1238
1681
|
return
|
|
1239
1682
|
end
|
|
1240
1683
|
|
|
1684
|
+
walk_constant_write_children(node, qualified_prefix, default_scope, accumulator, self_owner)
|
|
1685
|
+
end
|
|
1686
|
+
|
|
1687
|
+
# Recursion into `node`'s children, swapping `self` for the ONE child a self-rebinding call evaluates
|
|
1688
|
+
# under a different receiver — its block. Every other child, and every child of a node that rebinds
|
|
1689
|
+
# nothing, keeps the `self` it was reached under.
|
|
1690
|
+
#
|
|
1691
|
+
# The block is identified by its CLASS rather than by identity against `CallNode#block`. These walks
|
|
1692
|
+
# recurse over every node kind, so `node` here is the whole `Prism::Node` union and reading a member
|
|
1693
|
+
# only a call carries off it is exactly the shape Rigor's own check rejects. The test is equivalent:
|
|
1694
|
+
# `rebound` is non-nil only where {#rebound_block_self} already saw a `Prism::BlockNode` in the `block`
|
|
1695
|
+
# field, and no other child of a call can be one — a receiver is an expression, arguments sit under
|
|
1696
|
+
# `ArgumentsNode`, and a `&blk` pass-through is a `BlockArgumentNode`.
|
|
1697
|
+
def walk_constant_write_children(node, qualified_prefix, default_scope, accumulator, self_owner)
|
|
1698
|
+
rebound = rebound_block_self(node, qualified_prefix, default_scope)
|
|
1241
1699
|
node.rigor_each_child do |child|
|
|
1242
|
-
|
|
1700
|
+
owner = rebound && child.is_a?(Prism::BlockNode) ? rebound : self_owner
|
|
1701
|
+
walk_constant_writes(child, qualified_prefix, default_scope, accumulator, owner)
|
|
1243
1702
|
end
|
|
1244
1703
|
end
|
|
1245
1704
|
|
|
1246
|
-
|
|
1247
|
-
|
|
1705
|
+
# Issue #705 — the class or module `self` names inside `node`'s BLOCK, or nil when `node` opens no
|
|
1706
|
+
# block that rebinds `self` (an ordinary block keeps the `self` it closed over, so a `self::BAR = …`
|
|
1707
|
+
# there is still the enclosing declaration's and every walk below this stays as it was).
|
|
1708
|
+
#
|
|
1709
|
+
# {OPAQUE_SELF} where the new `self` reaches no class or module name: a `class_eval` receiver that is
|
|
1710
|
+
# not a static constant path, an {OPAQUE_SELF_BLOCK_CALLS} body, and the `Class.new { … }` /
|
|
1711
|
+
# `Module.new` / `Struct.new` / `Data.define` block, whose class is named only by a constant write the
|
|
1712
|
+
# walk would have to thread down to it. Declining there costs a resolution the engine never had; naming
|
|
1713
|
+
# the LEXICAL enclosure instead is the guess that fires, because it is the one name Ruby is guaranteed
|
|
1714
|
+
# NOT to have written.
|
|
1715
|
+
def rebound_block_self(node, qualified_prefix, default_scope = nil)
|
|
1716
|
+
return nil unless node.is_a?(Prism::CallNode) && node.block.is_a?(Prism::BlockNode)
|
|
1717
|
+
return OPAQUE_SELF if OPAQUE_SELF_BLOCK_CALLS.include?(node.name) || meta_new_constant_rvalue?(node)
|
|
1718
|
+
return nil unless SELF_REBINDING_EVAL_CALLS.include?(node.name)
|
|
1719
|
+
|
|
1720
|
+
eval_receiver_self(node.receiver, qualified_prefix, default_scope)
|
|
1721
|
+
end
|
|
1722
|
+
|
|
1723
|
+
# The `self` a `Recv.class_eval { … }` block runs under. An implicit or literal `self` receiver leaves
|
|
1724
|
+
# `self` where it was — nil, so nothing about the walk changes. A static constant path resolves the way
|
|
1725
|
+
# a path write's own namespace resolves ({#resolved_write_namespace}); anything else is opaque. A nil
|
|
1726
|
+
# `default_scope` is the publication census, which has no class knowledge to resolve through and keys a
|
|
1727
|
+
# path AS WRITTEN, so the receiver keeps its spelling there too.
|
|
1728
|
+
def eval_receiver_self(receiver, qualified_prefix, default_scope)
|
|
1729
|
+
return nil if receiver.nil? || receiver.is_a?(Prism::SelfNode)
|
|
1730
|
+
|
|
1731
|
+
name = Source::ConstantPath.qualified_name_or_nil(receiver)
|
|
1732
|
+
return OPAQUE_SELF if name.nil?
|
|
1733
|
+
return name if default_scope.nil? || qualified_prefix.empty? || Source::ConstantPath.rooted?(receiver)
|
|
1734
|
+
|
|
1735
|
+
resolved_write_namespace(name, qualified_prefix, default_scope)
|
|
1736
|
+
end
|
|
1737
|
+
|
|
1738
|
+
# Issue #690 — `enclosing_prefix` is the lexical declaration prefix the write SITS UNDER, `full` the
|
|
1739
|
+
# qualified name it WRITES. The two coincide for a bare `BAR = …` and diverge for a `Foo::BAR = …`,
|
|
1740
|
+
# which is why they are separate parameters. One parameter served both roles, so the path form had to
|
|
1741
|
+
# pass it EMPTY to keep the caller-supplied name intact — and that lost the key's qualification and the
|
|
1742
|
+
# rvalue's lexical context together, in the same argument.
|
|
1743
|
+
#
|
|
1744
|
+
# Issue #705 — and the rvalue needs BOTH halves of the context separately as well, because a
|
|
1745
|
+
# `class_eval` block moves one and not the other. `Module.nesting` stays lexical, so a bare `Post` in
|
|
1746
|
+
# `Other.class_eval { … }` inside `module Admin` is still `Admin::Post`; `self` becomes the RECEIVER, so
|
|
1747
|
+
# `self` and an implicit-self call in the same block answer on `Other`. Reading the self type off
|
|
1748
|
+
# `enclosing_prefix` typed `self::REF = self` as `singleton(Admin)` and `self::V = build` through
|
|
1749
|
+
# `Admin.build` — wrong on correct code, and reachable at a reader the moment the key stopped being a
|
|
1750
|
+
# guess. `self_owner` is a String exactly when the walk resolved a rebound `self`.
|
|
1751
|
+
def record_constant_write(node, enclosing_prefix, default_scope, accumulator, full, self_owner = nil)
|
|
1752
|
+
owner = self_owner.is_a?(String) ? self_owner : enclosing_prefix.join("::")
|
|
1248
1753
|
body_scope = default_scope
|
|
1249
|
-
unless
|
|
1250
|
-
body_scope =
|
|
1754
|
+
unless owner.empty?
|
|
1755
|
+
body_scope = census_body_scope(default_scope, enclosing_prefix, Type::Combinator.singleton_of(owner))
|
|
1251
1756
|
end
|
|
1252
1757
|
rvalue_type = meta_new_constant_type(node, full) || body_scope.type_of(node.value)
|
|
1253
1758
|
existing = accumulator[full]
|
|
1254
1759
|
accumulator[full] = existing ? Type::Combinator.union(existing, rvalue_type) : rvalue_type
|
|
1255
1760
|
end
|
|
1256
1761
|
|
|
1762
|
+
# The accumulator key for a `Foo::BAR = …`. Ruby resolves the path's NAMESPACE through the nesting at
|
|
1763
|
+
# the write site, so `Holder::DEFAULT = …` inside `module Admin` names `Admin::Holder::DEFAULT`
|
|
1764
|
+
# whenever `Admin::Holder` is a class or module some source declares. Filing the entry under the
|
|
1765
|
+
# as-written spelling put it where no read of the resolved name reaches it: a read spelled
|
|
1766
|
+
# `Admin::Holder::DEFAULT` answered `Dynamic[top]`, and the in-namespace spelling fell past the
|
|
1767
|
+
# qualified rung of the lexical ladder to the bare one, hit the mis-keyed entry, and got a top-level
|
|
1768
|
+
# constant of the same name — `call.undefined-method` on correct code ([#690](https://github.com/rigortype/rigor/issues/690)).
|
|
1769
|
+
#
|
|
1770
|
+
# Two static forms keep the as-written key because their namespace is not the enclosing one: a write at
|
|
1771
|
+
# the top level, and a rooted `::Foo::BAR`, which names the top level by definition. Re-qualifying a
|
|
1772
|
+
# namespace the code does not name would file the entry under a guess.
|
|
1773
|
+
#
|
|
1774
|
+
# A target that renders no static path is NOT a third such form, and must not be handled by rendering
|
|
1775
|
+
# it leniently. `self::BAR = …` names whatever `self` is at that point — the enclosing declaration in
|
|
1776
|
+
# an ordinary body, the receiver inside a `class_eval` block ([#705](https://github.com/rigortype/rigor/issues/705)),
|
|
1777
|
+
# which is why `self_owner` travels with the walk rather than being read off `qualified_prefix`. Any
|
|
1778
|
+
# other dynamic base (`klass::BAR`) is attributable to no namespace at all and is DECLINED:
|
|
1779
|
+
# `qualified_name`'s lenient render drops the dynamic segment and yields the bare trailing name, so
|
|
1780
|
+
# recording it filed a value under a name the write never touched — and once the rvalue is typed under
|
|
1781
|
+
# the enclosing nesting, that bare key outranks an RBS top-level constant declaration and hands every
|
|
1782
|
+
# reader of the top-level name a receiver Ruby never names there.
|
|
1783
|
+
def constant_path_write_key(target, qualified_prefix, default_scope, self_owner = nil)
|
|
1784
|
+
return Source::ConstantPath.qualified_name(target) if Source::ConstantPath.rooted?(target)
|
|
1785
|
+
|
|
1786
|
+
written = Source::ConstantPath.qualified_name_or_nil(target)
|
|
1787
|
+
return dynamic_base_write_key(target, qualified_prefix, self_owner) if written.nil?
|
|
1788
|
+
return written if qualified_prefix.empty?
|
|
1789
|
+
|
|
1790
|
+
namespace, _, base = written.rpartition("::")
|
|
1791
|
+
return written if namespace.empty?
|
|
1792
|
+
|
|
1793
|
+
"#{resolved_write_namespace(namespace, qualified_prefix, default_scope)}::#{base}"
|
|
1794
|
+
end
|
|
1795
|
+
|
|
1796
|
+
# The key for a path write whose base is not a static constant path: the name `self` carries for a
|
|
1797
|
+
# `self::BAR = …`, and nil — decline to record the write at all — for every other dynamic base.
|
|
1798
|
+
def dynamic_base_write_key(target, qualified_prefix, self_owner = nil)
|
|
1799
|
+
return nil unless target.parent.is_a?(Prism::SelfNode)
|
|
1800
|
+
|
|
1801
|
+
base = target.name&.to_s
|
|
1802
|
+
base && self_write_name(qualified_prefix, self_owner, base)
|
|
1803
|
+
end
|
|
1804
|
+
|
|
1805
|
+
# The qualified name a `self::BAR = …` writes. `self_owner` is nil in the ordinary case — `self` is the
|
|
1806
|
+
# enclosing declaration, so the write takes the key a bare `BAR = …` in the same body takes — a class or
|
|
1807
|
+
# module name where a `class_eval`-family block rebound `self` to one, and {OPAQUE_SELF} where it
|
|
1808
|
+
# rebound `self` to something unnameable, which declines the write (nil) rather than filing it under a
|
|
1809
|
+
# namespace Ruby did not touch ([#705](https://github.com/rigortype/rigor/issues/705)).
|
|
1810
|
+
#
|
|
1811
|
+
# `Object` is the one owner that is not a namespace of its own: its constants ARE the top-level ones, and
|
|
1812
|
+
# every other census key spells those bare. Keying `Object::T` would file the write beside a sibling's
|
|
1813
|
+
# plain `T = 2` instead of on top of it, so the conflict between them goes undetected and one of the two
|
|
1814
|
+
# values publishes — the direction this whole issue is about.
|
|
1815
|
+
def self_write_name(qualified_prefix, self_owner, base)
|
|
1816
|
+
return qualified_write_name(qualified_prefix, base) if self_owner.nil?
|
|
1817
|
+
return nil if self_owner.equal?(OPAQUE_SELF)
|
|
1818
|
+
return base if self_owner == OBJECT_OWNER
|
|
1819
|
+
|
|
1820
|
+
"#{self_owner}::#{base}"
|
|
1821
|
+
end
|
|
1822
|
+
|
|
1823
|
+
# The namespace of a `Foo::BAR = …`, resolved the way a READ of the same spelling resolves it: the
|
|
1824
|
+
# first `<nesting entry>::Foo` that names a known class or module, innermost first, and the as-written
|
|
1825
|
+
# name when none does. Only class/module knowledge participates — a value constant owns no constants —
|
|
1826
|
+
# and the ancestor rung {Reflection.resolve_constant_type} walks is deliberately skipped, so a
|
|
1827
|
+
# namespace no source declares keeps the key it has today rather than moving to a guess.
|
|
1828
|
+
def resolved_write_namespace(namespace, qualified_prefix, scope)
|
|
1829
|
+
census_nesting(scope, qualified_prefix).each do |entry|
|
|
1830
|
+
candidate = "#{entry}::#{namespace}"
|
|
1831
|
+
return candidate if known_namespace?(candidate, scope)
|
|
1832
|
+
end
|
|
1833
|
+
namespace
|
|
1834
|
+
end
|
|
1835
|
+
|
|
1836
|
+
# True when `name` is a class or module some source declares: a project declaration in
|
|
1837
|
+
# `discovered_classes` or an RBS-known class object. The [#528](https://github.com/rigortype/rigor/issues/528)
|
|
1838
|
+
# synthesized namespace prefixes reach that table for THIS FILE only — the runner's cross-file seed
|
|
1839
|
+
# (`discovered_project_index_for_paths`) returns `collect_class_decls`'s raw declarations without the
|
|
1840
|
+
# synthesis its sibling `discovered_classes_for_paths` applies — so a namespace known only through ANOTHER
|
|
1841
|
+
# file's COMPACT declaration (`class Admin::Holder::Inner`) does not move the key, where an explicit
|
|
1842
|
+
# `module Admin; class Holder` there does. Under-resolution, the direction this probe may err in.
|
|
1843
|
+
#
|
|
1844
|
+
# The RBS half needs no dependency edge — the loaded signature set is part of the run fingerprint — but
|
|
1845
|
+
# both project answers do; see {#record_namespace_probe}.
|
|
1846
|
+
def known_namespace?(name, scope)
|
|
1847
|
+
if scope.discovered_classes.key?(name)
|
|
1848
|
+
record_namespace_probe(name, scope, hit: true)
|
|
1849
|
+
return true
|
|
1850
|
+
end
|
|
1851
|
+
|
|
1852
|
+
environment = scope.environment
|
|
1853
|
+
return true if !environment.nil? && !environment.singleton_for_name(name).nil?
|
|
1854
|
+
|
|
1855
|
+
record_namespace_probe(name, scope, hit: false)
|
|
1856
|
+
false
|
|
1857
|
+
end
|
|
1858
|
+
|
|
1859
|
+
# ADR-46 — the key this census files a path write under is a function of ANOTHER file's class
|
|
1860
|
+
# declarations, and no edge the READER records covers it: a reference's negative key is
|
|
1861
|
+
# `class:<last segment>` of the name that failed to resolve, whereas here the write's key moves because
|
|
1862
|
+
# a MIDDLE segment appeared (`Holder::DEFAULT` inside `module Admin` starts naming
|
|
1863
|
+
# `Admin::Holder::DEFAULT` the moment some file declares `Admin::Holder`). Both directions therefore
|
|
1864
|
+
# need their own edge, recorded where the probe happens: a POSITIVE one on the file declaring the
|
|
1865
|
+
# namespace we resolved through, so deleting it moves the key back, and a NEGATIVE `class:` one on
|
|
1866
|
+
# every candidate that missed, so declaring it later moves the key forward. Without them a warm
|
|
1867
|
+
# incremental run serves a cached answer computed against a namespace that has since appeared. Gated on
|
|
1868
|
+
# the recorder, which is off on every ordinary run.
|
|
1869
|
+
def record_namespace_probe(name, scope, hit:)
|
|
1870
|
+
return unless Analysis::DependencyRecorder.active?
|
|
1871
|
+
|
|
1872
|
+
if hit
|
|
1873
|
+
scope.discovered_class_sources[name]&.each { |site| Analysis::DependencyRecorder.read_site(site) }
|
|
1874
|
+
else
|
|
1875
|
+
Analysis::DependencyRecorder.read_missing(:class, name.split("::").last)
|
|
1876
|
+
end
|
|
1877
|
+
end
|
|
1878
|
+
|
|
1257
1879
|
# Survey item (e): when the rvalue is a recognised `Module.new do ... end` / `Class.new do ... end` /
|
|
1258
1880
|
# `Struct.new(*sym) do ... end` / `Data.define(*sym) do ... end` form, type the named constant as
|
|
1259
1881
|
# `Singleton[<full>]` so the discovered-method table registered under `full` becomes reachable through
|
|
@@ -1334,9 +1956,8 @@ module Rigor
|
|
|
1334
1956
|
|
|
1335
1957
|
case node
|
|
1336
1958
|
when Prism::ClassNode, Prism::ModuleNode
|
|
1337
|
-
|
|
1338
|
-
if
|
|
1339
|
-
child_prefix = qualified_prefix + [name]
|
|
1959
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
1960
|
+
if child_prefix
|
|
1340
1961
|
record_meta_superclass_members(node, child_prefix, methods_acc) if node.is_a?(Prism::ClassNode)
|
|
1341
1962
|
if node.body
|
|
1342
1963
|
walk_methods_and_def_nodes(node.body, child_prefix, false, methods_acc, def_nodes_acc, source_path)
|
|
@@ -1354,11 +1975,12 @@ module Rigor
|
|
|
1354
1975
|
when Prism::ConstantWriteNode
|
|
1355
1976
|
if meta_new_block_body(node)
|
|
1356
1977
|
child_prefix = qualified_prefix + [node.name.to_s]
|
|
1978
|
+
record_meta_members(node.value, child_prefix, methods_acc)
|
|
1357
1979
|
walk_methods_and_def_nodes(meta_new_block_body(node), child_prefix, false, methods_acc, def_nodes_acc,
|
|
1358
1980
|
source_path)
|
|
1359
|
-
# No anonymous registration here: the constant IS the name, and `StatementEvaluator`
|
|
1360
|
-
#
|
|
1361
|
-
#
|
|
1981
|
+
# No anonymous registration here: the constant IS the name, and `StatementEvaluator#eval_constant_write`
|
|
1982
|
+
# enters the body under it (#590) by asking THIS recognition (`meta_new_block_body`), so the two passes
|
|
1983
|
+
# agree on the constant name alone.
|
|
1362
1984
|
return
|
|
1363
1985
|
end
|
|
1364
1986
|
when Prism::DefNode
|
|
@@ -1390,14 +2012,30 @@ module Rigor
|
|
|
1390
2012
|
def record_call_node_methods(node, qualified_prefix, in_singleton_class, methods_acc, source_path)
|
|
1391
2013
|
record_define_method(node, qualified_prefix, in_singleton_class, methods_acc) if node.name == :define_method
|
|
1392
2014
|
record_attr_methods(node, qualified_prefix, in_singleton_class, methods_acc) if ATTR_MACROS.include?(node.name)
|
|
2015
|
+
record_module_attr_methods(node, qualified_prefix, methods_acc) if MODULE_ATTR_MACROS.key?(node.name)
|
|
2016
|
+
record_alias_method_call(node, qualified_prefix, in_singleton_class, methods_acc)
|
|
1393
2017
|
AnonymousMetaClass.name_for(node, source_path)
|
|
1394
2018
|
end
|
|
1395
2019
|
|
|
2020
|
+
# The `alias_method :new, :old` CallNode twin of {#record_alias_method} (#533): the NEW name joins
|
|
2021
|
+
# the discovered-methods table so calls to it stop reading undefined, and
|
|
2022
|
+
# {#apply_alias_def_nodes}'s map walk gives it the ORIGINAL def node for return inference.
|
|
2023
|
+
def record_alias_method_call(call_node, qualified_prefix, in_singleton_class, accumulator)
|
|
2024
|
+
return if qualified_prefix.empty?
|
|
2025
|
+
|
|
2026
|
+
names = alias_method_call_names(call_node)
|
|
2027
|
+
return if names.nil?
|
|
2028
|
+
|
|
2029
|
+
kind = in_singleton_class ? :singleton : :instance
|
|
2030
|
+
record_method_kind(accumulator, qualified_prefix.join("::"), names.first, kind)
|
|
2031
|
+
end
|
|
2032
|
+
|
|
1396
2033
|
# #319 — walks a `Class.new do ... end` / `Module.new do ... end` / `Struct.new(*sym) do ... end` /
|
|
1397
2034
|
# `Data.define(*sym) do ... end` block body as the class body it is at runtime, keyed by the call site's
|
|
1398
2035
|
# synthetic anonymous `name`; the call's other children (receiver, arguments) keep the enclosing prefix.
|
|
1399
2036
|
def walk_anonymous_meta_block(call_node, name, qualified_prefix, in_singleton_class, methods_acc,
|
|
1400
2037
|
def_nodes_acc, source_path)
|
|
2038
|
+
record_meta_members(call_node, [name], methods_acc)
|
|
1401
2039
|
call_node.rigor_each_child do |child|
|
|
1402
2040
|
if child.equal?(call_node.block)
|
|
1403
2041
|
body = call_node.block.body
|
|
@@ -1461,6 +2099,7 @@ module Rigor
|
|
|
1461
2099
|
return nil unless node.is_a?(Prism::ConstantWriteNode)
|
|
1462
2100
|
|
|
1463
2101
|
rvalue = node.value
|
|
2102
|
+
return nil unless rvalue.is_a?(Prism::CallNode)
|
|
1464
2103
|
return nil unless data_define_call?(rvalue) ||
|
|
1465
2104
|
struct_new_call?(rvalue) ||
|
|
1466
2105
|
module_new_call?(rvalue) ||
|
|
@@ -1473,22 +2112,61 @@ module Rigor
|
|
|
1473
2112
|
# the subclass that no `def` / `attr_*` declares. Register them in the discovered-methods existence table so an
|
|
1474
2113
|
# implicit-self read of a member inside the class body is known to exist — both for the existing undefined-method
|
|
1475
2114
|
# suppression and for the ADR-24 slice-4 self-call recorder, which must treat a synthesized member as an existing
|
|
1476
|
-
# method, not an unresolved call.
|
|
1477
|
-
# `ConstantWriteNode` branch's block recursion; its members type `self` as `Object`, out of scope here.
|
|
2115
|
+
# method, not an unresolved call.
|
|
1478
2116
|
def record_meta_superclass_members(class_node, qualified_prefix, accumulator)
|
|
1479
|
-
superclass
|
|
1480
|
-
|
|
2117
|
+
record_meta_members(class_node.superclass, qualified_prefix, accumulator, allow_outer_block: false)
|
|
2118
|
+
end
|
|
2119
|
+
|
|
2120
|
+
# The registration itself, shared with the two BLOCK forms — `Const = Struct.new(:a) do … end` from the
|
|
2121
|
+
# `ConstantWriteNode` branch and the anonymous `Struct.new(:a) do … end` from {#walk_anonymous_meta_block}
|
|
2122
|
+
# (#590). Their bodies are entered as class bodies, so a member read inside one dispatches on the struct's
|
|
2123
|
+
# own class and must find the reader there: a member that shadows a `Kernel` private (`lambda`) otherwise
|
|
2124
|
+
# falls through to `Kernel#lambda`, and `lambda.upcase` reports an undefined method on `Proc` — a wrong-type
|
|
2125
|
+
# diagnostic on correct code. No-op for `Class.new` / `Module.new` and for a factory whose members are not
|
|
2126
|
+
# literal Symbols (nothing to register).
|
|
2127
|
+
def record_meta_members(factory_call, qualified_prefix, accumulator, allow_outer_block: true)
|
|
2128
|
+
factory_call = resolve_meta_factory_call(factory_call, allow_outer_block: allow_outer_block)
|
|
2129
|
+
return unless factory_call
|
|
1481
2130
|
|
|
1482
|
-
members = meta_member_names(
|
|
2131
|
+
members = meta_member_names(factory_call)
|
|
1483
2132
|
return if members.empty?
|
|
1484
2133
|
|
|
1485
2134
|
class_name = qualified_prefix.join("::")
|
|
1486
2135
|
members.each { |member| record_method_kind(accumulator, class_name, member, :instance) }
|
|
1487
2136
|
end
|
|
1488
2137
|
|
|
2138
|
+
# Unwinds nested single-parent `Class.new(...)` calls to a root `Struct.new(...)` / `Data.define(...)`.
|
|
2139
|
+
# A nested wrapper or factory block is declined because its method overrides belong to the intermediate
|
|
2140
|
+
# superclass and cannot be attributed to the outer class's reader-override guard.
|
|
2141
|
+
def resolve_meta_factory_call(call_node, allow_outer_block: true)
|
|
2142
|
+
wrapped = false
|
|
2143
|
+
while class_new_call?(call_node)
|
|
2144
|
+
return nil if intermediate_factory_block?(call_node, wrapped, allow_outer_block)
|
|
2145
|
+
|
|
2146
|
+
arguments = call_node.arguments&.arguments
|
|
2147
|
+
return nil unless arguments&.one?
|
|
2148
|
+
|
|
2149
|
+
call_node = arguments.first
|
|
2150
|
+
wrapped = true
|
|
2151
|
+
end
|
|
2152
|
+
return nil unless meta_factory_call?(call_node)
|
|
2153
|
+
return nil if intermediate_factory_block?(call_node, wrapped, allow_outer_block)
|
|
2154
|
+
|
|
2155
|
+
call_node
|
|
2156
|
+
end
|
|
2157
|
+
|
|
2158
|
+
def intermediate_factory_block?(call_node, wrapped, allow_outer_block)
|
|
2159
|
+
(wrapped || !allow_outer_block) && !call_node.block.nil?
|
|
2160
|
+
end
|
|
2161
|
+
|
|
2162
|
+
def meta_factory_call?(call_node)
|
|
2163
|
+
data_define_call?(call_node) || struct_new_call?(call_node)
|
|
2164
|
+
end
|
|
2165
|
+
|
|
1489
2166
|
# The Symbol member names of a `Data.define(*Symbol)` / `Struct.new(*Symbol [, keyword_init:])` call. For
|
|
1490
|
-
# `Struct.new` the
|
|
1491
|
-
#
|
|
2167
|
+
# `Struct.new` the trailing `keyword_init:` hash is stripped by {#struct_new_positionals}; a leading
|
|
2168
|
+
# String class name is NOT stripped, so `Struct.new("Name", :a) do` registers no members and the body
|
|
2169
|
+
# is entered under the anonymous name (consistent between passes). `Data.define` args are all Symbols.
|
|
1492
2170
|
def meta_member_names(call_node)
|
|
1493
2171
|
raw = call_node.arguments&.arguments || []
|
|
1494
2172
|
symbols = struct_new_call?(call_node) ? (struct_new_positionals(raw) || []) : raw
|
|
@@ -1592,9 +2270,9 @@ module Rigor
|
|
|
1592
2270
|
|
|
1593
2271
|
case node
|
|
1594
2272
|
when Prism::ClassNode, Prism::ModuleNode
|
|
1595
|
-
|
|
1596
|
-
if
|
|
1597
|
-
walk_singleton_body(node.body,
|
|
2273
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
2274
|
+
if child_prefix
|
|
2275
|
+
walk_singleton_body(node.body, child_prefix, false, accumulator) if node.body
|
|
1598
2276
|
return
|
|
1599
2277
|
end
|
|
1600
2278
|
when Prism::SingletonClassNode
|
|
@@ -1621,6 +2299,68 @@ module Rigor
|
|
|
1621
2299
|
end
|
|
1622
2300
|
end
|
|
1623
2301
|
|
|
2302
|
+
EMPTY_NESTING = [].freeze
|
|
2303
|
+
|
|
2304
|
+
# The empty qualified prefix. Distinct from {EMPTY_NESTING} only in what it MEANS: the two are handed
|
|
2305
|
+
# to different parameters, and this cycle spent a blocker on a prefix and a nesting sharing a name.
|
|
2306
|
+
EMPTY_PREFIX = [].freeze
|
|
2307
|
+
private_constant :EMPTY_NESTING
|
|
2308
|
+
|
|
2309
|
+
# Issue #681 — `{Prism::DefNode => Module.nesting}` for every `def` the file declares inside a class or
|
|
2310
|
+
# module body, keyed by node IDENTITY so the answer follows the DECLARATION rather than the class a
|
|
2311
|
+
# call happens to dispatch on. `Inference::ExpressionTyper#build_user_method_body_scope` rebuilds a
|
|
2312
|
+
# callee's body scope from the RECEIVER'S TYPE alone when it needs that callee's return, so unlike the
|
|
2313
|
+
# census scopes (#692) there is no prefix in hand to stamp: recording the chain where the declaration is
|
|
2314
|
+
# indexed is what makes the re-walk and the declaration walk agree BY CONSTRUCTION, which is the
|
|
2315
|
+
# argument #685 used to make {Reflection.lexical_nesting_chain} the single owner.
|
|
2316
|
+
#
|
|
2317
|
+
# Keyed on the def node and not on `(class, method)` because an inherited body is re-walked with the
|
|
2318
|
+
# SUBCLASS as receiver: `class Admin::CompactBase` declares `make`, `module Admin; class Child <
|
|
2319
|
+
# CompactBase` calls it, and the bare `Post` inside `make` names `::Post` — the constant the compact
|
|
2320
|
+
# declaration that OWNS the body reaches, not the one the receiver's spelling would suggest.
|
|
2321
|
+
#
|
|
2322
|
+
# A separate descent rather than a leaf of the fused methods/def-nodes walk: it needs the CHAIN
|
|
2323
|
+
# threaded, which the fused walk does not carry (a singleton-class body and a `Class.new` block body
|
|
2324
|
+
# both push a qualified prefix while pushing no `Module.nesting` entry), and threading a second value
|
|
2325
|
+
# through that walk and its anonymous-block twin exceeds their parameter budget. It stops at every
|
|
2326
|
+
# `Prism::DefNode`, so a def-dense file pays only the declaration spine.
|
|
2327
|
+
def build_def_nestings(root)
|
|
2328
|
+
accumulator = {}.compare_by_identity
|
|
2329
|
+
walk_def_nestings(root, EMPTY_NESTING, accumulator)
|
|
2330
|
+
accumulator.freeze
|
|
2331
|
+
end
|
|
2332
|
+
|
|
2333
|
+
# Records `nesting` for every `def` reachable from `node`. Only a `class` / `module` keyword pushes an
|
|
2334
|
+
# entry (qualified against the entry already on top, so a compact `class Admin::X` contributes ONE);
|
|
2335
|
+
# every other body — a singleton class, a `Class.new` / `Module.new` block, any other block — inherits
|
|
2336
|
+
# the chain unchanged, because Ruby pushes no cref for them.
|
|
2337
|
+
#
|
|
2338
|
+
# Issue #716 — a top-level `def` records the EMPTY chain, and that is a recorded answer rather than the
|
|
2339
|
+
# absence of one. Ruby's `Module.nesting` in a top-level body IS `[]`, so `def helper = Post.new` names
|
|
2340
|
+
# `::Post` wherever it is later called from; recording nothing left
|
|
2341
|
+
# {Reflection.lexical_nesting_chain} peeling the CALLER's qualified name, which answered `Admin::Post`
|
|
2342
|
+
# for a call made from inside `module Admin`. The table is keyed by node identity and populated only by
|
|
2343
|
+
# this walk, so "present with `[]`" (walked, and top level) stays distinguishable from "absent" (no
|
|
2344
|
+
# declaration walk built this scope — a plugin-constructed scope, an anonymous `Class.new` re-entered
|
|
2345
|
+
# through `Scope#evaluate`), which keeps the peel where it is still the only available answer.
|
|
2346
|
+
def walk_def_nestings(node, nesting, accumulator)
|
|
2347
|
+
return unless node.is_a?(Prism::Node)
|
|
2348
|
+
|
|
2349
|
+
case node
|
|
2350
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
2351
|
+
if node.body
|
|
2352
|
+
walk_def_nestings(node.body, Source::ConstantPath.pushed_nesting(nesting, node.constant_path),
|
|
2353
|
+
accumulator)
|
|
2354
|
+
end
|
|
2355
|
+
return
|
|
2356
|
+
when Prism::DefNode
|
|
2357
|
+
accumulator[node] = nesting unless nesting.nil?
|
|
2358
|
+
return
|
|
2359
|
+
end
|
|
2360
|
+
|
|
2361
|
+
node.rigor_each_child { |child| walk_def_nestings(child, nesting, accumulator) }
|
|
2362
|
+
end
|
|
2363
|
+
|
|
1624
2364
|
# Walks a class/module/singleton-class body's direct statements in source order, threading the
|
|
1625
2365
|
# bare-`module_function` toggle: once a bare `module_function` is seen, every subsequent `def` in the body
|
|
1626
2366
|
# registers as a singleton method. Nested classes/modules/defs and `module_function :a, :b` named forms recurse /
|
|
@@ -1700,41 +2440,153 @@ module Rigor
|
|
|
1700
2440
|
# ADR-24 slice 2 — per-class table mapping a fully qualified user class to its superclass name AS WRITTEN at the
|
|
1701
2441
|
# `class Foo < Bar` declaration. Only constant superclasses are recorded (`class Foo < Struct.new(...)` and other
|
|
1702
2442
|
# non-constant superclasses produce no entry). The as-written name is resolved to a qualified class at the call
|
|
1703
|
-
# site against the
|
|
1704
|
-
|
|
1705
|
-
|
|
2443
|
+
# site against the nesting the declaration's HEADER sits in — see {Scope#ancestor_name_candidates}, which reads
|
|
2444
|
+
# the second table this walk builds.
|
|
2445
|
+
#
|
|
2446
|
+
# Issue #682 — `header_nestings` maps the same qualified class name to that nesting: `Module.nesting` where the
|
|
2447
|
+
# `class` / `module` KEYWORD is written, which is the body's chain minus the declaration's own entry. It is
|
|
2448
|
+
# recorded here, with the walk's prefix in hand, because it is unrecoverable afterwards: `class Admin::Widget`
|
|
2449
|
+
# and `module Admin; class Widget` render the identical `"Admin::Widget"`, and an ancestor name is resolved in
|
|
2450
|
+
# the enclosing cref — `[]` for the first spelling, `["Admin"]` for the second. A class reopened under two
|
|
2451
|
+
# spellings keeps the LAST one walked, matching how `superclasses` itself merges.
|
|
2452
|
+
#
|
|
2453
|
+
# @return [Array(Hash{String=>String}, Hash{String=>Array[String]})] `[superclasses, header_nestings]`
|
|
2454
|
+
def build_superclass_tables(root, source_path = nil)
|
|
2455
|
+
accumulator = { superclasses: {}, header_nestings: {} }
|
|
1706
2456
|
walk_class_superclasses(root, [], accumulator, source_path)
|
|
1707
|
-
accumulator.freeze
|
|
2457
|
+
[accumulator[:superclasses].freeze, accumulator[:header_nestings].freeze]
|
|
1708
2458
|
end
|
|
1709
2459
|
|
|
1710
|
-
def walk_class_superclasses(node, qualified_prefix, accumulator, source_path = nil)
|
|
2460
|
+
def walk_class_superclasses(node, qualified_prefix, accumulator, source_path = nil, nesting = EMPTY_NESTING)
|
|
1711
2461
|
return unless node.is_a?(Prism::Node)
|
|
1712
2462
|
|
|
1713
2463
|
case node
|
|
1714
2464
|
when Prism::CallNode
|
|
1715
|
-
record_anonymous_meta_superclass(node, accumulator, source_path)
|
|
1716
|
-
when Prism::ClassNode
|
|
1717
|
-
|
|
1718
|
-
if
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
when Prism::ModuleNode
|
|
1726
|
-
name = Source::ConstantPath.qualified_name(node.constant_path)
|
|
1727
|
-
if name
|
|
1728
|
-
walk_class_superclasses(node.body, qualified_prefix + [name], accumulator) if node.body
|
|
2465
|
+
record_anonymous_meta_superclass(node, accumulator[:superclasses], source_path)
|
|
2466
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
2467
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
2468
|
+
if child_prefix
|
|
2469
|
+
record_declaration_ancestry(node, nesting, child_prefix, accumulator)
|
|
2470
|
+
if node.body
|
|
2471
|
+
walk_class_superclasses(node.body, child_prefix, accumulator, nil,
|
|
2472
|
+
Source::ConstantPath.pushed_nesting(nesting, node.constant_path) ||
|
|
2473
|
+
nesting)
|
|
2474
|
+
end
|
|
1729
2475
|
return
|
|
1730
2476
|
end
|
|
1731
2477
|
end
|
|
1732
2478
|
|
|
1733
2479
|
node.rigor_each_child do |child|
|
|
1734
|
-
walk_class_superclasses(child, qualified_prefix, accumulator, source_path)
|
|
2480
|
+
walk_class_superclasses(child, qualified_prefix, accumulator, source_path, nesting)
|
|
1735
2481
|
end
|
|
1736
2482
|
end
|
|
1737
2483
|
|
|
2484
|
+
# One declaration's two ancestry facts: the as-written superclass name (classes only), and the
|
|
2485
|
+
# `Module.nesting` its header is written in. `nesting` is the chain in force OUTSIDE the header — the
|
|
2486
|
+
# header is evaluated before the body is entered, so the declaration's own entry is not on the ladder
|
|
2487
|
+
# its superclass name walks — which is why the walk pushes only when it recurses into the body. The
|
|
2488
|
+
# chain is threaded rather than derived from the enclosing prefix because a rooted header resets that
|
|
2489
|
+
# prefix without resetting Ruby's nesting ([#708](https://github.com/rigortype/rigor/issues/708)).
|
|
2490
|
+
def record_declaration_ancestry(node, nesting, child_prefix, accumulator)
|
|
2491
|
+
full = child_prefix.join("::")
|
|
2492
|
+
add_header_nesting(accumulator[:header_nestings], full, nesting) if declares_ancestor_name?(node)
|
|
2493
|
+
return unless node.is_a?(Prism::ClassNode)
|
|
2494
|
+
|
|
2495
|
+
accumulator[:superclasses][full] = recorded_ancestor_name(node.superclass) if node.superclass
|
|
2496
|
+
end
|
|
2497
|
+
|
|
2498
|
+
# Issue #722 residue 1 / #637 — the recorded ancestor name, carrying the ROOTED marker when the site
|
|
2499
|
+
# wrote one. `Source::ConstantPath.qualified_name` renders segments only, so `class Rooted < ::Base`
|
|
2500
|
+
# recorded the same `"Base"` a bare `< Base` does, and the resolver then walked the enclosing nesting
|
|
2501
|
+
# and answered `A::Base` — a class Ruby never looks at, on a spelling whose whole point is that it
|
|
2502
|
+
# does not.
|
|
2503
|
+
#
|
|
2504
|
+
# The marker rides the existing table's VALUE rather than a new table. It is still a String, so the
|
|
2505
|
+
# per-file index, the cross-file fold and the ADR-85 seed bundle carry it unchanged; the three
|
|
2506
|
+
# resolvers that read it strip and anchor. `IncrementalSnapshot::SCHEMA` is bumped anyway, because an
|
|
2507
|
+
# older blob's un-rooted value is indistinguishable from "not rooted" and a warm run would keep the
|
|
2508
|
+
# pre-fix answer for every unchanged file.
|
|
2509
|
+
def recorded_ancestor_name(node)
|
|
2510
|
+
name = Source::ConstantPath.qualified_name(node)
|
|
2511
|
+
return name if name.nil? || !Source::ConstantPath.rooted?(node)
|
|
2512
|
+
|
|
2513
|
+
"::#{name}"
|
|
2514
|
+
end
|
|
2515
|
+
|
|
2516
|
+
# Whether this declaration SITE writes an ancestor name — a superclass in its header, or a mixin call
|
|
2517
|
+
# in its own body. Only such a site contributes a header nesting.
|
|
2518
|
+
#
|
|
2519
|
+
# Issue #708's review: a per-CLASS union of every site's chain is wrong for a rooted reopen inside
|
|
2520
|
+
# another namespace. `class ::Foo; def extra = 1; end` written in `module Outer` contributed `["Outer"]`
|
|
2521
|
+
# to `Foo`, and the ancestor rung then resolved `Foo`'s superclass `Base` as `Outer::Base` — a class
|
|
2522
|
+
# Ruby never looks at, ahead of the right one, on a site that named no ancestor at all. A site that
|
|
2523
|
+
# writes no ancestor name has no ancestor for its cref to govern, so it has nothing to say here.
|
|
2524
|
+
#
|
|
2525
|
+
# This keeps what the union was FOR. It was added because last-writer-wins let one rails TEST file
|
|
2526
|
+
# reopening `ActiveRecord::Relation` compactly beat the library declaration, costing the class all nine
|
|
2527
|
+
# of its `include`d modules; that reopen writes no ancestor name either, so it now contributes nothing
|
|
2528
|
+
# rather than winning — the protection comes from the recording rule instead of from a merge rule.
|
|
2529
|
+
# Two sites that BOTH write ancestor names still union, which is the conservative reading of an
|
|
2530
|
+
# ambiguity one table cannot represent.
|
|
2531
|
+
#
|
|
2532
|
+
# The mixin scan counts only a call whose `self` is the declaration itself, so it stops at every
|
|
2533
|
+
# construct that rebinds `self`: a nested `class` / `module` (that body's calls belong to the nested
|
|
2534
|
+
# class), a `class << self` body, and a block `#rebound_block_self` classifies as rebinding — the
|
|
2535
|
+
# `Class.new` / `Module.new` / `Struct.new` / `Data.define` and `class_eval` family. An `include`
|
|
2536
|
+
# written in any of those attaches to something other than this class, so treating it as this site's
|
|
2537
|
+
# ancestor name reinstates exactly the defect above with a different spelling.
|
|
2538
|
+
#
|
|
2539
|
+
# It stays structural rather than exhaustive: `self.include M` and `send(:include, M)` record nothing
|
|
2540
|
+
# and fall back to the peel, which is this walk's pre-#682 answer and never a new firing — and the
|
|
2541
|
+
# includes walk misses those two as well, so the class's ancestry agrees. For the REBINDING shapes it
|
|
2542
|
+
# does not: `walk_class_includes` still charges an `include` written in a `class << self` body or a
|
|
2543
|
+
# `Class.new` block to the enclosing class, where Ruby attaches it elsewhere. This scan is right and
|
|
2544
|
+
# that one is wrong; the divergence is [#728](https://github.com/rigortype/rigor/issues/728).
|
|
2545
|
+
def declares_ancestor_name?(node)
|
|
2546
|
+
return true if node.is_a?(Prism::ClassNode) && node.superclass
|
|
2547
|
+
|
|
2548
|
+
body = node.body
|
|
2549
|
+
body ? mixin_call_in_body?(body) : false
|
|
2550
|
+
end
|
|
2551
|
+
|
|
2552
|
+
def mixin_call_in_body?(node)
|
|
2553
|
+
return false unless node.is_a?(Prism::Node)
|
|
2554
|
+
return false if node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode) ||
|
|
2555
|
+
node.is_a?(Prism::SingletonClassNode)
|
|
2556
|
+
return true if node.is_a?(Prism::CallNode) && node.receiver.nil? && MIXIN_CALL_NAMES.include?(node.name)
|
|
2557
|
+
|
|
2558
|
+
rebinds = rebound_block_self(node, EMPTY_PREFIX)
|
|
2559
|
+
node.rigor_each_child do |child|
|
|
2560
|
+
next if rebinds && child.is_a?(Prism::BlockNode)
|
|
2561
|
+
|
|
2562
|
+
return true if mixin_call_in_body?(child)
|
|
2563
|
+
end
|
|
2564
|
+
false
|
|
2565
|
+
end
|
|
2566
|
+
|
|
2567
|
+
# Adds one declaration site's header nesting to the per-class table, UNIONING it with what a
|
|
2568
|
+
# previous site recorded, most-qualified first. A class reopened under two spellings has two header
|
|
2569
|
+
# nestings while the ancestor names its sites write collapse into ONE table, so taking either site's
|
|
2570
|
+
# chain alone drops a candidate the other one had — and dropping a candidate drops an ancestor edge,
|
|
2571
|
+
# the false-positive direction. Rails is the corpus case: `activerecord` declares
|
|
2572
|
+
# `ActiveRecord::Relation` inside `module ActiveRecord` and one test file reopens it as the compact
|
|
2573
|
+
# `class ActiveRecord::Relation`, so last-writer-wins cost the library class nine included modules.
|
|
2574
|
+
# The union is the conservative reading of an ambiguity the per-class table cannot represent: it
|
|
2575
|
+
# degrades such a class to the pre-fix candidate list rather than to a shorter one.
|
|
2576
|
+
def add_header_nesting(table, name, entries)
|
|
2577
|
+
existing = table[name]
|
|
2578
|
+
return table[name] = entries.freeze if existing.nil?
|
|
2579
|
+
return if entries.all? { |entry| existing.include?(entry) }
|
|
2580
|
+
|
|
2581
|
+
table[name] = (existing | entries).sort_by { |entry| [-entry.split("::").size, entry] }.freeze
|
|
2582
|
+
end
|
|
2583
|
+
|
|
2584
|
+
# {#add_header_nesting} over a whole table, for the per-file and cross-file merges.
|
|
2585
|
+
def merge_header_nestings(target, incoming)
|
|
2586
|
+
incoming.each { |name, entries| add_header_nesting(target, name, entries) }
|
|
2587
|
+
target
|
|
2588
|
+
end
|
|
2589
|
+
|
|
1738
2590
|
# #319 — `Class.new(Parent) do ... end` names its superclass in the first positional. Recording it under the
|
|
1739
2591
|
# call site's anonymous name keeps `Parent`'s surface reachable from the block body (whose `self_type` is now
|
|
1740
2592
|
# `Singleton[<anonymous>]`) and from an instance of the resulting class, so giving the anonymous class an
|
|
@@ -1745,6 +2597,11 @@ module Rigor
|
|
|
1745
2597
|
arg = call_node.arguments&.arguments&.first
|
|
1746
2598
|
return if arg.nil?
|
|
1747
2599
|
|
|
2600
|
+
# Issue #722 residue 1 — deliberately NOT marked rooted here, unlike the `class X < ::Parent`
|
|
2601
|
+
# header. Marking it changed nothing observable: `Made = Class.new(::Base)` inside a namespace that
|
|
2602
|
+
# shadows `Base` still resolved the shadow, so the anonymous class's ancestry is reached by a path
|
|
2603
|
+
# that does not read this value, and shipping a marker no resolver honours would be a change with
|
|
2604
|
+
# no gate. The residue stays on #722 with that finding attached.
|
|
1748
2605
|
superclass = Source::ConstantPath.qualified_name(arg)
|
|
1749
2606
|
return if superclass.nil?
|
|
1750
2607
|
|
|
@@ -1768,16 +2625,18 @@ module Rigor
|
|
|
1768
2625
|
|
|
1769
2626
|
case node
|
|
1770
2627
|
when Prism::ClassNode
|
|
1771
|
-
|
|
1772
|
-
if
|
|
1773
|
-
record_data_member_layout(
|
|
1774
|
-
|
|
2628
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
2629
|
+
if child_prefix
|
|
2630
|
+
record_data_member_layout(
|
|
2631
|
+
accumulator, child_prefix, node.superclass, allow_outer_block: false
|
|
2632
|
+
)
|
|
2633
|
+
walk_data_member_layouts(node.body, child_prefix, accumulator) if node.body
|
|
1775
2634
|
return
|
|
1776
2635
|
end
|
|
1777
2636
|
when Prism::ModuleNode
|
|
1778
|
-
|
|
1779
|
-
if
|
|
1780
|
-
walk_data_member_layouts(node.body,
|
|
2637
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
2638
|
+
if child_prefix
|
|
2639
|
+
walk_data_member_layouts(node.body, child_prefix, accumulator) if node.body
|
|
1781
2640
|
return
|
|
1782
2641
|
end
|
|
1783
2642
|
when Prism::ConstantWriteNode
|
|
@@ -1791,8 +2650,9 @@ module Rigor
|
|
|
1791
2650
|
|
|
1792
2651
|
# Records `qualified -> [members]` when `expr` is a `Data.define(*Symbol)` call with at least one literal-Symbol
|
|
1793
2652
|
# member.
|
|
1794
|
-
def record_data_member_layout(accumulator, qualified_parts, expr)
|
|
1795
|
-
|
|
2653
|
+
def record_data_member_layout(accumulator, qualified_parts, expr, allow_outer_block: true)
|
|
2654
|
+
expr = resolve_meta_factory_call(expr, allow_outer_block: allow_outer_block)
|
|
2655
|
+
return unless expr && data_define_call?(expr)
|
|
1796
2656
|
|
|
1797
2657
|
members = meta_member_names(expr)
|
|
1798
2658
|
return if members.empty?
|
|
@@ -1815,16 +2675,18 @@ module Rigor
|
|
|
1815
2675
|
|
|
1816
2676
|
case node
|
|
1817
2677
|
when Prism::ClassNode
|
|
1818
|
-
|
|
1819
|
-
if
|
|
1820
|
-
record_struct_member_layout(
|
|
1821
|
-
|
|
2678
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
2679
|
+
if child_prefix
|
|
2680
|
+
record_struct_member_layout(
|
|
2681
|
+
accumulator, child_prefix, node.superclass, allow_outer_block: false
|
|
2682
|
+
)
|
|
2683
|
+
walk_struct_member_layouts(node.body, child_prefix, accumulator) if node.body
|
|
1822
2684
|
return
|
|
1823
2685
|
end
|
|
1824
2686
|
when Prism::ModuleNode
|
|
1825
|
-
|
|
1826
|
-
if
|
|
1827
|
-
walk_struct_member_layouts(node.body,
|
|
2687
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
2688
|
+
if child_prefix
|
|
2689
|
+
walk_struct_member_layouts(node.body, child_prefix, accumulator) if node.body
|
|
1828
2690
|
return
|
|
1829
2691
|
end
|
|
1830
2692
|
when Prism::ConstantWriteNode
|
|
@@ -1838,8 +2700,9 @@ module Rigor
|
|
|
1838
2700
|
|
|
1839
2701
|
# Records `qualified -> { members:, keyword_init: }` when `expr` is a `Struct.new(*Symbol [, keyword_init:
|
|
1840
2702
|
# <bool>])` call with at least one literal-Symbol member.
|
|
1841
|
-
def record_struct_member_layout(accumulator, qualified_parts, expr)
|
|
1842
|
-
|
|
2703
|
+
def record_struct_member_layout(accumulator, qualified_parts, expr, allow_outer_block: true)
|
|
2704
|
+
expr = resolve_meta_factory_call(expr, allow_outer_block: allow_outer_block)
|
|
2705
|
+
return unless expr && struct_new_call?(expr)
|
|
1843
2706
|
|
|
1844
2707
|
members = meta_member_names(expr)
|
|
1845
2708
|
return if members.empty?
|
|
@@ -1882,14 +2745,21 @@ module Rigor
|
|
|
1882
2745
|
|
|
1883
2746
|
case node
|
|
1884
2747
|
when Prism::ClassNode, Prism::ModuleNode
|
|
1885
|
-
|
|
1886
|
-
if
|
|
1887
|
-
full =
|
|
1888
|
-
walk_class_includes(node.body,
|
|
2748
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
2749
|
+
if child_prefix
|
|
2750
|
+
full = child_prefix.join("::")
|
|
2751
|
+
walk_class_includes(node.body, child_prefix, full, accumulator) if node.body
|
|
1889
2752
|
return
|
|
1890
2753
|
end
|
|
2754
|
+
when Prism::SingletonClassNode
|
|
2755
|
+
# Issue #728 — `class << self; include M; end` mixes M into the SINGLETON: it contributes class
|
|
2756
|
+
# methods, not the instance surface this table feeds. Descending with no owner keeps any nested
|
|
2757
|
+
# declaration walked (a `ClassNode` child sets its own owner) while the mixin calls in the
|
|
2758
|
+
# singleton body record nothing, which is what `extends` is for.
|
|
2759
|
+
current_class = nil
|
|
1891
2760
|
when Prism::CallNode
|
|
1892
2761
|
record_mixin_call(node, current_class, accumulator)
|
|
2762
|
+
return walk_mixin_call_children(node, qualified_prefix, current_class, accumulator)
|
|
1893
2763
|
end
|
|
1894
2764
|
|
|
1895
2765
|
node.rigor_each_child do |child|
|
|
@@ -1897,6 +2767,30 @@ module Rigor
|
|
|
1897
2767
|
end
|
|
1898
2768
|
end
|
|
1899
2769
|
|
|
2770
|
+
# Issue #728 — a block that REBINDS `self` owns the mixin calls written in it, and this walk used to
|
|
2771
|
+
# hand them to the lexically enclosing declaration. `Thing = Class.new { include Taggable }` inside
|
|
2772
|
+
# `class Outer` registered `Taggable` on `Outer`, so `Outer.new.tag` typed `:tagged` where MRI raises
|
|
2773
|
+
# `NoMethodError` — a wrong answer, not a wider one. The classification is
|
|
2774
|
+
# {#rebound_block_self}, the same one #721 gave its own mixin scan.
|
|
2775
|
+
#
|
|
2776
|
+
# `Recv.class_eval { include M }` resolves to `Recv` and now records there, which it never did before
|
|
2777
|
+
# (the block's owner was the lexical enclosure, so at the top level the include was simply dropped).
|
|
2778
|
+
# An OPAQUE rebinding — `Class.new` / `Module.new` / a non-constant `class_eval` receiver — records
|
|
2779
|
+
# nothing: the class exists but this walk cannot name it, and naming the lexical enclosure instead is
|
|
2780
|
+
# the one answer Ruby is guaranteed not to have written.
|
|
2781
|
+
def walk_mixin_call_children(node, qualified_prefix, current_class, accumulator)
|
|
2782
|
+
rebound = rebound_block_self(node, qualified_prefix)
|
|
2783
|
+
node.rigor_each_child do |child|
|
|
2784
|
+
owner =
|
|
2785
|
+
if rebound && child.is_a?(Prism::BlockNode)
|
|
2786
|
+
rebound == OPAQUE_SELF ? nil : rebound
|
|
2787
|
+
else
|
|
2788
|
+
current_class
|
|
2789
|
+
end
|
|
2790
|
+
walk_class_includes(child, qualified_prefix, owner, accumulator)
|
|
2791
|
+
end
|
|
2792
|
+
end
|
|
2793
|
+
|
|
1900
2794
|
def record_mixin_call(node, current_class, accumulator)
|
|
1901
2795
|
return unless current_class && node.receiver.nil?
|
|
1902
2796
|
return unless MIXIN_CALL_NAMES.include?(node.name)
|
|
@@ -1907,6 +2801,78 @@ module Rigor
|
|
|
1907
2801
|
end
|
|
1908
2802
|
end
|
|
1909
2803
|
|
|
2804
|
+
# Issue #526 — `extend M` / `extend self` / bare `module_function`, the singleton-side siblings of
|
|
2805
|
+
# {#build_discovered_includes}. `extend self` and the bare `module_function` toggle both map the
|
|
2806
|
+
# module's own instance defs onto its singleton (recorded as extending ITSELF); `module_function`
|
|
2807
|
+
# with symbol arguments already resolves through the existing recording, and the toggle's
|
|
2808
|
+
# order-sensitivity (only SUBSEQUENT defs become module functions) is deliberately over-approximated
|
|
2809
|
+
# — the extra names only suppress `undefined-method` and enable inference on calls that raise at
|
|
2810
|
+
# runtime, both the ADR-5-safe direction.
|
|
2811
|
+
def build_discovered_extends(root)
|
|
2812
|
+
accumulator = {}
|
|
2813
|
+
walk_class_extends(root, [], nil, accumulator)
|
|
2814
|
+
accumulator.transform_values { |mods| mods.uniq.freeze }.freeze
|
|
2815
|
+
end
|
|
2816
|
+
|
|
2817
|
+
def walk_class_extends(node, qualified_prefix, current_class, accumulator)
|
|
2818
|
+
return unless node.is_a?(Prism::Node)
|
|
2819
|
+
|
|
2820
|
+
case node
|
|
2821
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
2822
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
2823
|
+
if child_prefix
|
|
2824
|
+
full = child_prefix.join("::")
|
|
2825
|
+
walk_class_extends(node.body, child_prefix, full, accumulator) if node.body
|
|
2826
|
+
return
|
|
2827
|
+
end
|
|
2828
|
+
when Prism::CallNode
|
|
2829
|
+
record_extend_call(node, current_class, accumulator)
|
|
2830
|
+
end
|
|
2831
|
+
|
|
2832
|
+
node.rigor_each_child do |child|
|
|
2833
|
+
walk_class_extends(child, qualified_prefix, current_class, accumulator)
|
|
2834
|
+
end
|
|
2835
|
+
end
|
|
2836
|
+
|
|
2837
|
+
def record_extend_call(node, current_class, accumulator)
|
|
2838
|
+
return unless current_class && node.receiver.nil?
|
|
2839
|
+
|
|
2840
|
+
case node.name
|
|
2841
|
+
when :extend then record_extend_targets(node, current_class, accumulator)
|
|
2842
|
+
when :module_function
|
|
2843
|
+
(accumulator[current_class] ||= []) << current_class if node.arguments.nil?
|
|
2844
|
+
end
|
|
2845
|
+
end
|
|
2846
|
+
|
|
2847
|
+
def record_extend_targets(node, current_class, accumulator)
|
|
2848
|
+
node.arguments&.arguments&.each do |arg|
|
|
2849
|
+
target = arg.is_a?(Prism::SelfNode) ? current_class : Source::ConstantPath.qualified_name(arg)
|
|
2850
|
+
(accumulator[current_class] ||= []) << target if target
|
|
2851
|
+
end
|
|
2852
|
+
end
|
|
2853
|
+
|
|
2854
|
+
# The materialization half of #526: for every `C extends M`, M's INSTANCE defs become C's
|
|
2855
|
+
# SINGLETON defs — existence (so `C.helper` stops firing undefined-method) and def nodes (so
|
|
2856
|
+
# call-site return inference runs with `self = Singleton[C]`, which is what Ruby binds). A name C
|
|
2857
|
+
# already defines on its own singleton wins (`||=`); an extend target with no discovered defs
|
|
2858
|
+
# contributes nothing (RBS-module extends stay on the dispatch tiers).
|
|
2859
|
+
def fold_extends_into_singleton_tables(extends, def_nodes, singleton_def_nodes, methods)
|
|
2860
|
+
extends.each do |class_name, mods|
|
|
2861
|
+
mods.each do |mod_name|
|
|
2862
|
+
source_defs = def_nodes[mod_name]
|
|
2863
|
+
next if source_defs.nil?
|
|
2864
|
+
|
|
2865
|
+
# An inner table inherited unchanged from a frozen seed must be thawed before the fold writes.
|
|
2866
|
+
target = singleton_def_nodes[class_name]
|
|
2867
|
+
target = singleton_def_nodes[class_name] = (target ? target.dup : {}) if target.nil? || target.frozen?
|
|
2868
|
+
source_defs.each do |method_name, def_node|
|
|
2869
|
+
target[method_name] ||= def_node
|
|
2870
|
+
record_method_kind(methods, class_name, method_name, :singleton)
|
|
2871
|
+
end
|
|
2872
|
+
end
|
|
2873
|
+
end
|
|
2874
|
+
end
|
|
2875
|
+
|
|
1910
2876
|
VISIBILITY_MODIFIERS = %i[public private protected].freeze
|
|
1911
2877
|
|
|
1912
2878
|
# v0.1.2 — per-class method-visibility table for the `def.method-visibility-mismatch` CheckRule.
|
|
@@ -1933,15 +2899,14 @@ module Rigor
|
|
|
1933
2899
|
accumulator.transform_values(&:freeze).freeze
|
|
1934
2900
|
end
|
|
1935
2901
|
|
|
1936
|
-
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
2902
|
+
# rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
1937
2903
|
def walk_method_visibilities(node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
|
|
1938
2904
|
return current_visibility unless node.is_a?(Prism::Node)
|
|
1939
2905
|
|
|
1940
2906
|
case node
|
|
1941
2907
|
when Prism::ClassNode, Prism::ModuleNode
|
|
1942
|
-
|
|
1943
|
-
if
|
|
1944
|
-
child_prefix = qualified_prefix + [name]
|
|
2908
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
2909
|
+
if child_prefix
|
|
1945
2910
|
walk_method_visibilities(node.body, child_prefix, false, :public, accumulator) if node.body
|
|
1946
2911
|
return current_visibility
|
|
1947
2912
|
end
|
|
@@ -1982,7 +2947,6 @@ module Rigor
|
|
|
1982
2947
|
end
|
|
1983
2948
|
current_visibility
|
|
1984
2949
|
end
|
|
1985
|
-
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/AbcSize
|
|
1986
2950
|
|
|
1987
2951
|
def record_def_visibility(def_node, qualified_prefix, in_singleton_class, current_visibility, accumulator)
|
|
1988
2952
|
return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
|
|
@@ -2071,9 +3035,9 @@ module Rigor
|
|
|
2071
3035
|
|
|
2072
3036
|
case node
|
|
2073
3037
|
when Prism::ClassNode, Prism::ModuleNode
|
|
2074
|
-
|
|
2075
|
-
if
|
|
2076
|
-
collect_class_alias_map(node.body,
|
|
3038
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
3039
|
+
if child_prefix
|
|
3040
|
+
collect_class_alias_map(node.body, child_prefix, accumulator) if node.body
|
|
2077
3041
|
return accumulator
|
|
2078
3042
|
end
|
|
2079
3043
|
when Prism::SingletonClassNode
|
|
@@ -2081,12 +3045,36 @@ module Rigor
|
|
|
2081
3045
|
when Prism::AliasMethodNode
|
|
2082
3046
|
record_alias_map_entry(node, qualified_prefix, accumulator)
|
|
2083
3047
|
return accumulator
|
|
3048
|
+
when Prism::CallNode
|
|
3049
|
+
# `alias_method :new, :old` — the CallNode twin of the `alias` keyword (#533; liquid's i18n
|
|
3050
|
+
# `t` alias was the corpus case). Recorded, then the walk continues: unlike AliasMethodNode a
|
|
3051
|
+
# call's children can carry further class bodies (`Class.new do … end`).
|
|
3052
|
+
names = alias_method_call_names(node)
|
|
3053
|
+
if names && !qualified_prefix.empty?
|
|
3054
|
+
(accumulator[qualified_prefix.join("::")] ||= {})[names.first] =
|
|
3055
|
+
names.last
|
|
3056
|
+
end
|
|
2084
3057
|
end
|
|
2085
3058
|
|
|
2086
3059
|
node.rigor_each_child { |child| collect_class_alias_map(child, qualified_prefix, accumulator) }
|
|
2087
3060
|
accumulator
|
|
2088
3061
|
end
|
|
2089
3062
|
|
|
3063
|
+
# `[new_name, old_name]` for an implicit-self `alias_method` call with two literal symbol /
|
|
3064
|
+
# string arguments, or nil. A variable-named alias is runtime data and stays unrecorded.
|
|
3065
|
+
def alias_method_call_names(call_node)
|
|
3066
|
+
return nil unless call_node.name == :alias_method && call_node.receiver.nil?
|
|
3067
|
+
|
|
3068
|
+
args = call_node.arguments&.arguments
|
|
3069
|
+
return nil unless args && args.size == 2
|
|
3070
|
+
|
|
3071
|
+
new_name = literal_method_name(args[0])
|
|
3072
|
+
old_name = literal_method_name(args[1])
|
|
3073
|
+
return nil if new_name.nil? || old_name.nil?
|
|
3074
|
+
|
|
3075
|
+
[new_name, old_name]
|
|
3076
|
+
end
|
|
3077
|
+
|
|
2090
3078
|
def record_alias_map_entry(alias_node, qualified_prefix, accumulator)
|
|
2091
3079
|
return if qualified_prefix.empty?
|
|
2092
3080
|
return unless alias_node.new_name.is_a?(Prism::SymbolNode) && alias_node.old_name.is_a?(Prism::SymbolNode)
|
|
@@ -2134,6 +3122,53 @@ module Rigor
|
|
|
2134
3122
|
end
|
|
2135
3123
|
end
|
|
2136
3124
|
|
|
3125
|
+
# Issue #736 — the module-level accessor macros, which introduce methods on BOTH sides of the class.
|
|
3126
|
+
# ActiveSupport defines them on `Module`, so every class and module body can use them, and Rails codebases
|
|
3127
|
+
# do: `mattr_accessor` / `cattr_accessor` (an alias of it) and `class_attribute` account for 92 false
|
|
3128
|
+
# `call.undefined-method` on redmine the moment any `sig/` declares the class — invisible before that,
|
|
3129
|
+
# because an undeclared receiver is not a receiver the rule can speak about.
|
|
3130
|
+
#
|
|
3131
|
+
# The value is `[reader, writer, predicate]`. `class_attribute` also defines `x?` on both sides.
|
|
3132
|
+
#
|
|
3133
|
+
# Recognising ActiveSupport's spellings in the engine's own walk is a deliberate exception to "steer
|
|
3134
|
+
# metaprogramming toward the plugin API": the consumer that has to see these is the cross-file
|
|
3135
|
+
# `discovered_methods` table (`finalize_def_index` keeps accessor-introduced names there precisely
|
|
3136
|
+
# because they are not monkey-patches), and no plugin surface reaches it — the ADR-16 synthetic-method
|
|
3137
|
+
# tier feeds the DISPATCHER, which `Analysis::CheckRules` does not consult. A name table is the smallest
|
|
3138
|
+
# thing that works; a plugin-supplied one can replace it whenever that surface exists.
|
|
3139
|
+
MODULE_ATTR_MACROS = {
|
|
3140
|
+
mattr_reader: [true, false, false], mattr_writer: [false, true, false],
|
|
3141
|
+
mattr_accessor: [true, true, false], cattr_reader: [true, false, false],
|
|
3142
|
+
cattr_writer: [false, true, false], cattr_accessor: [true, true, false],
|
|
3143
|
+
class_attribute: [true, true, true]
|
|
3144
|
+
}.freeze
|
|
3145
|
+
|
|
3146
|
+
# Both kinds, unconditionally. `instance_accessor: false` (and its `instance_reader:` /
|
|
3147
|
+
# `instance_writer:` / `instance_predicate:` siblings) narrows the real surface, and honouring them here
|
|
3148
|
+
# would buy a missed detection on a shape nobody writes by accident — this table's only consumer is a
|
|
3149
|
+
# SUPPRESSION, where over-recording costs a diagnostic that would have fired on working code anyway.
|
|
3150
|
+
def record_module_attr_methods(call_node, qualified_prefix, accumulator)
|
|
3151
|
+
return if qualified_prefix.empty?
|
|
3152
|
+
return unless call_node.receiver.nil? # only the implicit-self macro defines on the lexical class
|
|
3153
|
+
return if call_node.arguments.nil?
|
|
3154
|
+
|
|
3155
|
+
reader, writer, predicate = MODULE_ATTR_MACROS.fetch(call_node.name)
|
|
3156
|
+
class_name = qualified_prefix.join("::")
|
|
3157
|
+
call_node.arguments.arguments.each do |arg|
|
|
3158
|
+
base = literal_method_name(arg)
|
|
3159
|
+
next if base.nil?
|
|
3160
|
+
|
|
3161
|
+
record_both_kinds(accumulator, class_name, base) if reader
|
|
3162
|
+
record_both_kinds(accumulator, class_name, :"#{base}=") if writer
|
|
3163
|
+
record_both_kinds(accumulator, class_name, :"#{base}?") if predicate
|
|
3164
|
+
end
|
|
3165
|
+
end
|
|
3166
|
+
|
|
3167
|
+
def record_both_kinds(accumulator, class_name, method_name)
|
|
3168
|
+
record_method_kind(accumulator, class_name, method_name, :instance)
|
|
3169
|
+
record_method_kind(accumulator, class_name, method_name, :singleton)
|
|
3170
|
+
end
|
|
3171
|
+
|
|
2137
3172
|
def literal_method_name(node)
|
|
2138
3173
|
return nil unless node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
|
|
2139
3174
|
|
|
@@ -2169,7 +3204,7 @@ module Rigor
|
|
|
2169
3204
|
# Skip files that fail to parse or read; the per-file analyzer surfaces the parse error separately.
|
|
2170
3205
|
next
|
|
2171
3206
|
end
|
|
2172
|
-
accumulator.freeze
|
|
3207
|
+
synthesize_namespace_prefixes(accumulator).freeze
|
|
2173
3208
|
end
|
|
2174
3209
|
|
|
2175
3210
|
# ADR-24 slice 2 — cross-file companion to `discovered_classes_for_paths`. Walks every project file once and
|
|
@@ -2249,12 +3284,30 @@ module Rigor
|
|
|
2249
3284
|
def declaration_signature(file_index)
|
|
2250
3285
|
parts = []
|
|
2251
3286
|
append_ancestry_signature(parts, file_index)
|
|
3287
|
+
append_constant_signature(parts, file_index)
|
|
2252
3288
|
append_declaration_tables(parts, file_index)
|
|
2253
3289
|
append_def_signatures(parts, file_index[:def_nodes], "#")
|
|
2254
3290
|
append_def_signatures(parts, file_index[:singleton_def_nodes], ".")
|
|
2255
3291
|
Digest::SHA256.hexdigest(parts.join("\x00"))
|
|
2256
3292
|
end
|
|
2257
3293
|
|
|
3294
|
+
# Issue #644 — the cross-file VALUE-constant surface. Load-bearing for soundness, not precision: a
|
|
3295
|
+
# file's published constants are a declaration-level fact its file-level dependents consume, so
|
|
3296
|
+
# `FOO = :sym` becoming `FOO = :other` MUST move this signature or the reader would be declaration-
|
|
3297
|
+
# stable, dropped from the closure, and served the pre-edit `:sym`. Both halves are here: the write
|
|
3298
|
+
# NAMES (an added / removed / renamed assignment) and the published VALUES (a same-name value edit).
|
|
3299
|
+
def append_constant_signature(parts, file_index)
|
|
3300
|
+
parts.concat((file_index[:constant_writes] || {}).sort.map do |name, by_path|
|
|
3301
|
+
"k:#{name}=#{constant_descriptor_signature(by_path.values.first)}"
|
|
3302
|
+
end)
|
|
3303
|
+
end
|
|
3304
|
+
|
|
3305
|
+
# `[literal]` renders its value; an unpublishable write renders as `?`. Both halves matter: a write
|
|
3306
|
+
# appearing or vanishing moves the signature, and so does the same name's value changing.
|
|
3307
|
+
def constant_descriptor_signature(descriptor)
|
|
3308
|
+
descriptor.is_a?(Array) ? descriptor.first.inspect : "?"
|
|
3309
|
+
end
|
|
3310
|
+
|
|
2258
3311
|
# The class-declaration + ancestry + member-layout surface of the declaration signature (declared class
|
|
2259
3312
|
# names, superclass / include ancestry, Data/Struct member layouts). Kept out of
|
|
2260
3313
|
# {#declaration_signature} to hold its ABC budget.
|
|
@@ -2466,7 +3519,23 @@ module Rigor
|
|
|
2466
3519
|
# bundle's carries {DefHandle}s. The merges never deref the value, so both fold identically.
|
|
2467
3520
|
def fold_file_index(acc, file_index)
|
|
2468
3521
|
fold_def_tables(acc, file_index)
|
|
3522
|
+
# Issue #681 — a re-walked file contributes live nodes and their chains together. A file restored from
|
|
3523
|
+
# a seed bundle contributes NO entry here and cannot: this table is keyed by node identity, and the
|
|
3524
|
+
# only object a bundle has is a {DefHandle}. Issue #707 — the chain travels ON the handle instead, and
|
|
3525
|
+
# {DefNodeResolver} re-attaches it to the node it mints, so both paths answer the same chain for the
|
|
3526
|
+
# same body without either one keying a table by an object the other never sees.
|
|
3527
|
+
acc[:def_nestings].merge!(file_index[:def_nestings] || {})
|
|
2469
3528
|
fold_ancestry_tables(acc, file_index)
|
|
3529
|
+
fold_constant_tables(acc, file_index)
|
|
3530
|
+
end
|
|
3531
|
+
|
|
3532
|
+
# Issue #644 — the census folds per (name, path), so a re-folded file replaces exactly its own
|
|
3533
|
+
# contribution and the fold stays order-independent. `|| {}` keeps it total over a pre-#644 seed
|
|
3534
|
+
# bundle, which carries no census.
|
|
3535
|
+
def fold_constant_tables(acc, file_index)
|
|
3536
|
+
(file_index[:constant_writes] || {}).each do |name, by_path|
|
|
3537
|
+
(acc[:constant_writes][name] ||= {}).merge!(by_path)
|
|
3538
|
+
end
|
|
2470
3539
|
end
|
|
2471
3540
|
|
|
2472
3541
|
# def_nodes / singleton_def_nodes / method_visibilities / methods fold class-nested later-wins;
|
|
@@ -2492,12 +3561,21 @@ module Rigor
|
|
|
2492
3561
|
# superclasses later-wins; includes / class_sources accumulate; member layouts later-wins.
|
|
2493
3562
|
def fold_ancestry_tables(acc, file_index)
|
|
2494
3563
|
acc[:superclasses].merge!(file_index[:superclasses])
|
|
2495
|
-
|
|
3564
|
+
# Issue #682 — a pre-#682 seed bundle carries no header nestings; the SCHEMA bump makes such a blob a
|
|
3565
|
+
# cold rebuild, but default so any in-flight fold stays total and simply peels for those classes.
|
|
3566
|
+
merge_header_nestings(acc[:header_nestings], file_index[:header_nestings] || {})
|
|
3567
|
+
accumulate_module_lists(acc[:includes], file_index[:includes])
|
|
3568
|
+
accumulate_module_lists(acc[:extends], file_index[:extends] || {})
|
|
2496
3569
|
file_index[:class_sources].each { |cn, files| (acc[:class_sources][cn] ||= Set.new).merge(files) }
|
|
2497
3570
|
acc[:data_member_layouts].merge!(file_index[:data_member_layouts])
|
|
2498
3571
|
acc[:struct_member_layouts].merge!(file_index[:struct_member_layouts])
|
|
2499
3572
|
end
|
|
2500
3573
|
|
|
3574
|
+
# Shared accumulate-and-dedupe fold for the class -> module-name-list tables (includes / extends).
|
|
3575
|
+
def accumulate_module_lists(target, additions)
|
|
3576
|
+
additions.each { |cn, mods| target[cn] = ((target[cn] || []) + mods).uniq }
|
|
3577
|
+
end
|
|
3578
|
+
|
|
2501
3579
|
# ADR-85 WD2 — converts a file's live single-file index + its class table into a Marshal-clean seed
|
|
2502
3580
|
# bundle: the plain-data tables verbatim, the def-node tables re-expressed as `[node_id, name,
|
|
2503
3581
|
# fingerprint]` triples (the path is the bundle key), the class-source names (path implicit), and the
|
|
@@ -2514,15 +3592,22 @@ module Rigor
|
|
|
2514
3592
|
# value stored here equals the one a later recheck recomputes for an unchanged declaration.
|
|
2515
3593
|
declaration_signature: declaration_signature(file_index),
|
|
2516
3594
|
classes: file_classes,
|
|
2517
|
-
|
|
2518
|
-
|
|
3595
|
+
extends: file_index[:extends],
|
|
3596
|
+
def_nodes: live_defs_to_bundle(file_index[:def_nodes], file_index[:def_nestings]),
|
|
3597
|
+
singleton_def_nodes: live_defs_to_bundle(file_index[:singleton_def_nodes], file_index[:def_nestings]),
|
|
2519
3598
|
def_sources: file_index[:def_sources],
|
|
2520
3599
|
singleton_def_sources: file_index[:singleton_def_sources],
|
|
2521
3600
|
superclasses: file_index[:superclasses],
|
|
3601
|
+
# Issue #682 — plain `{class name => Array[String]}` data, so the bundle stays Marshal-clean and a
|
|
3602
|
+
# warm incremental file resolves its ancestor names the way a cold walk of it does.
|
|
3603
|
+
header_nestings: file_index[:header_nestings],
|
|
2522
3604
|
includes: file_index[:includes],
|
|
2523
3605
|
method_visibilities: file_index[:method_visibilities],
|
|
2524
3606
|
methods: file_index[:methods],
|
|
2525
3607
|
class_source_names: file_index[:class_sources].keys,
|
|
3608
|
+
# Issue #644 — the file's publication census (`name => [literal] | :unpublishable`; the path is
|
|
3609
|
+
# the bundle key). Plain data, so the bundle stays Marshal-clean.
|
|
3610
|
+
constant_writes: file_index[:constant_writes].transform_values { |by_path| by_path.values.first },
|
|
2526
3611
|
data_member_layouts: file_index[:data_member_layouts],
|
|
2527
3612
|
struct_member_layouts: file_index[:struct_member_layouts]
|
|
2528
3613
|
}
|
|
@@ -2540,42 +3625,60 @@ module Rigor
|
|
|
2540
3625
|
# but default to `{}` so any in-flight fold stays total.
|
|
2541
3626
|
singleton_def_sources: bundle[:singleton_def_sources] || {},
|
|
2542
3627
|
superclasses: bundle[:superclasses],
|
|
3628
|
+
header_nestings: bundle[:header_nestings] || {},
|
|
2543
3629
|
includes: bundle[:includes],
|
|
3630
|
+
# #526 — pre-extends bundles lack the key; default `{}` keeps the fold total.
|
|
3631
|
+
extends: bundle[:extends] || {},
|
|
2544
3632
|
method_visibilities: bundle[:method_visibilities],
|
|
2545
3633
|
methods: bundle[:methods],
|
|
2546
3634
|
class_sources: bundle[:class_source_names].to_h { |name| [name, Set[path]] },
|
|
3635
|
+
# Issue #644 — a pre-#644 bundle carries no census; the SCHEMA bump makes such a blob a cold
|
|
3636
|
+
# rebuild, but default so any in-flight fold stays total.
|
|
3637
|
+
constant_writes: (bundle[:constant_writes] || {}).transform_values { |descriptor| { path => descriptor } },
|
|
2547
3638
|
data_member_layouts: bundle[:data_member_layouts],
|
|
2548
3639
|
struct_member_layouts: bundle[:struct_member_layouts]
|
|
2549
3640
|
}
|
|
2550
3641
|
end
|
|
2551
3642
|
|
|
2552
|
-
# `{class => {method => Prism::DefNode}}` → `{class => {method => [node_id, name, fingerprint
|
|
2553
|
-
|
|
3643
|
+
# `{class => {method => Prism::DefNode}}` → `{class => {method => [node_id, name, fingerprint,
|
|
3644
|
+
# nesting]}}`. Issue #707 — `nestings` is this file's identity-keyed `{DefNode => Module.nesting}` table,
|
|
3645
|
+
# read here while the live nodes are still in hand, because it is the LAST moment the two can be paired:
|
|
3646
|
+
# the bundle's reader has only the row. Issue #716 — `[]` for a top-level def, which records the empty
|
|
3647
|
+
# chain; nil only for a def no declaration walk reached.
|
|
3648
|
+
def live_defs_to_bundle(defs, nestings)
|
|
2554
3649
|
defs.transform_values do |methods|
|
|
2555
3650
|
methods.transform_values do |node|
|
|
2556
|
-
[node.node_id, node.name.to_s, Digest::SHA256.hexdigest(node.location.slice)]
|
|
3651
|
+
[node.node_id, node.name.to_s, Digest::SHA256.hexdigest(node.location.slice), nestings[node]]
|
|
2557
3652
|
end
|
|
2558
3653
|
end
|
|
2559
3654
|
end
|
|
2560
3655
|
|
|
2561
|
-
# `{class => {method => [node_id, name, fingerprint]}}` → `{class => {method => DefHandle}}`
|
|
3656
|
+
# `{class => {method => [node_id, name, fingerprint, nesting]}}` → `{class => {method => DefHandle}}`
|
|
3657
|
+
# for `path`.
|
|
2562
3658
|
def bundle_defs_to_handles(defs, path)
|
|
2563
3659
|
defs.transform_values do |methods|
|
|
2564
|
-
methods.transform_values do |(node_id, name, fingerprint)|
|
|
2565
|
-
DefHandle.new(path: path, node_id: node_id, name: name, fingerprint: fingerprint)
|
|
3660
|
+
methods.transform_values do |(node_id, name, fingerprint, nesting)|
|
|
3661
|
+
DefHandle.new(path: path, node_id: node_id, name: name, fingerprint: fingerprint, nesting: nesting)
|
|
2566
3662
|
end
|
|
2567
3663
|
end
|
|
2568
3664
|
end
|
|
2569
3665
|
|
|
2570
3666
|
# The empty per-run accumulator the def-index passes fold each file into.
|
|
2571
3667
|
def new_def_index_accumulator
|
|
2572
|
-
{ def_nodes: {},
|
|
2573
|
-
|
|
3668
|
+
{ def_nodes: {}, def_nestings: {}.compare_by_identity,
|
|
3669
|
+
singleton_def_nodes: {}, def_sources: {}, singleton_def_sources: {},
|
|
3670
|
+
superclasses: {}, header_nestings: {}, includes: {}, extends: {}, method_visibilities: {}, methods: {},
|
|
3671
|
+
class_sources: {},
|
|
3672
|
+
constant_writes: {},
|
|
2574
3673
|
data_member_layouts: {}, struct_member_layouts: {} }
|
|
2575
3674
|
end
|
|
2576
3675
|
|
|
2577
3676
|
# Post-processes and freezes a fully-folded def-index accumulator.
|
|
2578
3677
|
def finalize_def_index(acc)
|
|
3678
|
+
# Issue #644 — resolve the cross-file constant-reassignment rule here, where the whole project's
|
|
3679
|
+
# write census is known, and turn the surviving literals into their published `Type::Constant`.
|
|
3680
|
+
acc[:constant_values], acc[:constant_sources] = finalize_constant_writes(acc[:constant_writes])
|
|
3681
|
+
fold_extends_into_singleton_tables(acc[:extends], acc[:def_nodes], acc[:singleton_def_nodes], acc[:methods])
|
|
2579
3682
|
# Cross-file method suppression is for the project's OWN accessors (attr_* / define_method / alias) — NOT for
|
|
2580
3683
|
# plain `def`s. A cross-file `def` on a class is exactly the ADR-17 monkey-patch case the undefined-method rule
|
|
2581
3684
|
# deliberately surfaces (fire + def-site annotation, nudging `pre_eval:`), so dropping the `def`-declared names
|
|
@@ -2583,7 +3686,7 @@ module Rigor
|
|
|
2583
3686
|
# for `obj.x` in another.
|
|
2584
3687
|
acc[:methods] = subtract_def_methods(acc[:methods], acc[:def_nodes])
|
|
2585
3688
|
%i[def_nodes singleton_def_nodes def_sources singleton_def_sources includes method_visibilities
|
|
2586
|
-
methods class_sources].each do |key|
|
|
3689
|
+
methods class_sources constant_sources].each do |key|
|
|
2587
3690
|
acc[key].each_value(&:freeze)
|
|
2588
3691
|
end
|
|
2589
3692
|
acc.transform_values(&:freeze)
|
|
@@ -2616,23 +3719,35 @@ module Rigor
|
|
|
2616
3719
|
# def-nodes ×2). See {#build_methods_and_def_nodes}.
|
|
2617
3720
|
file_methods, file_def_nodes = build_methods_and_def_nodes(root, path)
|
|
2618
3721
|
merge_discovered_defs(acc[:def_nodes], acc[:def_sources], path, file_def_nodes)
|
|
3722
|
+
# Issue #681 — node-identity keyed, so this is a flat union: no two files can contribute the same key.
|
|
3723
|
+
acc[:def_nestings].merge!(build_def_nestings(root))
|
|
2619
3724
|
# ADR-46 slice 4 (singleton) — record the singleton-side `"path:line"` sources alongside the nodes,
|
|
2620
3725
|
# the exact mirror of the instance-side `merge_discovered_defs`, so a class/singleton-method body edit
|
|
2621
3726
|
# produces a changed `"Class.method"` fingerprint pair (and its call sites a symbol edge) instead of
|
|
2622
3727
|
# silently degrading to the file's full ancestry closure.
|
|
2623
3728
|
merge_discovered_defs(acc[:singleton_def_nodes], acc[:singleton_def_sources], path,
|
|
2624
3729
|
build_discovered_singleton_def_nodes(root))
|
|
2625
|
-
superclasses =
|
|
3730
|
+
superclasses, header_nestings = build_superclass_tables(root, path)
|
|
2626
3731
|
includes = build_discovered_includes(root)
|
|
2627
3732
|
acc[:superclasses].merge!(superclasses)
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
3733
|
+
merge_header_nestings(acc[:header_nestings], header_nestings)
|
|
3734
|
+
accumulate_module_lists(acc[:includes], includes)
|
|
3735
|
+
accumulate_module_lists(acc[:extends], build_discovered_extends(root))
|
|
2631
3736
|
record_class_sources(acc[:class_sources], path, root, superclasses, includes, file_def_nodes)
|
|
3737
|
+
merge_constant_literal_tables(acc, root, path)
|
|
2632
3738
|
merge_class_keyed_index_tables(acc, root, file_methods)
|
|
2633
3739
|
merge_member_layout_tables(acc, root)
|
|
2634
3740
|
end
|
|
2635
3741
|
|
|
3742
|
+
# Issue #644 — folds one file's publication census into the cross-file accumulator, keyed by
|
|
3743
|
+
# (name, path) (kept out of {#accumulate_project_index} to hold its ABC budget). The conflict rule that
|
|
3744
|
+
# makes the fold order irrelevant runs at {#finalize_constant_writes}.
|
|
3745
|
+
def merge_constant_literal_tables(acc, root, path)
|
|
3746
|
+
constant_writes_for_file(root).each do |name, descriptor|
|
|
3747
|
+
(acc[:constant_writes][name] ||= {})[path] = descriptor
|
|
3748
|
+
end
|
|
3749
|
+
end
|
|
3750
|
+
|
|
2636
3751
|
# Folds one file's Data + Struct member-layout tables into the cross-file accumulator (kept out of
|
|
2637
3752
|
# {#accumulate_project_index} to hold its ABC budget).
|
|
2638
3753
|
def merge_member_layout_tables(acc, root)
|
|
@@ -2668,6 +3783,220 @@ module Rigor
|
|
|
2668
3783
|
names.each { |name| (class_sources[name] ||= Set.new) << path }
|
|
2669
3784
|
end
|
|
2670
3785
|
|
|
3786
|
+
# Issue #644 — the cross-file VALUE-constant pre-pass, the twin of {#record_class_sources} for plain
|
|
3787
|
+
# constant ASSIGNMENTS. Returns one file's **publication census**: `{qualified name => descriptor}`,
|
|
3788
|
+
# where the descriptor is either `[literal]` (a publishable frozen scalar) or {CONSTANT_UNPUBLISHABLE}.
|
|
3789
|
+
#
|
|
3790
|
+
# EVERY constant write is censused, whatever its rvalue and whatever its form, because the census is
|
|
3791
|
+
# three things at once and only the first cares about the value:
|
|
3792
|
+
#
|
|
3793
|
+
# 1. the published table ({#finalize_constant_writes}: a name publishes only when exactly one file
|
|
3794
|
+
# writes it and that write is a literal),
|
|
3795
|
+
# 2. the attribution the ADR-46 positive edge reads, and
|
|
3796
|
+
# 3. the producer whose per-file DIFF re-checks readers on an incremental run
|
|
3797
|
+
# ({Analysis::Incremental.changed_constant_publications}).
|
|
3798
|
+
#
|
|
3799
|
+
# A write the census cannot see does not merely lose precision — it silently bypasses the conflict rule
|
|
3800
|
+
# and publishes a value the program does not have. That is why the operator / multi-assign / chained /
|
|
3801
|
+
# `self::` forms below are censused as unpublishable rather than skipped.
|
|
3802
|
+
#
|
|
3803
|
+
# **Why the publishable set is only Symbol / Integer / Float.** Publishing turns `Dynamic[top]` into a
|
|
3804
|
+
# real type at every reader in the project, which is precisely where a wrong answer becomes a diagnostic
|
|
3805
|
+
# on correct code (AGENTS.md § "Implementation Guidelines"), so the recognised set is the one whose
|
|
3806
|
+
# precision is worth that risk.
|
|
3807
|
+
#
|
|
3808
|
+
# - `String` / `Array` / `Hash` literals are MUTABLE. A sibling file's `PATH << "/x"` or `LIST.push(...)`
|
|
3809
|
+
# moves the value this table pinned, and only the DECLARING file's `widen_mutated_constants` census can
|
|
3810
|
+
# see such a mutation. The composite two additionally carry the `HashShape` / `Tuple` hazard
|
|
3811
|
+
# {PreEvalConstants} documents (a closed `HashShape` makes `CONFIG.fetch(:b)` fire; a `Tuple` routes
|
|
3812
|
+
# through `ShapeDispatch` instead of the RBS overload a call site relied on).
|
|
3813
|
+
# - `nil` declines for {PreEvalConstants}' reason: at declaration position it is a placeholder for a
|
|
3814
|
+
# value assigned later far more often than it is a genuine `NilClass`.
|
|
3815
|
+
# - `true` / `false` DO publish. They are branched on rather than dispatched to, so the value would fuel
|
|
3816
|
+
# `flow.always-truthy-condition` at every `if FEATURE_FLAG` in another file; that is withheld at the
|
|
3817
|
+
# rule instead ({Analysis::CheckRules::PublishedConstantGuard}), which is the repo's own answer to a
|
|
3818
|
+
# value that is real but must not be concluded from, and it keeps the treatment of every published
|
|
3819
|
+
# kind the same.
|
|
3820
|
+
#
|
|
3821
|
+
# Everything else — a method call, a constant alias, `ENV[...]`, a `Data.define` — is not a literal, so
|
|
3822
|
+
# the reader keeps today's `Dynamic[top]`. Declining is always the safe answer.
|
|
3823
|
+
#
|
|
3824
|
+
# A name written TWICE in one file is unpublishable too, which is what makes a conditional
|
|
3825
|
+
# `X = :a if c` / `X = :b` pair gradual rather than pinned to whichever arm the walk saw first.
|
|
3826
|
+
def constant_writes_for_file(root)
|
|
3827
|
+
writes = {}
|
|
3828
|
+
walk_constant_write_census(root, [], writes, Set.new)
|
|
3829
|
+
writes
|
|
3830
|
+
end
|
|
3831
|
+
|
|
3832
|
+
# Mirrors {#walk_constant_writes}'s traversal for the forms it shares — the lexical class/module prefix
|
|
3833
|
+
# qualifies a bare `ConstantWriteNode` — and extends it to the four forms that walk misses, each
|
|
3834
|
+
# censused as unpublishable: an operator / `&&=` /
|
|
3835
|
+
# `||=` constant write, a `ConstantTargetNode` under a `MultiWriteNode` (`A, B = :x, :y`), a `self::X =`
|
|
3836
|
+
# whose target renders no qualified name, and the inner write of a chain (`C = D = :x`), which is why a
|
|
3837
|
+
# `ConstantWriteNode` descends into its own rvalue instead of returning.
|
|
3838
|
+
def walk_constant_write_census(node, qualified_prefix, writes, seen, self_owner = nil)
|
|
3839
|
+
return unless node.is_a?(Prism::Node)
|
|
3840
|
+
|
|
3841
|
+
case node
|
|
3842
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
3843
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
3844
|
+
if child_prefix
|
|
3845
|
+
walk_constant_write_census(node.body, child_prefix, writes, seen) if node.body
|
|
3846
|
+
return
|
|
3847
|
+
end
|
|
3848
|
+
else
|
|
3849
|
+
census_constant_write(node, qualified_prefix, writes, seen, self_owner)
|
|
3850
|
+
end
|
|
3851
|
+
|
|
3852
|
+
rebound = rebound_block_self(node, qualified_prefix)
|
|
3853
|
+
node.rigor_each_child do |child|
|
|
3854
|
+
owner = rebound && child.is_a?(Prism::BlockNode) ? rebound : self_owner
|
|
3855
|
+
walk_constant_write_census(child, qualified_prefix, writes, seen, owner)
|
|
3856
|
+
end
|
|
3857
|
+
end
|
|
3858
|
+
|
|
3859
|
+
# Censuses `node` when it is any constant-assigning form. Only a plain `ConstantWriteNode` /
|
|
3860
|
+
# `ConstantPathWriteNode` can contribute a VALUE; every other form is recorded unpublishable, which
|
|
3861
|
+
# suppresses publication of the name exactly as a second file's write would.
|
|
3862
|
+
def census_constant_write(node, qualified_prefix, writes, seen, self_owner = nil)
|
|
3863
|
+
case node
|
|
3864
|
+
when Prism::ConstantWriteNode
|
|
3865
|
+
record_constant_write_census(qualified_write_name(qualified_prefix, node.name.to_s),
|
|
3866
|
+
constant_literal_value(node.value), writes, seen)
|
|
3867
|
+
when Prism::ConstantPathWriteNode
|
|
3868
|
+
record_constant_write_census(constant_path_write_name(node.target, qualified_prefix, self_owner),
|
|
3869
|
+
constant_path_write_literal(node, self_owner), writes, seen)
|
|
3870
|
+
when Prism::ConstantOperatorWriteNode, Prism::ConstantOrWriteNode, Prism::ConstantAndWriteNode
|
|
3871
|
+
record_constant_write_census(qualified_write_name(qualified_prefix, node.name.to_s), nil, writes, seen)
|
|
3872
|
+
when Prism::ConstantPathOperatorWriteNode, Prism::ConstantPathOrWriteNode, Prism::ConstantPathAndWriteNode
|
|
3873
|
+
record_constant_write_census(constant_path_write_name(node.target, qualified_prefix, self_owner),
|
|
3874
|
+
nil, writes, seen)
|
|
3875
|
+
when Prism::MultiWriteNode
|
|
3876
|
+
census_multi_write_constants(node, qualified_prefix, writes, seen, self_owner)
|
|
3877
|
+
end
|
|
3878
|
+
end
|
|
3879
|
+
|
|
3880
|
+
# `A, B = :x, :y` and `A, *rest = …`. A destructured element's value is a projection of the right-hand
|
|
3881
|
+
# side, which this syntactic walk does not evaluate, so every constant target is censused unpublishable.
|
|
3882
|
+
def census_multi_write_constants(node, qualified_prefix, writes, seen, self_owner = nil)
|
|
3883
|
+
targets = node.lefts + node.rights
|
|
3884
|
+
targets << node.rest if node.rest
|
|
3885
|
+
targets.each do |target|
|
|
3886
|
+
case target
|
|
3887
|
+
when Prism::ConstantTargetNode
|
|
3888
|
+
record_constant_write_census(qualified_write_name(qualified_prefix, target.name.to_s), nil, writes, seen)
|
|
3889
|
+
when Prism::ConstantPathTargetNode
|
|
3890
|
+
record_constant_write_census(constant_path_write_name(target, qualified_prefix, self_owner),
|
|
3891
|
+
nil, writes, seen)
|
|
3892
|
+
end
|
|
3893
|
+
end
|
|
3894
|
+
end
|
|
3895
|
+
|
|
3896
|
+
# The qualified name a constant-path write targets, for the PUBLICATION census only. `Foo::BAR = …`
|
|
3897
|
+
# renders as written — deliberately NOT what {#constant_path_write_key} answers for the typed table,
|
|
3898
|
+
# which resolves the namespace through the enclosing nesting
|
|
3899
|
+
# ([#690](https://github.com/rigortype/rigor/issues/690)). Moving this one changes which name a value
|
|
3900
|
+
# publishes to the whole project, an over-suppression question with its own safety story, so the two
|
|
3901
|
+
# censuses key a path write differently until that is settled.
|
|
3902
|
+
# `self::BAR = …` names whatever `self` is at that point, which the walk carries in `self_owner`
|
|
3903
|
+
# ([#705](https://github.com/rigortype/rigor/issues/705)) — the enclosing lexical namespace in an
|
|
3904
|
+
# ordinary body, the receiver inside a `class_eval` block. Where that `self` is {OPAQUE_SELF}, and for
|
|
3905
|
+
# any other dynamic receiver (`klass::BAR = …`), the target is "some class, then `::BAR`" and the name
|
|
3906
|
+
# falls back to the bare last segment — the one spelling that class could make it, `Object`. Suppressing
|
|
3907
|
+
# a top-level name is gradual typing, which is the safe direction here; the VALUE is what such a write
|
|
3908
|
+
# must not contribute ({#constant_path_write_literal}).
|
|
3909
|
+
def constant_path_write_name(target, qualified_prefix, self_owner = nil)
|
|
3910
|
+
full = Source::ConstantPath.qualified_name_or_nil(target)
|
|
3911
|
+
return full if full
|
|
3912
|
+
|
|
3913
|
+
base = target.name&.to_s
|
|
3914
|
+
return nil if base.nil?
|
|
3915
|
+
return base unless target.parent.is_a?(Prism::SelfNode)
|
|
3916
|
+
|
|
3917
|
+
self_write_name(qualified_prefix, self_owner, base) || base
|
|
3918
|
+
end
|
|
3919
|
+
|
|
3920
|
+
# The publishable literal a path write contributes — none, whenever its base is not statically
|
|
3921
|
+
# nameable ([#705](https://github.com/rigortype/rigor/issues/705)). `[Foo].each { |k| k::X = 1 }`
|
|
3922
|
+
# renders as the bare `X`, and publishing `1` under it handed every reader of the top-level `X` a value
|
|
3923
|
+
# the program never has: `X == 2` folded to `false`, and in the WRITING file — where
|
|
3924
|
+
# `Scope#local_constant_names` exempts the name from #644's withholding guard —
|
|
3925
|
+
# `flow.always-truthy-condition` then fired on correct code.
|
|
3926
|
+
#
|
|
3927
|
+
# The NAME still enters the census, because a decline means opposite things in the two censuses. In the
|
|
3928
|
+
# typed one, recording no type is gradual. Here, recording nothing would leave ANOTHER file's value for
|
|
3929
|
+
# the name trusted — silence is the risky direction, and the census's own gradual answer is to say the
|
|
3930
|
+
# name was touched and no value for it is safe.
|
|
3931
|
+
def constant_path_write_literal(node, self_owner)
|
|
3932
|
+
return nil unless nameable_write_target?(node.target, self_owner)
|
|
3933
|
+
|
|
3934
|
+
constant_literal_value(node.value)
|
|
3935
|
+
end
|
|
3936
|
+
|
|
3937
|
+
# True when the census can name what the write targets: a static constant path, or a `self::BAR` whose
|
|
3938
|
+
# `self` the walk resolved to a class or module.
|
|
3939
|
+
def nameable_write_target?(target, self_owner)
|
|
3940
|
+
return true if Source::ConstantPath.qualified_name_or_nil(target)
|
|
3941
|
+
return false unless target.parent.is_a?(Prism::SelfNode)
|
|
3942
|
+
|
|
3943
|
+
!self_owner.equal?(OPAQUE_SELF)
|
|
3944
|
+
end
|
|
3945
|
+
|
|
3946
|
+
def qualified_write_name(qualified_prefix, base_name)
|
|
3947
|
+
qualified_prefix.empty? ? base_name : "#{qualified_prefix.join('::')}::#{base_name}"
|
|
3948
|
+
end
|
|
3949
|
+
|
|
3950
|
+
# Records one censused write. `seen` carries the names this file has already written, so a repeat write
|
|
3951
|
+
# retracts the publishable descriptor whatever either rvalue was — neither arm of an in-file
|
|
3952
|
+
# reassignment is ever published, and the name still counts as written.
|
|
3953
|
+
def record_constant_write_census(full, literal, writes, seen)
|
|
3954
|
+
return if full.nil?
|
|
3955
|
+
|
|
3956
|
+
writes[full] = seen.add?(full) ? (literal || CONSTANT_UNPUBLISHABLE) : CONSTANT_UNPUBLISHABLE
|
|
3957
|
+
end
|
|
3958
|
+
|
|
3959
|
+
# The frozen-scalar literal fold: `[value]` for a publishable rvalue, nil to decline. The one-element
|
|
3960
|
+
# Array is the presence wrapper (`false` is a legitimate published value), and the raw Ruby value is
|
|
3961
|
+
# what rides the ADR-85 seed bundle through `Marshal`.
|
|
3962
|
+
def constant_literal_value(rvalue)
|
|
3963
|
+
case rvalue
|
|
3964
|
+
when Prism::SymbolNode then symbol_literal_publication(rvalue)
|
|
3965
|
+
when Prism::IntegerNode, Prism::FloatNode then [rvalue.value]
|
|
3966
|
+
when Prism::TrueNode then [true]
|
|
3967
|
+
when Prism::FalseNode then [false]
|
|
3968
|
+
end
|
|
3969
|
+
end
|
|
3970
|
+
|
|
3971
|
+
# `Prism::SymbolNode` is the STATIC symbol node (a dynamic `:"#{x}"` parses as
|
|
3972
|
+
# `InterpolatedSymbolNode` and never reaches here), so its unescaped text is the whole value.
|
|
3973
|
+
def symbol_literal_publication(node)
|
|
3974
|
+
text = node.unescaped
|
|
3975
|
+
text.is_a?(String) ? [text.to_sym] : nil
|
|
3976
|
+
end
|
|
3977
|
+
|
|
3978
|
+
# Issue #644 — materialises the published table and the write attribution from the folded census.
|
|
3979
|
+
#
|
|
3980
|
+
# A name publishes only when exactly ONE file writes it and that write is a literal. Two files writing
|
|
3981
|
+
# the same qualified name is a cross-file reassignment whose winner is load order, so the name stays
|
|
3982
|
+
# `Dynamic[top]` — the type it already had, and the widest there is. The rule runs at finalize (not at
|
|
3983
|
+
# fold time) so the fold stays order-independent, which is what lets the ADR-85 incremental path re-fold
|
|
3984
|
+
# a changed file's contribution in place.
|
|
3985
|
+
#
|
|
3986
|
+
# @return [Array(Hash, Hash)] `[{name => Type}, {name => Set[path]}]`.
|
|
3987
|
+
def finalize_constant_writes(writes)
|
|
3988
|
+
values = {}
|
|
3989
|
+
sources = {}
|
|
3990
|
+
writes.each do |name, by_path|
|
|
3991
|
+
sources[name] = by_path.keys.to_set.freeze
|
|
3992
|
+
next unless by_path.size == 1
|
|
3993
|
+
|
|
3994
|
+
descriptor = by_path.values.first
|
|
3995
|
+
values[name] = Type::Combinator.constant_of(descriptor.first) if descriptor.is_a?(Array)
|
|
3996
|
+
end
|
|
3997
|
+
[values, sources]
|
|
3998
|
+
end
|
|
3999
|
+
|
|
2671
4000
|
# Merges one file's `class → method → DefNode` map into the cross-file `def_nodes` index and records each method's
|
|
2672
4001
|
# first- seen `"path:line"` definition site in `def_sources` (ADR-17 — the un-registered-project-patch signal
|
|
2673
4002
|
# `call.undefined-method` and `rigor triage` key on).
|
|
@@ -2688,11 +4017,11 @@ module Rigor
|
|
|
2688
4017
|
|
|
2689
4018
|
case node
|
|
2690
4019
|
when Prism::ClassNode, Prism::ModuleNode
|
|
2691
|
-
|
|
2692
|
-
if
|
|
2693
|
-
full =
|
|
4020
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
4021
|
+
if child_prefix
|
|
4022
|
+
full = child_prefix.join("::")
|
|
2694
4023
|
accumulator[full] = Type::Combinator.singleton_of(full)
|
|
2695
|
-
return collect_class_decls(node.body,
|
|
4024
|
+
return collect_class_decls(node.body, child_prefix, accumulator) if node.body
|
|
2696
4025
|
end
|
|
2697
4026
|
when Prism::ConstantWriteNode
|
|
2698
4027
|
record_class_new_constant_decl(node, qualified_prefix, accumulator)
|
|
@@ -2775,7 +4104,32 @@ module Rigor
|
|
|
2775
4104
|
identity_table = {}.compare_by_identity
|
|
2776
4105
|
discovered = {}
|
|
2777
4106
|
record_declarations(root, [], identity_table, discovered)
|
|
2778
|
-
[identity_table.freeze, discovered.freeze]
|
|
4107
|
+
[identity_table.freeze, synthesize_namespace_prefixes(discovered).freeze]
|
|
4108
|
+
end
|
|
4109
|
+
|
|
4110
|
+
# Issue #528 — every proper prefix of a discovered COMPACT class name is a namespace module that
|
|
4111
|
+
# provably exists at runtime, even when no `module X` declaration is anywhere in the source
|
|
4112
|
+
# (Zeitwerk derives it from the directory: mastodon writes `class Api::V1::AccountsController`
|
|
4113
|
+
# and never `module Api`). Registering the prefixes lets a bare `Api` read — and the inner
|
|
4114
|
+
# ConstantReadNodes of every resolving constant path — type as the namespace singleton instead of
|
|
4115
|
+
# falling to the unresolved fallback (~500 sites on mastodon). An explicitly-declared name always
|
|
4116
|
+
# wins: prefixes never overwrite an existing entry.
|
|
4117
|
+
def synthesize_namespace_prefixes(discovered)
|
|
4118
|
+
additions = {}
|
|
4119
|
+
discovered.each_key do |full|
|
|
4120
|
+
next unless full.include?("::")
|
|
4121
|
+
|
|
4122
|
+
segments = full.split("::")
|
|
4123
|
+
(1...segments.size).each do |keep|
|
|
4124
|
+
prefix = segments.first(keep).join("::")
|
|
4125
|
+
next if discovered.key?(prefix) || additions.key?(prefix)
|
|
4126
|
+
|
|
4127
|
+
additions[prefix] = Type::Combinator.singleton_of(prefix)
|
|
4128
|
+
end
|
|
4129
|
+
end
|
|
4130
|
+
return discovered if additions.empty?
|
|
4131
|
+
|
|
4132
|
+
discovered.merge(additions)
|
|
2779
4133
|
end
|
|
2780
4134
|
|
|
2781
4135
|
def record_declarations(node, qualified_prefix, identity_table, discovered)
|
|
@@ -2794,14 +4148,13 @@ module Rigor
|
|
|
2794
4148
|
end
|
|
2795
4149
|
|
|
2796
4150
|
def record_class_or_module?(node, qualified_prefix, identity_table, discovered)
|
|
2797
|
-
|
|
2798
|
-
return false unless
|
|
4151
|
+
child_prefix = Source::ConstantPath.declaration_prefix(qualified_prefix, node.constant_path)
|
|
4152
|
+
return false unless child_prefix
|
|
2799
4153
|
|
|
2800
|
-
full =
|
|
4154
|
+
full = child_prefix.join("::")
|
|
2801
4155
|
singleton = Type::Combinator.singleton_of(full)
|
|
2802
4156
|
identity_table[node.constant_path] = singleton
|
|
2803
4157
|
discovered[full] = singleton
|
|
2804
|
-
child_prefix = qualified_prefix + [name]
|
|
2805
4158
|
record_declarations(node.body, child_prefix, identity_table, discovered) if node.body
|
|
2806
4159
|
true
|
|
2807
4160
|
end
|
|
@@ -2818,7 +4171,8 @@ module Rigor
|
|
|
2818
4171
|
# The block body, if present, is recursed into so any nested class/module declarations in the override block (rare
|
|
2819
4172
|
# but legal) still feed the discovered table.
|
|
2820
4173
|
def record_meta_new_constant?(node, qualified_prefix, identity_table, discovered)
|
|
2821
|
-
|
|
4174
|
+
factory_call = resolve_meta_factory_call(node.value)
|
|
4175
|
+
return false unless factory_call
|
|
2822
4176
|
|
|
2823
4177
|
full = (qualified_prefix + [node.name.to_s]).join("::")
|
|
2824
4178
|
discovered[full] = Type::Combinator.singleton_of(full)
|
|
@@ -2932,6 +4286,5 @@ module Rigor
|
|
|
2932
4286
|
propagate(node.else_clause, table, truthy_scope) if node.else_clause
|
|
2933
4287
|
end
|
|
2934
4288
|
end
|
|
2935
|
-
# rubocop:enable Metrics/ModuleLength
|
|
2936
4289
|
end
|
|
2937
4290
|
end
|