rigortype 0.4.4 → 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +5 -5
- data/docs/handbook/11-sig-gen.md +100 -18
- data/docs/manual/02-cli-reference.md +85 -16
- data/docs/manual/03-configuration.md +12 -9
- data/docs/manual/04-diagnostics.md +17 -6
- data/docs/manual/05-inspecting-types.md +2 -2
- data/docs/manual/06-baseline.md +1 -1
- data/docs/manual/07-plugins.md +2 -1
- data/docs/manual/08-skills.md +7 -0
- data/docs/manual/10-mcp-server.md +17 -2
- data/docs/manual/12-caching.md +8 -1
- data/docs/manual/18-removing-dead-code.md +18 -7
- data/docs/manual/plugins/rigor-actionmailer.md +30 -0
- data/docs/manual/plugins/rigor-actionpack.md +30 -0
- data/docs/manual/plugins/rigor-devise.md +15 -0
- data/docs/manual/plugins/rigor-rspec.md +102 -1
- data/lib/rigor/analysis/buffer_binding.rb +14 -0
- data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
- data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +115 -7
- data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +253 -0
- data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
- data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
- data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
- data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +64 -4
- data/lib/rigor/analysis/check_rules.rb +319 -21
- data/lib/rigor/analysis/dependency_recorder.rb +11 -0
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/edit_replay.rb +67 -0
- data/lib/rigor/analysis/incremental.rb +86 -0
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
- data/lib/rigor/analysis/incremental_session.rb +336 -46
- data/lib/rigor/analysis/path_expansion.rb +44 -1
- data/lib/rigor/analysis/reachability/graph.rb +90 -13
- data/lib/rigor/analysis/reachability/scan.rb +279 -39
- data/lib/rigor/analysis/require_relative_closure.rb +100 -0
- data/lib/rigor/analysis/rule_catalog.rb +22 -5
- data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
- data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
- data/lib/rigor/analysis/runner/pool_coordinator.rb +82 -16
- data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
- data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
- data/lib/rigor/analysis/runner.rb +222 -111
- data/lib/rigor/analysis/severity_stamp.rb +11 -0
- data/lib/rigor/analysis/worker_session.rb +24 -1
- data/lib/rigor/cache/descriptor.rb +40 -7
- data/lib/rigor/cache/engine_source.rb +3 -2
- data/lib/rigor/cache/incremental_snapshot.rb +49 -8
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +4 -3
- data/lib/rigor/cli/check_command.rb +93 -4
- data/lib/rigor/cli/command.rb +3 -1
- data/lib/rigor/cli/diff_command.rb +2 -1
- data/lib/rigor/cli/docs_command.rb +3 -2
- data/lib/rigor/cli/doctor_command.rb +1 -1
- data/lib/rigor/cli/explain_command.rb +3 -2
- data/lib/rigor/cli/lens_command.rb +5 -5
- data/lib/rigor/cli/mcp_command.rb +2 -1
- data/lib/rigor/cli/options.rb +20 -3
- data/lib/rigor/cli/plugin_command.rb +2 -1
- data/lib/rigor/cli/plugins_command.rb +7 -1
- data/lib/rigor/cli/plugins_renderer.rb +24 -2
- data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
- data/lib/rigor/cli/sig_gen_command.rb +73 -13
- data/lib/rigor/cli/skill_command.rb +2 -1
- data/lib/rigor/cli/skill_describe.rb +3 -2
- data/lib/rigor/cli/type_of_command.rb +2 -1
- data/lib/rigor/cli/type_of_project_scope.rb +27 -0
- data/lib/rigor/cli/unused_command.rb +10 -7
- data/lib/rigor/cli.rb +3 -2
- data/lib/rigor/config_audit.rb +72 -16
- data/lib/rigor/configuration/severity_profile.rb +20 -2
- data/lib/rigor/configuration.rb +43 -7
- data/lib/rigor/effects/signature_sources.rb +3 -1
- data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
- data/lib/rigor/environment/gated_signature_guard.rb +2 -1
- data/lib/rigor/environment/gated_signature_plan.rb +2 -1
- data/lib/rigor/environment/installed_gem_set.rb +2 -1
- data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
- data/lib/rigor/environment/rbs_loader.rb +136 -7
- data/lib/rigor/environment/required_features.rb +136 -29
- data/lib/rigor/environment/ruby_library_roots.rb +122 -0
- data/lib/rigor/environment.rb +4 -2
- data/lib/rigor/inference/block_call_timing.rb +7 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
- data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
- data/lib/rigor/inference/captured_locals.rb +44 -13
- data/lib/rigor/inference/clause_widening.rb +90 -0
- data/lib/rigor/inference/deep_erasure.rb +138 -0
- data/lib/rigor/inference/def_return_typer.rb +159 -9
- data/lib/rigor/inference/definer_resolution.rb +101 -20
- data/lib/rigor/inference/error_info.rb +17 -3
- data/lib/rigor/inference/expression_typer.rb +97 -18
- data/lib/rigor/inference/global_write_census.rb +36 -1
- data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
- data/lib/rigor/inference/ivar_writer_census.rb +195 -0
- data/lib/rigor/inference/loop_value_record.rb +53 -0
- data/lib/rigor/inference/macro_block_self_type.rb +20 -6
- data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +278 -42
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
- data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
- data/lib/rigor/inference/method_dispatcher.rb +113 -19
- data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
- data/lib/rigor/inference/module_function_state.rb +149 -42
- data/lib/rigor/inference/mutation_widening.rb +5 -2
- data/lib/rigor/inference/narrowing.rb +116 -11
- data/lib/rigor/inference/object_mixins.rb +13 -1
- data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
- data/lib/rigor/inference/project_patched_methods.rb +168 -4
- data/lib/rigor/inference/project_patched_mixins.rb +162 -0
- data/lib/rigor/inference/project_patched_scanner.rb +51 -12
- data/lib/rigor/inference/project_patched_visibility.rb +214 -0
- data/lib/rigor/inference/project_seed_tables.rb +121 -0
- data/lib/rigor/inference/project_yielder.rb +128 -0
- data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
- data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
- data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
- data/lib/rigor/inference/return_barrier.rb +16 -0
- data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
- data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
- data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
- data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
- data/lib/rigor/inference/scope_indexer.rb +1144 -197
- data/lib/rigor/inference/self_call_ivar_writes.rb +29 -12
- data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
- data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
- data/lib/rigor/inference/statement_evaluator.rb +1088 -213
- data/lib/rigor/inference/synthetic_method_index.rb +23 -0
- data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
- data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
- data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
- data/lib/rigor/language_server/buffer_table.rb +6 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
- data/lib/rigor/language_server/project_context.rb +59 -3
- data/lib/rigor/language_server/server.rb +5 -3
- data/lib/rigor/lens/contract_reader.rb +45 -2
- data/lib/rigor/lens/document.rb +2 -1
- data/lib/rigor/lens/file_reader.rb +12 -2
- data/lib/rigor/lens/members.rb +16 -2
- data/lib/rigor/lens/run.rb +34 -7
- data/lib/rigor/mcp/server.rb +11 -3
- data/lib/rigor/plugin/base.rb +29 -13
- data/lib/rigor/plugin/io_boundary.rb +42 -6
- data/lib/rigor/plugin/isolation.rb +4 -3
- data/lib/rigor/plugin/manifest.rb +29 -3
- data/lib/rigor/plugin/registry.rb +38 -0
- data/lib/rigor/protection/discovery_seed.rb +64 -36
- data/lib/rigor/protection/mutation_cache.rb +8 -2
- data/lib/rigor/rooted_glob.rb +36 -0
- data/lib/rigor/scope/discovery_index.rb +5 -0
- data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
- data/lib/rigor/scope/resolution_chain.rb +40 -5
- data/lib/rigor/scope.rb +59 -7
- data/lib/rigor/sig_gen/alias_index.rb +2 -1
- data/lib/rigor/sig_gen/classification.rb +4 -1
- data/lib/rigor/sig_gen/generator.rb +465 -49
- data/lib/rigor/sig_gen/layout_index.rb +5 -2
- data/lib/rigor/sig_gen/observation_collector.rb +141 -12
- data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
- data/lib/rigor/sig_gen/renderer.rb +16 -155
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
- data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
- data/lib/rigor/sig_gen/write_reporter.rb +203 -0
- data/lib/rigor/sig_gen/write_result.rb +3 -1
- data/lib/rigor/sig_gen/writer.rb +5 -3
- data/lib/rigor/signature_path_audit.rb +4 -2
- data/lib/rigor/type/combinator.rb +4 -3
- data/lib/rigor/type/constant.rb +54 -3
- data/lib/rigor/type/hash_shape.rb +14 -9
- data/lib/rigor/type/struct_instance.rb +3 -2
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +61 -44
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
- data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
- data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +7 -6
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
- data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
- data/sig/rigor/analysis/baseline.rbs +3 -2
- data/sig/rigor/effects/file_collection.rbs +5 -6
- data/sig/rigor/effects/registry.rbs +8 -6
- data/sig/rigor/effects/summary.rbs +6 -5
- data/sig/rigor/environment.rbs +5 -3
- data/sig/rigor/inference/optimistic_origin.rbs +11 -0
- data/sig/rigor/inference.rbs +6 -0
- data/sig/rigor/plugin/additional_initializer.rbs +5 -5
- data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
- data/sig/rigor/plugin/effect_attribution.rbs +25 -10
- data/sig/rigor/plugin/effect_edge.rbs +12 -5
- data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
- data/sig/rigor/plugin/io_boundary.rbs +5 -2
- data/sig/rigor/plugin/manifest.rbs +16 -18
- data/sig/rigor/plugin/protocol_contract.rbs +10 -9
- data/sig/rigor/plugin/registry.rbs +1 -1
- data/sig/rigor/rooted_glob.rbs +7 -0
- data/sig/rigor/scope.rbs +34 -52
- data/sig/rigor/type.rbs +0 -6
- data/skills/rigor-doctor/references/01-checks.md +23 -11
- data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
- metadata +32 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../analysis/path_expansion"
|
|
4
|
+
require_relative "../rooted_glob"
|
|
4
5
|
|
|
5
6
|
module Rigor
|
|
6
7
|
class Environment
|
|
@@ -42,6 +43,36 @@ module Rigor
|
|
|
42
43
|
# anything.
|
|
43
44
|
PATTERN = /(?:(?<![\w.$@])|(?<=Kernel\.))require[ \t]*\(?[ \t]*(["'])(#{FEATURE_ALTERNATION})\1/
|
|
44
45
|
|
|
46
|
+
# Issue #2174 — the gems Ruby 4.0 bundles (its `gems/bundled_gems` list, which an installation does not
|
|
47
|
+
# keep): installed beside the interpreter, but loaded only by an explicit `require`. Gems installed later
|
|
48
|
+
# into the same directory are deliberately not read from it.
|
|
49
|
+
BUNDLED_GEMS = %w[
|
|
50
|
+
abbrev base64 benchmark bigdecimal csv debug drb fiddle getoptlong irb logger matrix minitest mutex_m
|
|
51
|
+
net-ftp net-imap net-pop net-smtp nkf observer ostruct power_assert prime pstore racc rake rbs rdoc readline
|
|
52
|
+
reline repl_type_completor resolv-replace rexml rinda rss syslog test-unit typeprof win32ole
|
|
53
|
+
].freeze
|
|
54
|
+
|
|
55
|
+
# Issue #2174 — the first path segments of the features Ruby ships: the `.rb` files and directories of the
|
|
56
|
+
# interpreter's library directory, the extensions and directories of its architecture directory, and the
|
|
57
|
+
# bundled gems' names (`net-ftp` → `net`). Read once, at load time and on the main Ractor, since
|
|
58
|
+
# `RbConfig::CONFIG` is not shareable; empty when the directories cannot be read.
|
|
59
|
+
SHIPPED_SEGMENTS = begin
|
|
60
|
+
require "rbconfig"
|
|
61
|
+
segments = Set.new
|
|
62
|
+
[RbConfig::CONFIG["rubylibdir"], RbConfig::CONFIG["archdir"]].compact.each do |dir|
|
|
63
|
+
next unless File.directory?(dir)
|
|
64
|
+
|
|
65
|
+
Dir.children(dir).each { |entry| segments << entry.sub(/\.(?:rb|so|bundle|dll)\z/, "") }
|
|
66
|
+
end
|
|
67
|
+
BUNDLED_GEMS.each { |name| segments << name.split("-").first }
|
|
68
|
+
segments.to_set(&:freeze).freeze
|
|
69
|
+
rescue StandardError
|
|
70
|
+
Set.new.freeze
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# A `require` of any literal feature, read as {PATTERN} reads one; {.features_of} keeps those Ruby ships.
|
|
74
|
+
ANY_FEATURE = %r{(?:(?<![\w.$@])|(?<=Kernel\.))require[ \t]*\(?[ \t]*(["'])([\w-]+(?:/[\w-]+)*)\1}
|
|
75
|
+
|
|
45
76
|
module_function
|
|
46
77
|
|
|
47
78
|
# The features a run over `configuration` requires: every file under the configured paths, plus
|
|
@@ -53,37 +84,51 @@ module Rigor
|
|
|
53
84
|
def for_configuration(configuration, extra_files = [], sources: {})
|
|
54
85
|
files = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns,
|
|
55
86
|
base: configuration.exclude_base)
|
|
56
|
-
scan(files + Array(extra_files).map(&:to_s), sources: sources
|
|
87
|
+
scan(files + Array(extra_files).map(&:to_s), sources: sources,
|
|
88
|
+
sig_paths: configured_sig_paths(configuration))
|
|
57
89
|
rescue StandardError
|
|
58
90
|
[]
|
|
59
91
|
end
|
|
60
92
|
|
|
93
|
+
# The project signature roots the environment loads: the configured `signature_paths:`, else `./sig` when it
|
|
94
|
+
# exists (`Environment.project_signature_roots`, which this leaf file does not load).
|
|
95
|
+
def configured_sig_paths(configuration)
|
|
96
|
+
configured = configuration.signature_paths
|
|
97
|
+
return Array(configured).map(&:to_s) if configured
|
|
98
|
+
|
|
99
|
+
File.directory?("sig") ? ["sig"] : []
|
|
100
|
+
end
|
|
101
|
+
|
|
61
102
|
# @param files — source paths (Strings or Pathnames); an unreadable one contributes nothing.
|
|
62
103
|
# @param sources — path => source text, scanned instead of the file; its paths are scanned even when
|
|
63
104
|
# `files` does not list them.
|
|
64
|
-
# @
|
|
65
|
-
|
|
105
|
+
# @param sig_paths — the project's signature roots, whose `.rbs` declaration headers count as the project's
|
|
106
|
+
# own names ({.relevant}).
|
|
107
|
+
# @return the sorted, frozen features some file requires, a shipped one kept only when {.relevant}.
|
|
108
|
+
def scan(files, sources: {}, sig_paths: [])
|
|
66
109
|
sources = sources.to_h { |path, text| [File.expand_path(path.to_s), text] }
|
|
67
110
|
paths = (Array(files).map { |path| source_key(File.expand_path(path.to_s), sources) } + sources.keys).uniq
|
|
68
111
|
found = Set.new
|
|
112
|
+
names = Set.new
|
|
69
113
|
paths.each do |path|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
114
|
+
features, declared = sources.key?(path) ? scan_source(sources[path]) : memoized_scan_in(path)
|
|
115
|
+
found.merge(features)
|
|
116
|
+
names.merge(declared)
|
|
73
117
|
end
|
|
74
118
|
prune_memo(paths)
|
|
75
|
-
found.
|
|
119
|
+
relevant(found, names.merge(sig_names(sig_paths))).sort.freeze
|
|
76
120
|
end
|
|
77
121
|
|
|
78
|
-
# @return path (absolute) =>
|
|
79
|
-
# that
|
|
122
|
+
# @return path (absolute) => `[features, declared names]` ({.scan_source}) for every file under the
|
|
123
|
+
# configured paths that has either. A long-lived reader (the language server) keeps this, overlays its open
|
|
124
|
+
# buffers and passes the union to {.relevant}.
|
|
80
125
|
def feature_files(configuration)
|
|
81
126
|
listed = Analysis::PathExpansion.ruby_files(configuration.paths, configuration.exclude_patterns,
|
|
82
127
|
base: configuration.exclude_base)
|
|
83
128
|
files = listed.map { |path| File.expand_path(path.to_s) }
|
|
84
129
|
result = files.each_with_object({}) do |path, acc|
|
|
85
|
-
|
|
86
|
-
acc[path] =
|
|
130
|
+
scanned = memoized_scan_in(path)
|
|
131
|
+
acc[path] = scanned unless scanned.all?(&:empty?)
|
|
87
132
|
end
|
|
88
133
|
prune_memo(files)
|
|
89
134
|
result
|
|
@@ -91,6 +136,42 @@ module Rigor
|
|
|
91
136
|
{}
|
|
92
137
|
end
|
|
93
138
|
|
|
139
|
+
# Issue #2174 — `features` with each shipped feature kept only when one of `names` (constant names the
|
|
140
|
+
# project's source or signatures declare a class or module under) is a root the feature's library declares
|
|
141
|
+
# ({RubyLibraryRoots}). Only such a feature can open a class (`RbsLoader#ruby_library_reopen?` asks it of a
|
|
142
|
+
# class every declaration of which is the project's), so dropping the rest changes no diagnostic, and keeps a
|
|
143
|
+
# `require "json"` edit in a project that reopens no library class out of the incremental snapshot's
|
|
144
|
+
# fingerprint and the language server's environment key. The listed features ({VENDORED_DIRS}) are kept.
|
|
145
|
+
def relevant(features, names)
|
|
146
|
+
return features.to_a if features.all? { |feature| VENDORED_DIRS.key?(feature) }
|
|
147
|
+
|
|
148
|
+
segments = names.empty? ? EMPTY_SEGMENTS : library_segments(names)
|
|
149
|
+
features.select { |feature| VENDORED_DIRS.key?(feature) || segments.include?(feature.split("/").first) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# The feature segments of the libraries that declare one of `names` as a root. Loaded lazily: the roots read
|
|
153
|
+
# the `rbs` stdlib tree, which a run with no shipped `require` never needs.
|
|
154
|
+
def library_segments(names)
|
|
155
|
+
require_relative "ruby_library_roots"
|
|
156
|
+
roots = RubyLibraryRoots.shared
|
|
157
|
+
names.each_with_object(Set.new) { |name, acc| acc.merge(roots.fetch(name, EMPTY_SEGMENTS)) }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
EMPTY_SEGMENTS = [].freeze
|
|
161
|
+
|
|
162
|
+
# The constant names the `.rbs` files under `sig_paths` declare a class or module under.
|
|
163
|
+
def sig_names(sig_paths)
|
|
164
|
+
Array(sig_paths).each_with_object(Set.new) do |root, acc|
|
|
165
|
+
next unless File.directory?(root.to_s)
|
|
166
|
+
|
|
167
|
+
RootedGlob.glob(root.to_s, "**/*.rbs").each do |path|
|
|
168
|
+
acc.merge(declared_names(File.binread(path)))
|
|
169
|
+
rescue SystemCallError, IOError
|
|
170
|
+
next
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
94
175
|
# Paths are compared expanded, so a relative `--instead-of=lib/a.rb` replaces the configured
|
|
95
176
|
# `/project/lib/a.rb`; a source whose real path matches (a symlinked directory) replaces it too. The real
|
|
96
177
|
# path is asked only for a file that shares a source's basename, so a scan does not resolve every path.
|
|
@@ -114,8 +195,8 @@ module Rigor
|
|
|
114
195
|
# build; a long-lived language server on every rebuild), so on the main Ractor each file's answer is kept
|
|
115
196
|
# against its stat and the file is re-read only when the stat moved. A non-main Ractor may not touch the
|
|
116
197
|
# module's state and reads the file every time.
|
|
117
|
-
def
|
|
118
|
-
return
|
|
198
|
+
def memoized_scan_in(path)
|
|
199
|
+
return scan_in(path) unless Ractor.current == Ractor.main
|
|
119
200
|
|
|
120
201
|
stat = File.stat(path)
|
|
121
202
|
stamp = [stat.mtime.to_r, stat.ctime.to_r, stat.size, stat.ino]
|
|
@@ -123,13 +204,15 @@ module Rigor
|
|
|
123
204
|
cached = memo[path]
|
|
124
205
|
return cached.last if cached && cached.first == stamp
|
|
125
206
|
|
|
126
|
-
|
|
127
|
-
memo[path] = [stamp,
|
|
128
|
-
|
|
207
|
+
scanned = scan_in(path)
|
|
208
|
+
memo[path] = [stamp, scanned]
|
|
209
|
+
scanned
|
|
129
210
|
rescue SystemCallError
|
|
130
|
-
|
|
211
|
+
NOTHING
|
|
131
212
|
end
|
|
132
213
|
|
|
214
|
+
NOTHING = [[].freeze, [].freeze].freeze
|
|
215
|
+
|
|
133
216
|
# Keeps the memo to the files the latest scan was asked about, so a long-lived language server holds one
|
|
134
217
|
# entry per project file rather than one per file it ever saw.
|
|
135
218
|
def prune_memo(paths)
|
|
@@ -139,28 +222,51 @@ module Rigor
|
|
|
139
222
|
@memo.select! { |path, _| keep.include?(path) }
|
|
140
223
|
end
|
|
141
224
|
|
|
142
|
-
# @return
|
|
143
|
-
def
|
|
144
|
-
|
|
225
|
+
# @return `[features, declared names]` for one file ({.scan_source}).
|
|
226
|
+
def scan_in(path)
|
|
227
|
+
scan_source(File.binread(path))
|
|
145
228
|
rescue SystemCallError, IOError
|
|
146
|
-
|
|
229
|
+
NOTHING
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# @return `[features, declared names]`: what {.features_of} reads, and the constant names a `class` or
|
|
233
|
+
# `module` header in `source` spells, each segment on its own (`class Net::SMTP` gives `Net` and `SMTP`).
|
|
234
|
+
def scan_source(source)
|
|
235
|
+
[features_of(source), declared_names(source)].freeze
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# A header on a line of its own, or inside a comment (an rbs-inline `# @rbs!` block declares
|
|
239
|
+
# `# class Matrix`). Over-matching a comment only keeps more features.
|
|
240
|
+
HEADER = /^[ \t]*(?:#[ \t]*)?(?:class|module)[ \t]+(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)/
|
|
241
|
+
|
|
242
|
+
def declared_names(source)
|
|
243
|
+
bytes = source.b
|
|
244
|
+
return [] unless bytes.include?("class") || bytes.include?("module")
|
|
245
|
+
|
|
246
|
+
bytes.scan(HEADER).flat_map { |(name)| name.split("::") }
|
|
247
|
+
.uniq.map { |name| name.dup.force_encoding(Encoding::UTF_8).freeze }.freeze
|
|
147
248
|
end
|
|
148
249
|
|
|
149
|
-
# @return the features `source` requires
|
|
250
|
+
# @return the features `source` requires: the listed ones ({VENDORED_DIRS}), and, for issue #2174, every
|
|
251
|
+
# feature Ruby ships ({SHIPPED_SEGMENTS}), which `RbsLoader#ruby_library_reopen?` reads to tell a reopened
|
|
252
|
+
# library class from a project class of the same name. Only the listed ones become library tokens.
|
|
150
253
|
def features_of(source)
|
|
151
|
-
# A plain substring test first:
|
|
152
|
-
# the regexp on a file that
|
|
153
|
-
return [] unless
|
|
254
|
+
# A plain substring test first: a file with no `require` at all is the common case, and it is far cheaper
|
|
255
|
+
# than the regexp on a file that has none.
|
|
256
|
+
return [] unless source.include?("require")
|
|
154
257
|
|
|
155
258
|
bytes = source.b
|
|
156
259
|
named = []
|
|
157
260
|
position = 0
|
|
158
|
-
while (match =
|
|
261
|
+
while (match = ANY_FEATURE.match(bytes, position))
|
|
159
262
|
named << match[2] unless commented?(bytes, match.begin(0))
|
|
160
263
|
position = match.end(0)
|
|
161
264
|
end
|
|
162
|
-
# The table's own frozen keys, so the result is shareable across Ractors
|
|
163
|
-
|
|
265
|
+
# The table's own frozen keys, and frozen copies of the rest, so the result is shareable across Ractors
|
|
266
|
+
# whatever the source's encoding.
|
|
267
|
+
listed = VENDORED_DIRS.each_key.select { |feature| named.include?(feature) }
|
|
268
|
+
shipped = named.select { |feature| SHIPPED_SEGMENTS.include?(feature.split("/").first) }
|
|
269
|
+
(listed + shipped.map { |feature| feature.dup.force_encoding(Encoding::UTF_8).freeze }).uniq
|
|
164
270
|
end
|
|
165
271
|
|
|
166
272
|
# Whether the match at `offset` sits on a line whose first non-blank character is `#`: a whole-line comment.
|
|
@@ -176,7 +282,8 @@ module Rigor
|
|
|
176
282
|
# The library tokens for `features` and for any listed feature the configuration's `libraries:` names.
|
|
177
283
|
def tokens(features, configured_libraries)
|
|
178
284
|
configured = Array(configured_libraries).map(&:to_s).select { |name| VENDORED_DIRS.key?(name) }
|
|
179
|
-
|
|
285
|
+
listed = Array(features).select { |feature| VENDORED_DIRS.key?(feature) }
|
|
286
|
+
(listed | configured).sort.map { |feature| token(feature) }
|
|
180
287
|
end
|
|
181
288
|
|
|
182
289
|
def token(feature)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rbs"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require_relative "missing_gem_constant_index"
|
|
6
|
+
require_relative "required_features"
|
|
7
|
+
require_relative "../rooted_glob"
|
|
8
|
+
|
|
9
|
+
module Rigor
|
|
10
|
+
class Environment
|
|
11
|
+
# Issue #2174 — which library Ruby ships declares each root constant name. Three sources are read, and no others:
|
|
12
|
+
#
|
|
13
|
+
# - every library of the `rbs` gem's `stdlib/` tree, loaded into this run or not, read from its `.rbs` files
|
|
14
|
+
# (`net-http` declares `Net`);
|
|
15
|
+
# - every top-level `.rb` file of the interpreter's library directory (`RbConfig::CONFIG["rubylibdir"]`), the
|
|
16
|
+
# default gems' and the standard library's entry files, read from their top-level declarations;
|
|
17
|
+
# - the entry file of each gem Ruby bundles ({RequiredFeatures::BUNDLED_GEMS}), found under the interpreter's
|
|
18
|
+
# gem directory. A gem installed there later is not one of them and is not read.
|
|
19
|
+
#
|
|
20
|
+
# Each root maps to the first path segments of the features that load it (`Matrix` → `matrix`, `Net` → `net`),
|
|
21
|
+
# which {RbsLoader#ruby_library_reopen?} intersects with the features the project's source requires
|
|
22
|
+
# ({RequiredFeatures.features_of}).
|
|
23
|
+
#
|
|
24
|
+
# Ownership is read, never guessed by camelizing a library name, for the reason {MissingGemConstantIndex}
|
|
25
|
+
# gives; that module's reader parses the `.rb` entry files. No library code runs. Everything fails open to
|
|
26
|
+
# "no library", which keeps the pre-#2174 reading. Core names (`Array`, `Kernel`) appear too, harmlessly:
|
|
27
|
+
# core signatures always load, so no project-only class carries one.
|
|
28
|
+
#
|
|
29
|
+
# The directories are resolved at load time, on the main Ractor: `RbConfig::CONFIG` and `Gem.default_dir`
|
|
30
|
+
# read non-shareable constants and raise `Ractor::IsolationError` inside a pool worker. The scan itself runs
|
|
31
|
+
# lazily (about 0.1 s), memoised per loader, and only for a class about to be reported.
|
|
32
|
+
module RubyLibraryRoots
|
|
33
|
+
STDLIB_ROOT = begin
|
|
34
|
+
::RBS::Repository::DEFAULT_STDLIB_ROOT.to_s.dup.freeze
|
|
35
|
+
rescue StandardError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
RUBY_LIB_DIR = begin
|
|
40
|
+
RbConfig::CONFIG["rubylibdir"].to_s.dup.freeze
|
|
41
|
+
rescue StandardError
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
RUBY_GEMS_DIR = begin
|
|
46
|
+
File.join(Gem.default_dir, "gems").freeze
|
|
47
|
+
rescue StandardError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
module_function
|
|
52
|
+
|
|
53
|
+
# {.build}, built once per process on the main Ractor, where the language server and the incremental
|
|
54
|
+
# snapshot fingerprint ask it repeatedly; a non-main Ractor may not keep module state and builds its own.
|
|
55
|
+
def shared
|
|
56
|
+
return build unless Ractor.current == Ractor.main
|
|
57
|
+
|
|
58
|
+
@shared ||= build
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The frozen map of root constant name to the frozen set of feature segments that load it.
|
|
62
|
+
def build
|
|
63
|
+
roots = Hash.new { |hash, key| hash[key] = Set.new }
|
|
64
|
+
stdlib_roots(roots)
|
|
65
|
+
ruby_lib_roots(roots)
|
|
66
|
+
bundled_gem_roots(roots)
|
|
67
|
+
roots.each_value(&:freeze)
|
|
68
|
+
roots.default_proc = nil
|
|
69
|
+
roots.freeze
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def stdlib_roots(roots)
|
|
73
|
+
return if STDLIB_ROOT.nil? || !File.directory?(STDLIB_ROOT)
|
|
74
|
+
|
|
75
|
+
Dir.children(STDLIB_ROOT).each do |library|
|
|
76
|
+
segment = library.split("-").first
|
|
77
|
+
RootedGlob.glob(File.join(STDLIB_ROOT, library), "*/**/*.rbs").each do |path|
|
|
78
|
+
stdlib_file_roots(path).each { |root| roots[root] << segment }
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def stdlib_file_roots(path)
|
|
84
|
+
_, _, decls = ::RBS::Parser.parse_signature(File.read(path))
|
|
85
|
+
decls.filter_map { |decl| root_of(decl) if named_declaration?(decl) }
|
|
86
|
+
rescue StandardError
|
|
87
|
+
[]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def ruby_lib_roots(roots)
|
|
91
|
+
return if RUBY_LIB_DIR.nil? || !File.directory?(RUBY_LIB_DIR)
|
|
92
|
+
|
|
93
|
+
RootedGlob.glob(RUBY_LIB_DIR, "*.rb").each do |path|
|
|
94
|
+
segment = File.basename(path, ".rb")
|
|
95
|
+
MissingGemConstantIndex.top_level_root_constants(path).each { |root| roots[root] << segment }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def bundled_gem_roots(roots)
|
|
100
|
+
return if RUBY_GEMS_DIR.nil? || !File.directory?(RUBY_GEMS_DIR)
|
|
101
|
+
|
|
102
|
+
Dir.children(RUBY_GEMS_DIR).each do |basename|
|
|
103
|
+
gem_name = basename[/\A(.+?)-\d/, 1]
|
|
104
|
+
next if gem_name.nil? || !RequiredFeatures::BUNDLED_GEMS.include?(gem_name)
|
|
105
|
+
|
|
106
|
+
segment = gem_name.split("-").first
|
|
107
|
+
MissingGemConstantIndex.entry_files(File.join(RUBY_GEMS_DIR, basename), gem_name).each do |path|
|
|
108
|
+
MissingGemConstantIndex.top_level_root_constants(path).each { |root| roots[root] << segment }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def named_declaration?(decl)
|
|
114
|
+
decl.is_a?(::RBS::AST::Declarations::Class) || decl.is_a?(::RBS::AST::Declarations::Module)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def root_of(decl)
|
|
118
|
+
decl.name.to_s.delete_prefix("::").split("::").first
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -268,7 +268,7 @@ module Rigor
|
|
|
268
268
|
# {Environment::RbsLoader} so constant lookups (and, in later v0.0.9 slices, other reflection
|
|
269
269
|
# artefacts) consult the cache. Pass `nil` (the default) to skip caching for this environment.
|
|
270
270
|
# rubocop:disable-next Metrics/MethodLength, Metrics/ParameterLists
|
|
271
|
-
def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil,
|
|
271
|
+
def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil, # rubocop:disable Metrics/AbcSize
|
|
272
272
|
plugin_registry: nil, dependency_source_index: nil,
|
|
273
273
|
rbs_extended_reporter: nil, boundary_cross_reporter: nil,
|
|
274
274
|
source_rbs_synthesis_reporter: nil,
|
|
@@ -364,7 +364,9 @@ module Rigor
|
|
|
364
364
|
# Issue #610 — the plugin-contributed subset is loaded LAST and may stand down against a colliding
|
|
365
365
|
# generic arity. Only the bundled plugins' own `sig/` is deferred: the user's `signature_paths:`,
|
|
366
366
|
# the bundle walk and `rbs collection install` all stay authoritative.
|
|
367
|
-
deferred_signature_paths: plugin_sig_paths
|
|
367
|
+
deferred_signature_paths: plugin_sig_paths,
|
|
368
|
+
project_signature_paths: resolved_paths,
|
|
369
|
+
required_features: required_features || RequiredFeatures.scan(source_files, sig_paths: resolved_paths)
|
|
368
370
|
)
|
|
369
371
|
# Issues #824 / #1075 — the rows ADR-112 WD5's consistency rule reports for members the project's
|
|
370
372
|
# own `sig/` and an inline source both declare: an error per contradiction (or per refinement that
|
|
@@ -72,6 +72,12 @@ module Rigor
|
|
|
72
72
|
|
|
73
73
|
module_function
|
|
74
74
|
|
|
75
|
+
# Whether `call_node` is spelled to reach the private `Kernel#loop` (see {.loop_may_complete?}), which runs its
|
|
76
|
+
# block until it breaks or raises.
|
|
77
|
+
def kernel_loop?(call_node)
|
|
78
|
+
call_node.name == :loop && kernel_spelled_receiver?(call_node.receiver)
|
|
79
|
+
end
|
|
80
|
+
|
|
75
81
|
# Issue #1107 — whether a `loop` call can complete normally although its declared return is `bot`.
|
|
76
82
|
#
|
|
77
83
|
# `Kernel#loop` is `() { () -> void } -> bot` in core RBS, yet it rescues a `StopIteration` its block
|
|
@@ -86,7 +92,7 @@ module Rigor
|
|
|
86
92
|
# promise. A project redefinition of `loop` itself is not excluded: widening its `bot` costs precision,
|
|
87
93
|
# never a false positive.
|
|
88
94
|
def loop_may_complete?(call_node)
|
|
89
|
-
return false unless
|
|
95
|
+
return false unless kernel_loop?(call_node)
|
|
90
96
|
|
|
91
97
|
block = call_node.block
|
|
92
98
|
return false if block.nil?
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
|
+
require_relative "../../rooted_glob"
|
|
4
5
|
|
|
5
6
|
module Rigor
|
|
6
7
|
module Inference
|
|
@@ -49,7 +50,7 @@ module Rigor
|
|
|
49
50
|
end
|
|
50
51
|
|
|
51
52
|
# Every catalogue file under {DATA_ROOT}, in a stable order.
|
|
52
|
-
def self.topic_paths =
|
|
53
|
+
def self.topic_paths = RootedGlob.glob(DATA_ROOT, "*.yml")
|
|
53
54
|
|
|
54
55
|
def initialize(path:, mutating_selectors: {})
|
|
55
56
|
@path = path
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Inference
|
|
5
|
+
# Issue #2104 — the callee floor's census made transitive. A method that hands its parameter to another method
|
|
6
|
+
# which mutates it (`def stripes(acc) = poke(acc)`, `def poke(acc) = (acc[0] = "x")`) mutates that parameter as
|
|
7
|
+
# surely as one that writes it itself, so the caller's argument is floored after `stripes(acc)` too.
|
|
8
|
+
#
|
|
9
|
+
# This module is the closure over a parameter-flow graph, independent of how the graph is read. `own` answers a
|
|
10
|
+
# def's own census, `{ name => slot }` (a slot is a positional index or a keyword symbol); `edges` answers the
|
|
11
|
+
# {Edge}s along which a def hands one of its parameters to a resolved callee. A node is one parameter of one def,
|
|
12
|
+
# and the walk follows only the edges that hand on the parameter the node is, so a def's hand-offs of its OTHER
|
|
13
|
+
# parameters cost nothing. A node is mutated when its def mutates it or it hands itself to a mutated node: the
|
|
14
|
+
# least fixpoint, so a recursive or mutually recursive chain terminates and a cycle with no mutation on it mutates
|
|
15
|
+
# nothing.
|
|
16
|
+
#
|
|
17
|
+
# The walk reads at most {MAX_NODES} nodes per closure. A hand-off to a node past the cap is read as no hand-off,
|
|
18
|
+
# as the one-level floor reads a callee it cannot resolve: flooring there would drop a literal shape on evidence
|
|
19
|
+
# the walk never saw, and a floored `Array[Dynamic[top]]` then fails a declared tuple on correct code.
|
|
20
|
+
module CalleeMutationChain
|
|
21
|
+
# `name` and `slot` are the forwarding def's parameter, `callee` the def it is handed to, and `callee_names`
|
|
22
|
+
# the callee's parameters it may bind there.
|
|
23
|
+
Edge = Data.define(:name, :slot, :callee, :callee_names)
|
|
24
|
+
|
|
25
|
+
MAX_NODES = 32
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def close(root, own:, edges:)
|
|
30
|
+
census = own.call(root)
|
|
31
|
+
handed = edges.call(root).reject { |edge| census.key?(edge.name) }
|
|
32
|
+
return census if handed.empty?
|
|
33
|
+
|
|
34
|
+
flow = Flow.new(own, edges)
|
|
35
|
+
handed.each { |edge| flow.visit(root, edge.name) }
|
|
36
|
+
flow.settle
|
|
37
|
+
handed.each_with_object(census.dup) do |edge, result|
|
|
38
|
+
result[edge.name] = edge.slot if flow.mutated?(root, edge.name)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The parameter-flow graph one closure reads: per def, per parameter name, whether the node is mutated and the
|
|
43
|
+
# nodes it hands itself on to.
|
|
44
|
+
class Flow
|
|
45
|
+
Node = Struct.new(:mutated, :successors)
|
|
46
|
+
|
|
47
|
+
def initialize(own, edges)
|
|
48
|
+
@own = own
|
|
49
|
+
@edges = edges
|
|
50
|
+
@nodes = {}.compare_by_identity
|
|
51
|
+
@count = 0
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def mutated?(def_node, name)
|
|
55
|
+
node = @nodes.dig(def_node, name)
|
|
56
|
+
!node.nil? && node.mutated
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Reads the nodes reachable from `def_node`'s `name`, breadth first, while fewer than {MAX_NODES} are read.
|
|
60
|
+
def visit(def_node, name)
|
|
61
|
+
queue = [[def_node, name]]
|
|
62
|
+
until queue.empty?
|
|
63
|
+
current, parameter = queue.shift
|
|
64
|
+
next if known?(current, parameter)
|
|
65
|
+
break if @count >= MAX_NODES
|
|
66
|
+
|
|
67
|
+
queue.concat(add(current, parameter))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def settle
|
|
72
|
+
changed = true
|
|
73
|
+
while changed
|
|
74
|
+
changed = false
|
|
75
|
+
each_node do |node|
|
|
76
|
+
next if node.mutated
|
|
77
|
+
next unless node.successors.any? { |callee, name| mutated?(callee, name) }
|
|
78
|
+
|
|
79
|
+
node.mutated = true
|
|
80
|
+
changed = true
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def known?(def_node, name)
|
|
88
|
+
by_name = @nodes[def_node]
|
|
89
|
+
!by_name.nil? && by_name.key?(name)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Records the node and answers its successors. A node its def mutates needs none.
|
|
93
|
+
def add(def_node, name)
|
|
94
|
+
@count += 1
|
|
95
|
+
mutated = @own.call(def_node).key?(name)
|
|
96
|
+
successors = mutated ? [] : successors_of(def_node, name)
|
|
97
|
+
(@nodes[def_node] ||= {})[name] = Node.new(mutated, successors)
|
|
98
|
+
successors
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def successors_of(def_node, name)
|
|
102
|
+
@edges.call(def_node).each_with_object([]) do |edge, successors|
|
|
103
|
+
next unless edge.name == name
|
|
104
|
+
|
|
105
|
+
edge.callee_names.each { |callee_name| successors << [edge.callee, callee_name] }
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def each_node(&)
|
|
110
|
+
@nodes.each_value { |by_name| by_name.each_value(&) }
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -6,6 +6,7 @@ require_relative "../source/node_walker"
|
|
|
6
6
|
require_relative "block_parameter_binder"
|
|
7
7
|
require_relative "element_read_widening"
|
|
8
8
|
require_relative "index_write_widening"
|
|
9
|
+
require_relative "intervening_ivar_reset"
|
|
9
10
|
require_relative "mutation_widening"
|
|
10
11
|
require_relative "optimistic_origin"
|
|
11
12
|
require_relative "receiver_alias"
|
|
@@ -109,16 +110,22 @@ module Rigor
|
|
|
109
110
|
# `base_scope`), except that one still on its class-wide binding does not: ADR-58's declaration seed is
|
|
110
111
|
# the union of every write in the class, this body's included, so nothing the body stores can move it,
|
|
111
112
|
# and rebinding it would only drop the declaration mark that keeps its nil from being diagnostic fuel.
|
|
112
|
-
#
|
|
113
|
-
# `
|
|
114
|
-
#
|
|
113
|
+
# Issue #1402 — an instance variable written in a nested block that runs on an object provably not `self`
|
|
114
|
+
# (`Other.class_eval { @x = 1 }`, {InterveningIvarReset.foreign_self_block?}, its receiver typed in
|
|
115
|
+
# `base_scope`) is that object's, and does not count; one on a receiver that may be `self` does. A class
|
|
116
|
+
# variable or global written there counts as anywhere else: Ruby resolves a class variable lexically, and
|
|
117
|
+
# a global is the same one whatever `self` is. A nested `def` runs only when called; it still counts,
|
|
118
|
+
# because a call to it inside the body writes this one.
|
|
119
|
+
# @param foreign_self — the block itself runs on an object provably not `self`
|
|
120
|
+
# ({InterveningIvarReset.foreign_self_block?} of its call): no instance variable it writes is the caller's.
|
|
121
|
+
# Its locals, class variables and globals still count.
|
|
115
122
|
# @param non_locals — the per-element fold's superset of `ivars`: the class variables and globals the body
|
|
116
123
|
# rebinds count too, on the same terms, and so does an attribute setter called on `self` (`self.w = v`)
|
|
117
124
|
# as a rebind of the instance variable it is named after, `@w`: the `attr_writer` / `attr_accessor`
|
|
118
125
|
# convention stores there, and no write node shows it. A hand-written setter storing elsewhere is not
|
|
119
126
|
# seen.
|
|
120
127
|
# @return the captured names the body writes, each once, in first-write order.
|
|
121
|
-
def writes(block_node, base_scope, ivars: false, non_locals: false)
|
|
128
|
+
def writes(block_node, base_scope, ivars: false, non_locals: false, foreign_self: false)
|
|
122
129
|
body = block_node.body
|
|
123
130
|
return NO_NAMES if body.nil?
|
|
124
131
|
|
|
@@ -126,20 +133,44 @@ module Rigor
|
|
|
126
133
|
names = nil
|
|
127
134
|
outliving = non_locals || ivars
|
|
128
135
|
Source::NodeWalker.each_with_ancestors(body) do |descendant, ancestors|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
elsif outliving
|
|
133
|
-
if ReflectiveIvarWrite.unknown_target?(descendant)
|
|
134
|
-
(names ||= []).concat(reflective_unknown_ivars(base_scope))
|
|
135
|
-
end
|
|
136
|
-
rebindable_outliving_write(descendant, base_scope, non_locals)
|
|
136
|
+
if LOCAL_WRITE_NODES.include?(descendant.class)
|
|
137
|
+
name = captured_local_write(descendant, ancestors, base_scope) do
|
|
138
|
+
introduced ||= introduced_locals(block_node)
|
|
137
139
|
end
|
|
138
|
-
|
|
140
|
+
(names ||= []) << name if name
|
|
141
|
+
elsif outliving
|
|
142
|
+
found = outliving_writes(descendant, ancestors, base_scope, non_locals, foreign_self)
|
|
143
|
+
(names ||= []).concat(found) unless found.empty?
|
|
144
|
+
end
|
|
139
145
|
end
|
|
140
146
|
names ? names.uniq : NO_NAMES
|
|
141
147
|
end
|
|
142
148
|
|
|
149
|
+
# The non-local names `node` writes ({.reflective_unknown_ivars} for an `instance_variable_set` on `self` whose
|
|
150
|
+
# target no literal names, then {.rebindable_outliving_write}), less the instance variables when the write
|
|
151
|
+
# runs on an object provably not `self` (`foreign_self`, or a nested block {.under_foreign_self_block?}).
|
|
152
|
+
def outliving_writes(node, ancestors, base_scope, non_locals, foreign_self)
|
|
153
|
+
found = ReflectiveIvarWrite.unknown_target?(node) ? reflective_unknown_ivars(base_scope) : NO_NAMES
|
|
154
|
+
written = rebindable_outliving_write(node, base_scope, non_locals)
|
|
155
|
+
return found if written.nil? && found.empty?
|
|
156
|
+
|
|
157
|
+
names = written ? found + [written] : found
|
|
158
|
+
return names unless foreign_self || under_foreign_self_block?(ancestors, base_scope)
|
|
159
|
+
|
|
160
|
+
names.reject { |name| ivar_name?(name) }
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Issue #1402 — whether a block on the path `ancestors` runs on an object provably not `self`: its call is a
|
|
164
|
+
# {InterveningIvarReset.foreign_self_block?}, the receiver typed in `base_scope`.
|
|
165
|
+
def under_foreign_self_block?(ancestors, base_scope)
|
|
166
|
+
(1...ancestors.size).any? do |index|
|
|
167
|
+
child = ancestors[index]
|
|
168
|
+
parent = ancestors[index - 1]
|
|
169
|
+
child.is_a?(Prism::BlockNode) && parent.is_a?(Prism::CallNode) && parent.block.equal?(child) &&
|
|
170
|
+
InterveningIvarReset.foreign_self_block?(parent, base_scope)
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
143
174
|
# Issue #1889 — every ivar `base_scope` binds: an `instance_variable_set` on `self` whose target no literal
|
|
144
175
|
# names ({ReflectiveIvarWrite.unknown_target?}) may write any of them. One on its ADR-58 declaration seed
|
|
145
176
|
# counts too, since the census behind the seed sees no reflective write; {.bind} keeps its mark.
|