rigortype 0.4.3 → 0.4.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +5 -5
- data/data/core_overlay/ostruct.rbs +40 -0
- data/docs/handbook/11-sig-gen.md +104 -15
- data/docs/manual/02-cli-reference.md +43 -12
- data/docs/manual/03-configuration.md +20 -7
- data/docs/manual/04-diagnostics.md +56 -3
- data/docs/manual/05-inspecting-types.md +2 -2
- data/docs/manual/06-baseline.md +1 -1
- data/docs/manual/07-plugins.md +3 -2
- data/docs/manual/08-skills.md +7 -0
- data/docs/manual/10-mcp-server.md +40 -4
- data/docs/manual/18-removing-dead-code.md +23 -2
- data/docs/manual/plugins/rigor-rails-routes.md +2 -2
- data/docs/manual/plugins/rigor-rspec.md +101 -1
- data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
- data/exe/rigor +12 -17
- data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
- data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
- data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
- data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
- data/lib/rigor/analysis/check_rules.rb +274 -51
- data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
- data/lib/rigor/analysis/incremental.rb +61 -0
- data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
- data/lib/rigor/analysis/incremental_session.rb +270 -28
- data/lib/rigor/analysis/path_expansion.rb +3 -1
- data/lib/rigor/analysis/reachability/graph.rb +111 -14
- data/lib/rigor/analysis/reachability/scan.rb +305 -23
- data/lib/rigor/analysis/rule_catalog.rb +22 -4
- data/lib/rigor/analysis/run_cache_key.rb +31 -7
- data/lib/rigor/analysis/run_cache_probe.rb +5 -3
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
- data/lib/rigor/analysis/runner/pool_coordinator.rb +179 -31
- data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
- data/lib/rigor/analysis/runner.rb +102 -72
- data/lib/rigor/analysis/severity_stamp.rb +45 -1
- data/lib/rigor/analysis/worker_session.rb +69 -2
- data/lib/rigor/cache/descriptor.rb +79 -12
- data/lib/rigor/cache/engine_source.rb +3 -2
- data/lib/rigor/cache/incremental_snapshot.rb +39 -5
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +4 -3
- data/lib/rigor/cli/command.rb +3 -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/mcp_command.rb +1 -1
- data/lib/rigor/cli/plugin_command.rb +2 -1
- data/lib/rigor/cli/plugins_command.rb +2 -1
- data/lib/rigor/cli/sig_gen_command.rb +32 -11
- data/lib/rigor/cli/skill_command.rb +2 -1
- data/lib/rigor/cli/skill_describe.rb +3 -2
- data/lib/rigor/cli/unused_command.rb +10 -7
- data/lib/rigor/cli.rb +3 -2
- data/lib/rigor/config_audit.rb +163 -22
- data/lib/rigor/configuration/dependencies.rb +19 -11
- data/lib/rigor/configuration/severity_profile.rb +20 -2
- data/lib/rigor/configuration.rb +29 -3
- 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 +67 -11
- data/lib/rigor/inference/block_call_timing.rb +12 -1
- data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
- data/lib/rigor/inference/captured_locals.rb +67 -13
- data/lib/rigor/inference/clause_widening.rb +90 -0
- data/lib/rigor/inference/definer_resolution.rb +101 -25
- data/lib/rigor/inference/expression_typer.rb +94 -9
- data/lib/rigor/inference/guard_rebinding.rb +7 -2
- data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
- data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
- data/lib/rigor/inference/macro_block_self_type.rb +20 -6
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
- data/lib/rigor/inference/method_dispatcher.rb +67 -3
- data/lib/rigor/inference/module_function_state.rb +213 -51
- data/lib/rigor/inference/narrowing.rb +235 -75
- 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 +120 -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 +116 -0
- data/lib/rigor/inference/repeated_or_writes.rb +414 -1
- data/lib/rigor/inference/return_barrier.rb +16 -0
- data/lib/rigor/inference/scope_indexer.rb +1061 -122
- data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
- 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 +512 -150
- data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
- data/lib/rigor/lens/anchor.rb +3 -3
- data/lib/rigor/lens/contract_reader.rb +60 -2
- data/lib/rigor/lens/document.rb +4 -3
- data/lib/rigor/lens/file_reader.rb +452 -73
- data/lib/rigor/lens/members.rb +81 -17
- data/lib/rigor/lens/run.rb +73 -4
- data/lib/rigor/lens.rb +2 -1
- data/lib/rigor/mcp/server.rb +63 -22
- data/lib/rigor/plugin/base.rb +3 -2
- data/lib/rigor/plugin/box_probe.rb +154 -7
- data/lib/rigor/plugin/io_boundary.rb +42 -6
- data/lib/rigor/plugin/isolation.rb +4 -3
- data/lib/rigor/project_environment.rb +6 -3
- data/lib/rigor/protection/discovery_seed.rb +9 -36
- data/lib/rigor/protection/mutation_cache.rb +8 -2
- data/lib/rigor/rooted_glob.rb +27 -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 +56 -8
- 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 +167 -22
- data/lib/rigor/sig_gen/layout_index.rb +40 -1
- data/lib/rigor/sig_gen/method_candidate.rb +8 -0
- data/lib/rigor/sig_gen/observation_collector.rb +137 -11
- data/lib/rigor/sig_gen/path_mapper.rb +18 -2
- data/lib/rigor/sig_gen/renderer.rb +13 -155
- data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
- 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 +50 -35
- data/lib/rigor/signature_path_audit.rb +4 -2
- data/lib/rigor/type/constant.rb +10 -1
- 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 +1 -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-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-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-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
- data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
- data/plugins/rigor-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 +155 -12
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
- 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/sig/rigor/plugin/io_boundary.rbs +5 -2
- data/sig/rigor/rooted_glob.rbs +7 -0
- data/sig/rigor/scope.rbs +4 -3
- data/skills/rigor-doctor/references/01-checks.md +23 -11
- data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
- metadata +16 -1
|
@@ -27,25 +27,57 @@ module Rigor
|
|
|
27
27
|
# alias earlier in the `pre_eval:` files binds `old`, the alias's entry copies that entry's `return_type` (and
|
|
28
28
|
# its `alias_of`); otherwise `alias_of` names `old`, a method the class had before the patch, and the
|
|
29
29
|
# dispatcher answers the alias with what that method answers. `alias_of` is nil for a `def`.
|
|
30
|
-
|
|
31
|
-
|
|
30
|
+
#
|
|
31
|
+
# Issue #1708 — `visibility` is `:public`, `:private` or `:protected`, as the patch file leaves the method: a
|
|
32
|
+
# `private def x`, a `def` after a bare `private` in the same body, a later `private :x`, and the instance copy
|
|
33
|
+
# a `module_function` makes are `:private` ({#callable?} reads it). An alias takes its old name's visibility,
|
|
34
|
+
# as Ruby's does; one whose old name no patch binds is `:public`.
|
|
35
|
+
#
|
|
36
|
+
# `farther` is set only on an entry {#lookup_through_ancestors} returns: the other definers of the name farther
|
|
37
|
+
# on the walk (entries, or {SOURCE_DEFINER} for a project-source `def`), which {#callable?} reads before it
|
|
38
|
+
# refuses the call.
|
|
39
|
+
Entry = Data.define(:class_name, :method_name, :kind, :source_path, :source_line, :return_type, :alias_of,
|
|
40
|
+
:visibility, :farther) do
|
|
41
|
+
def initialize(class_name:, method_name:, kind:, source_path:, source_line:, return_type: nil, alias_of: nil,
|
|
42
|
+
visibility: :public, farther: nil)
|
|
32
43
|
super
|
|
33
44
|
end
|
|
34
45
|
end
|
|
35
46
|
|
|
36
|
-
|
|
47
|
+
# Issue #1708 — one `include` or `prepend` a `pre_eval:` file writes (`Numeric.include(M)`, or `include M` in
|
|
48
|
+
# `class Numeric`): `owner` gains `module_name` as an ancestor, `kind` `:include` or `:prepend`. Names are as
|
|
49
|
+
# written, with no lexical resolution, as a patch's class name is.
|
|
50
|
+
Mixin = Data.define(:owner, :module_name, :kind)
|
|
51
|
+
|
|
52
|
+
attr_reader :by_key, :write_census, :mixins, :overrides
|
|
53
|
+
|
|
54
|
+
# Each owner's prepended and included modules, the most recent first.
|
|
55
|
+
def self.mixins_by_owner(mixins)
|
|
56
|
+
table = mixins.each_with_object({}) do |mixin, acc|
|
|
57
|
+
edges = (acc[mixin.owner] ||= { prepend: [], include: [] })
|
|
58
|
+
edges[mixin.kind].unshift(mixin.module_name)
|
|
59
|
+
end
|
|
60
|
+
table.each_value { |edges| edges.each_value(&:freeze) }.freeze
|
|
61
|
+
end
|
|
37
62
|
|
|
38
63
|
# @param entries — flat list of declarations observed during the pre-pass.
|
|
39
64
|
# Last-write-wins on `(class_name, method_name, kind)` duplicates, as at runtime (ADR-17); the
|
|
40
65
|
# `pre-eval.duplicate-declaration` diagnostic emission stays decoupled from registry behaviour.
|
|
41
66
|
# @param write_census — issue #1367: the `pre_eval:` files' {GlobalWriteCensus}, which the `global.*` write
|
|
42
67
|
# rules join with the project's, since a patch file is loaded ahead of the code it patches.
|
|
43
|
-
|
|
68
|
+
# @param mixins — issue #1708: the {Mixin} edges the `pre_eval:` files write, in the order they run.
|
|
69
|
+
# @param overrides — issue #1708: `[class_name, method_name, kind] => visibility` for a named visibility call
|
|
70
|
+
# (`public :kp` in `class Integer`) on a method the class inherits rather than binds, which Ruby copies onto
|
|
71
|
+
# the class with that visibility.
|
|
72
|
+
def initialize(entries: [], write_census: GlobalWriteCensus::EMPTY, mixins: [], overrides: {})
|
|
44
73
|
@by_key = entries.each_with_object({}) do |entry, acc|
|
|
45
74
|
key = [entry.class_name, entry.method_name, entry.kind]
|
|
46
75
|
acc[key] = entry
|
|
47
76
|
end.freeze
|
|
48
77
|
@write_census = write_census.frozen? ? write_census : write_census.dup.freeze
|
|
78
|
+
@mixins = mixins.dup.freeze
|
|
79
|
+
@mixins_of = ProjectPatchedMethods.mixins_by_owner(@mixins)
|
|
80
|
+
@overrides = overrides.dup.freeze
|
|
49
81
|
freeze
|
|
50
82
|
end
|
|
51
83
|
|
|
@@ -55,11 +87,143 @@ module Rigor
|
|
|
55
87
|
@by_key[[class_name, method_name, kind]]
|
|
56
88
|
end
|
|
57
89
|
|
|
90
|
+
# Issue #1708 — the entry a `kind` receiver of `class_name` reaches through its ancestors, searched in Ruby's
|
|
91
|
+
# order: a patch on `Object`, `Kernel`, `Numeric`, `Comparable` or `Enumerable` answers an `Integer`, `Array`
|
|
92
|
+
# or `String` receiver, and a patch on a nearer ancestor wins over one on a farther. A class object's chain
|
|
93
|
+
# is its singleton classes, then `Class`, `Module`, `Object`, `Kernel` and `BasicObject`, so `def zz` in
|
|
94
|
+
# `class Object` answers `Integer.zz` too. The ancestors are the class's RBS ones; a class RBS does not
|
|
95
|
+
# declare is searched as itself only, as {#lookup} searches it.
|
|
96
|
+
#
|
|
97
|
+
# It answers only which patch the chain holds. A method a nearer ancestor's RBS declares wins over the patch
|
|
98
|
+
# in Ruby, so the callers ask once that RBS has come back without the method. A method the project's own
|
|
99
|
+
# source defines on the way wins too: `source_defines` is called with each `owner, side` BEFORE that owner's
|
|
100
|
+
# patch is read, and when it answers true the walk stops with no entry, leaving the call to what types that
|
|
101
|
+
# method. That includes an owner holding both a patch and a source `def` (a `lib/` reopen loaded after the
|
|
102
|
+
# patch, or a module the project prepends to it), unlike {#lookup}, which answers the patch.
|
|
103
|
+
#
|
|
104
|
+
# A module the `pre_eval:` files include into or prepend to an owner on the way ({Mixin}) is searched where Ruby
|
|
105
|
+
# puts it: prepends ahead of the owner, includes after it, the most recent first, each with its own mixins. A
|
|
106
|
+
# named visibility call on an owner the walk passes before the body (`public :kp` in `class Integer`, an
|
|
107
|
+
# override) gives the entry it returns that visibility, and that owner for {#callable?}'s protected check.
|
|
108
|
+
# @return the entry, or `nil` when no ancestor's patch declares the method ahead of a source `def`.
|
|
109
|
+
def lookup_through_ancestors(class_name:, method_name:, kind:, rbs_loader:, source_defines: nil) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
110
|
+
return nil if empty?
|
|
111
|
+
|
|
112
|
+
override = nil
|
|
113
|
+
found = nil
|
|
114
|
+
farther = []
|
|
115
|
+
ancestor_keys(class_name.to_s, kind, rbs_loader).each do |owner, side|
|
|
116
|
+
if source_defines&.call(owner, side)
|
|
117
|
+
return nil if found.nil?
|
|
118
|
+
|
|
119
|
+
farther << SOURCE_DEFINER
|
|
120
|
+
break
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
entry = @by_key[[owner, method_name, side]]
|
|
124
|
+
if found
|
|
125
|
+
farther << entry if entry
|
|
126
|
+
elsif entry
|
|
127
|
+
found = override ? entry.with(class_name: override.first, visibility: override.last) : entry
|
|
128
|
+
elsif (visibility = @overrides[[owner, method_name, side]])
|
|
129
|
+
override ||= [owner, visibility]
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
farther.empty? || found.nil? ? found : found.with(farther: farther.freeze)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# A project-source `def` of the name farther on the walk than the entry it returns, in {Entry#farther}.
|
|
136
|
+
SOURCE_DEFINER = Object.new.freeze
|
|
137
|
+
|
|
138
|
+
# Issue #1708 — whether Ruby lets a call reach `entry`. A public method answers any call; a private one only a
|
|
139
|
+
# call with no receiver or with `self` as it (`explicit` is false for those); a protected one also a call from
|
|
140
|
+
# a method whose `self` is an instance of the entry's owner. `self_type` is the caller's `self` (`nil` at the
|
|
141
|
+
# top level, where it is `main`, an Object); a `self` this cannot name (the default) may be one, so a
|
|
142
|
+
# protected entry answers it.
|
|
143
|
+
#
|
|
144
|
+
# It never refuses a call some other definer of the name may answer: the walk's order is an approximation
|
|
145
|
+
# (a module two owners mix in is spliced at the nearer one, where Ruby keeps the first include and skips the
|
|
146
|
+
# second), so when {Entry#farther} holds a definer the call may reach (a project-source `def`, or an entry this
|
|
147
|
+
# would let it reach), it answers nil: decline, neither refused nor typed from `entry`.
|
|
148
|
+
# @return true (the call reaches `entry`), false (Ruby refuses it), or nil (declined).
|
|
149
|
+
def callable?(entry, explicit:, self_type: :unknown, rbs_loader: nil)
|
|
150
|
+
return true unless explicit
|
|
151
|
+
|
|
152
|
+
allowed =
|
|
153
|
+
case entry.visibility
|
|
154
|
+
when :private, :module_function then false
|
|
155
|
+
when :protected then self_kind_of?(self_type, entry.class_name, rbs_loader)
|
|
156
|
+
else true
|
|
157
|
+
end
|
|
158
|
+
return true if allowed
|
|
159
|
+
|
|
160
|
+
reachable = (entry.farther || []).any? do |other|
|
|
161
|
+
other.equal?(SOURCE_DEFINER) || callable?(other, explicit: true, self_type: self_type, rbs_loader: rbs_loader)
|
|
162
|
+
end
|
|
163
|
+
reachable ? nil : false # rubocop:disable Style/ReturnNilInPredicateMethodDefinition
|
|
164
|
+
end
|
|
165
|
+
|
|
58
166
|
def empty?
|
|
59
167
|
@by_key.empty?
|
|
60
168
|
end
|
|
61
169
|
|
|
62
170
|
EMPTY = new.freeze
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
# Whether `self_type` may be an instance of `owner`: false only when the ancestors of a class RBS knows,
|
|
175
|
+
# with the patches' mixins, do not hold it.
|
|
176
|
+
def self_kind_of?(self_type, owner, rbs_loader)
|
|
177
|
+
name, kind =
|
|
178
|
+
if self_type.nil? then ["Object", :instance]
|
|
179
|
+
elsif self_type.respond_to?(:class_name) && self_type.class_name
|
|
180
|
+
[self_type.class_name.to_s, self_type.is_a?(Type::Singleton) ? :singleton : :instance]
|
|
181
|
+
end
|
|
182
|
+
return true if name.nil? || rbs_loader.nil?
|
|
183
|
+
|
|
184
|
+
keys = ancestor_keys(name, kind, rbs_loader)
|
|
185
|
+
keys == [[name, kind]] || keys.include?([owner, :instance])
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def ancestor_keys(class_name, kind, rbs_loader)
|
|
189
|
+
pairs =
|
|
190
|
+
if kind == :singleton
|
|
191
|
+
sides = rbs_loader ? rbs_loader.singleton_ancestors_for(class_name) : []
|
|
192
|
+
sides.empty? ? [[class_name, :singleton]] : sides.map { |side, owner| [owner, side] }
|
|
193
|
+
else
|
|
194
|
+
names = rbs_loader ? rbs_loader.ancestor_names_for(class_name) : []
|
|
195
|
+
(names.empty? ? [class_name] : names).map { |owner| [owner, :instance] }
|
|
196
|
+
end
|
|
197
|
+
@mixins_of.empty? ? pairs : with_mixins(pairs)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# The RBS ancestor pairs with each instance-side owner's `pre_eval:` mixins spliced in. A module already on
|
|
201
|
+
# the chain is not added again, as Ruby skips a module the ancestors already hold.
|
|
202
|
+
def with_mixins(pairs)
|
|
203
|
+
chain = pairs.filter_map { |owner, side| owner if side == :instance }.to_set
|
|
204
|
+
seen = Set.new
|
|
205
|
+
pairs.flat_map do |owner, side|
|
|
206
|
+
side == :instance ? expand_mixins(owner, chain, seen).map { |name| [name, :instance] } : [[owner, side]]
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# `owner` with the modules the patches prepend to it ahead and the ones they include after, each expanded the
|
|
211
|
+
# same way: the order Ruby's `ancestors` lists them in. An include of a module the RBS chain already holds
|
|
212
|
+
# changes nothing, as Ruby skips it (`Integer.include(Comparable)`); a prepend of one is spliced ahead all the
|
|
213
|
+
# same, as Ruby adds it again (`Integer.prepend(Comparable)`). `seen` holds the modules already spliced, so a
|
|
214
|
+
# module is spliced once, at the nearest owner that mixes it in, and an include cycle ends. Ruby skips
|
|
215
|
+
# `Integer.include(A)` run after `Numeric.include(A)`, leaving A after Numeric; the registry, which does not read
|
|
216
|
+
# the order the two edges ran in, splices A after Integer either way.
|
|
217
|
+
def expand_mixins(owner, chain, seen)
|
|
218
|
+
edges = @mixins_of[owner]
|
|
219
|
+
return [owner] if edges.nil?
|
|
220
|
+
|
|
221
|
+
ahead = edges[:prepend].flat_map { |name| seen.add?(name) ? expand_mixins(name, chain, seen) : [] }
|
|
222
|
+
after = edges[:include].flat_map do |name|
|
|
223
|
+
!chain.include?(name) && seen.add?(name) ? expand_mixins(name, chain, seen) : []
|
|
224
|
+
end
|
|
225
|
+
ahead + [owner] + after
|
|
226
|
+
end
|
|
63
227
|
end
|
|
64
228
|
end
|
|
65
229
|
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
require_relative "project_patched_methods"
|
|
6
|
+
require_relative "../source/constant_path"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Inference
|
|
10
|
+
module ProjectPatchedScanner
|
|
11
|
+
# Issue #1708 — the `include` / `prepend` edges a `pre_eval:` file writes, so a module it mixes into an
|
|
12
|
+
# ancestor (`Numeric.include(M)`) answers the receivers below that ancestor through
|
|
13
|
+
# {ProjectPatchedMethods#lookup_through_ancestors}. Ruby 4.0.5: after `Numeric.include(NumExt)`, `1.inc_m` and
|
|
14
|
+
# `1.5.inc_m` run.
|
|
15
|
+
module Mixins
|
|
16
|
+
# An `include` / `prepend` the walk met, with the lexical prefix {.resolve} resolves its module's written
|
|
17
|
+
# name in.
|
|
18
|
+
Pending = Data.define(:owner, :module_name, :kind, :prefix)
|
|
19
|
+
|
|
20
|
+
CALLS = %i[include prepend].freeze
|
|
21
|
+
private_constant :CALLS
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
# The {Pending} edges `node` writes, or none: `Numeric.include(M)` on a constant, or `include M` /
|
|
26
|
+
# `self.include(M)` in a body. A top-level `include M` includes into `Object`, as Ruby's does. One in a
|
|
27
|
+
# `class << self` body is an `extend`, which this does not read; a module argument that is not a constant is
|
|
28
|
+
# skipped.
|
|
29
|
+
def pending(node, qualified_prefix, in_singleton_class)
|
|
30
|
+
return [] unless CALLS.include?(node.name) && node.arguments
|
|
31
|
+
|
|
32
|
+
owner = owner(node.receiver, qualified_prefix, in_singleton_class)
|
|
33
|
+
return [] if owner.nil?
|
|
34
|
+
|
|
35
|
+
node.arguments.arguments.filter_map do |arg|
|
|
36
|
+
name = Source::ConstantPath.qualified_name_or_nil(arg)
|
|
37
|
+
next if name.nil?
|
|
38
|
+
|
|
39
|
+
prefix = arg.is_a?(Prism::ConstantPathNode) && arg.parent.nil? ? [] : qualified_prefix
|
|
40
|
+
Pending.new(owner: owner, module_name: name, kind: node.name, prefix: prefix)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def owner(receiver, qualified_prefix, in_singleton_class)
|
|
45
|
+
case receiver
|
|
46
|
+
when nil, Prism::SelfNode
|
|
47
|
+
return nil if in_singleton_class
|
|
48
|
+
return "Object" if qualified_prefix.empty? && receiver.nil?
|
|
49
|
+
|
|
50
|
+
qualified_prefix.join("::") unless qualified_prefix.empty?
|
|
51
|
+
else
|
|
52
|
+
Source::ConstantPath.qualified_name_or_nil(receiver)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
private_class_method :owner
|
|
56
|
+
|
|
57
|
+
# The `include` / `prepend` call an idempotence guard runs only while its module is not yet an ancestor of its
|
|
58
|
+
# receiver, or nil. The guards are `X.include?(M)`, `X.ancestors.include?(M)` and `X < M`, written after the
|
|
59
|
+
# call as `unless` or as `if !`; the guard only skips a second include, so the call is read as a direct
|
|
60
|
+
# statement would be (#2109). The guard names the call's own receiver and module; any other condition, or a
|
|
61
|
+
# guard naming another receiver or module, leaves the call unread.
|
|
62
|
+
#
|
|
63
|
+
# `top_level` says the conditional is a direct statement of the file's top level, where a receiverless call
|
|
64
|
+
# includes into Object; see {guarded_call?}. The `CALLS` check is defensive: the scanner applies a call's mixin
|
|
65
|
+
# edge only when {Mixins.pending} reads it as include or prepend, so a call of another name changes nothing.
|
|
66
|
+
def guarded(node, top_level)
|
|
67
|
+
return nil unless node.is_a?(Prism::UnlessNode) || node.is_a?(Prism::IfNode)
|
|
68
|
+
|
|
69
|
+
call = guarded_body(node)
|
|
70
|
+
return nil unless call.is_a?(Prism::CallNode) && CALLS.include?(call.name) && guarded_call?(call, top_level)
|
|
71
|
+
|
|
72
|
+
guard = when_undefined(node)
|
|
73
|
+
call if guard && guards?(guard, call)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Whether `call` takes exactly one module, and is not receiverless at the file's top level. Such an include's
|
|
77
|
+
# guard would be `include?` on main, which Ruby does not define: the guard raises, so it is not read as an
|
|
78
|
+
# include into Object. A receiverless call in a class or module body names that body's own module.
|
|
79
|
+
def guarded_call?(call, top_level)
|
|
80
|
+
call.arguments&.arguments&.size == 1 && !(top_level && call.receiver.nil?)
|
|
81
|
+
end
|
|
82
|
+
private_class_method :guarded_call?
|
|
83
|
+
|
|
84
|
+
# The sole statement of an `unless` or `if` body, or nil when the conditional has an else branch or another
|
|
85
|
+
# body.
|
|
86
|
+
def guarded_body(node)
|
|
87
|
+
return nil if node.is_a?(Prism::UnlessNode) ? node.else_clause : node.subsequent
|
|
88
|
+
|
|
89
|
+
body = node.statements&.body
|
|
90
|
+
body.first if body&.size == 1
|
|
91
|
+
end
|
|
92
|
+
private_class_method :guarded_body
|
|
93
|
+
|
|
94
|
+
# The predicate the body runs under: `unless P` runs its body when P is false; `if !P` does too.
|
|
95
|
+
def when_undefined(node)
|
|
96
|
+
predicate = unwrap(node.predicate)
|
|
97
|
+
return predicate if node.is_a?(Prism::UnlessNode)
|
|
98
|
+
return nil unless predicate.is_a?(Prism::CallNode) && predicate.name == :! && predicate.receiver
|
|
99
|
+
|
|
100
|
+
unwrap(predicate.receiver)
|
|
101
|
+
end
|
|
102
|
+
private_class_method :when_undefined
|
|
103
|
+
|
|
104
|
+
def unwrap(node)
|
|
105
|
+
body = node.body.body if node.is_a?(Prism::ParenthesesNode) && node.body.is_a?(Prism::StatementsNode)
|
|
106
|
+
body&.size == 1 ? body.first : node
|
|
107
|
+
end
|
|
108
|
+
private_class_method :unwrap
|
|
109
|
+
|
|
110
|
+
# Whether `guard` is `X.include?(M)`, `X.ancestors.include?(M)` or `X < M`, where `X` and `M` are the receiver
|
|
111
|
+
# and the argument of `call`; {guarded} passes only a call with exactly one argument.
|
|
112
|
+
def guards?(guard, call)
|
|
113
|
+
return false unless guard.is_a?(Prism::CallNode) && guard.arguments&.arguments&.size == 1
|
|
114
|
+
|
|
115
|
+
subject, named = guard_subject(guard)
|
|
116
|
+
named && same_constant?(subject, call.receiver) && same_constant?(named, call.arguments.arguments.first)
|
|
117
|
+
end
|
|
118
|
+
private_class_method :guards?
|
|
119
|
+
|
|
120
|
+
# The `[receiver, module]` a guard names, or nil.
|
|
121
|
+
def guard_subject(guard)
|
|
122
|
+
named = guard.arguments.arguments.first
|
|
123
|
+
case guard.name
|
|
124
|
+
when :<
|
|
125
|
+
[guard.receiver, named] if guard.receiver
|
|
126
|
+
when :include?
|
|
127
|
+
receiver = guard.receiver
|
|
128
|
+
if receiver.is_a?(Prism::CallNode) && receiver.name == :ancestors && receiver.arguments.nil?
|
|
129
|
+
[receiver.receiver, named]
|
|
130
|
+
else
|
|
131
|
+
[receiver, named]
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
private_class_method :guard_subject
|
|
136
|
+
|
|
137
|
+
# Whether two receivers or modules name the same constant; two absent receivers (self) match.
|
|
138
|
+
def same_constant?(left, right)
|
|
139
|
+
return left.nil? && right.nil? if left.nil? || right.nil?
|
|
140
|
+
|
|
141
|
+
name = Source::ConstantPath.qualified_name_or_nil(left)
|
|
142
|
+
!name.nil? && name == Source::ConstantPath.qualified_name_or_nil(right)
|
|
143
|
+
end
|
|
144
|
+
private_class_method :same_constant?
|
|
145
|
+
|
|
146
|
+
# Each {Pending} edge's module name, resolved as Ruby resolves the constant from where it is written:
|
|
147
|
+
# `include NumExt` in `module Ext` names `Ext::NumExt` when a patch body opens that, else `NumExt`. Only the
|
|
148
|
+
# names the `pre_eval:` files open or mix into are candidates; one they do not name stays as written.
|
|
149
|
+
def resolve(pending, entries)
|
|
150
|
+
known = entries.to_set(&:class_name).merge(pending.map(&:owner))
|
|
151
|
+
pending.map do |item|
|
|
152
|
+
candidates = item.prefix.size.downto(1).map do |depth|
|
|
153
|
+
[*item.prefix.first(depth), item.module_name].join("::")
|
|
154
|
+
end
|
|
155
|
+
name = candidates.find { |candidate| known.include?(candidate) } || item.module_name
|
|
156
|
+
ProjectPatchedMethods::Mixin.new(owner: item.owner, module_name: name, kind: item.kind)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -4,6 +4,8 @@ require "prism"
|
|
|
4
4
|
|
|
5
5
|
require_relative "project_patched_methods"
|
|
6
6
|
require_relative "project_patched_definedness_guard"
|
|
7
|
+
require_relative "project_patched_visibility"
|
|
8
|
+
require_relative "project_patched_mixins"
|
|
7
9
|
require_relative "../analysis/dependency_source_inference/return_type_heuristic"
|
|
8
10
|
require_relative "../source/alias_names"
|
|
9
11
|
require_relative "../source/constant_path"
|
|
@@ -20,7 +22,7 @@ module Rigor
|
|
|
20
22
|
# only needs `class C; def m; end; end` shape recognition, not full inference. Parse errors degrade
|
|
21
23
|
# to a fail-soft `:warning` `pre-eval.parse-error` diagnostic accumulated alongside the registry;
|
|
22
24
|
# per ADR-17 § "Failure modes" a parse failure in a pre-eval file MUST NOT abort the rest of the run.
|
|
23
|
-
module ProjectPatchedScanner
|
|
25
|
+
module ProjectPatchedScanner # rubocop:disable Metrics/ModuleLength
|
|
24
26
|
# Frozen scan outcome carrying the populated registry and the per-file warnings the runner emits
|
|
25
27
|
# at run start.
|
|
26
28
|
class Result < Data.define(:registry, :diagnostics)
|
|
@@ -57,10 +59,13 @@ module Rigor
|
|
|
57
59
|
diagnostics = []
|
|
58
60
|
census = Set.new
|
|
59
61
|
paths.each { |path| scan_file(path, collected, diagnostics, buffer, census) }
|
|
60
|
-
|
|
62
|
+
pending_mixins, collected = collected.partition { |item| item.is_a?(Mixins::Pending) }
|
|
63
|
+
overrides = {}
|
|
64
|
+
entries = resolve_aliases(collected, overrides)
|
|
61
65
|
diagnostics.concat(duplicate_declaration_diagnostics(entries))
|
|
62
66
|
Result.new(
|
|
63
|
-
registry: ProjectPatchedMethods.new(entries: entries, write_census: census.freeze
|
|
67
|
+
registry: ProjectPatchedMethods.new(entries: entries, write_census: census.freeze, overrides: overrides,
|
|
68
|
+
mixins: Mixins.resolve(pending_mixins, entries)),
|
|
64
69
|
diagnostics: diagnostics
|
|
65
70
|
)
|
|
66
71
|
end
|
|
@@ -105,32 +110,50 @@ module Rigor
|
|
|
105
110
|
# idiom (`def foo`, `alias_method :foo_without_x, :foo`, `alias_method :foo, :foo_with_x`) leaves `foo` the
|
|
106
111
|
# alias. Bindings of one name in DIFFERENT files are what `pre-eval.duplicate-declaration` reports, and the
|
|
107
112
|
# registry keeps the last of those, as Ruby does.
|
|
108
|
-
|
|
113
|
+
#
|
|
114
|
+
# A {Visibility::Pending} (issue #1708) changes the visibility of the binding its name has when it runs, in the
|
|
115
|
+
# file that bound it, so it is not a second declaration; a name no earlier patch binds on the class is one it
|
|
116
|
+
# inherits, recorded in `overrides` ({Visibility.apply}). `module_function :x` also binds the public singleton
|
|
117
|
+
# copy.
|
|
118
|
+
def resolve_aliases(collected, overrides = {})
|
|
109
119
|
bound = {}
|
|
120
|
+
homes = {}
|
|
110
121
|
per_file = collected.group_by(&:source_path).values.map do |items|
|
|
111
122
|
final = {}
|
|
112
123
|
items.each do |item|
|
|
124
|
+
if item.is_a?(Visibility::Pending)
|
|
125
|
+
next Visibility.apply(item, bound, homes, overrides) { |copy| bind(copy, bound, homes, final) }
|
|
126
|
+
end
|
|
127
|
+
|
|
113
128
|
entry = item.is_a?(PendingAlias) ? alias_entry(item, bound) : item
|
|
114
129
|
entry = entry.entry if entry.is_a?(GuardedDef)
|
|
115
130
|
key = [entry.class_name, entry.method_name, entry.kind]
|
|
116
131
|
next if item.is_a?(GuardedDef) && bound.key?(key)
|
|
117
132
|
|
|
118
|
-
bound
|
|
119
|
-
final.delete(key)
|
|
120
|
-
final[key] = entry
|
|
133
|
+
bind(entry, bound, homes, final)
|
|
121
134
|
end
|
|
122
|
-
final
|
|
135
|
+
final
|
|
123
136
|
end
|
|
124
|
-
per_file.
|
|
137
|
+
per_file.flat_map(&:values)
|
|
125
138
|
end
|
|
126
139
|
private_class_method :resolve_aliases
|
|
127
140
|
|
|
141
|
+
def bind(entry, bound, homes, final)
|
|
142
|
+
key = [entry.class_name, entry.method_name, entry.kind]
|
|
143
|
+
bound[key] = entry
|
|
144
|
+
homes[key] = final
|
|
145
|
+
final.delete(key)
|
|
146
|
+
final[key] = entry
|
|
147
|
+
end
|
|
148
|
+
private_class_method :bind
|
|
149
|
+
|
|
128
150
|
def alias_entry(item, bound)
|
|
129
151
|
target = bound[[item.class_name, item.old_name, item.kind]]
|
|
130
152
|
ProjectPatchedMethods::Entry.new(
|
|
131
153
|
class_name: item.class_name, method_name: item.new_name, kind: item.kind,
|
|
132
154
|
source_path: item.source_path, source_line: item.source_line,
|
|
133
|
-
return_type: target&.return_type, alias_of: target ? target.alias_of : item.old_name
|
|
155
|
+
return_type: target&.return_type, alias_of: target ? target.alias_of : item.old_name,
|
|
156
|
+
visibility: target ? target.visibility : :public
|
|
134
157
|
)
|
|
135
158
|
end
|
|
136
159
|
private_class_method :alias_entry
|
|
@@ -148,8 +171,7 @@ module Rigor
|
|
|
148
171
|
return
|
|
149
172
|
end
|
|
150
173
|
|
|
151
|
-
|
|
152
|
-
walk_node(parse_result.value, [], false, path, entries)
|
|
174
|
+
walk_file(parse_result.value, path, entries)
|
|
153
175
|
# Issue #1367 — the file's `global.*` write facts: a patch file may alias a special or give a class the
|
|
154
176
|
# method a setter asks for, as any project file may.
|
|
155
177
|
census.merge(GlobalWriteCensus.scan(parse_result.value))
|
|
@@ -164,9 +186,19 @@ module Rigor
|
|
|
164
186
|
)
|
|
165
187
|
ensure
|
|
166
188
|
Thread.current[:rigor_guarded_defs] = nil
|
|
189
|
+
Thread.current[:rigor_def_visibilities] = nil
|
|
167
190
|
end
|
|
168
191
|
private_class_method :scan_file
|
|
169
192
|
|
|
193
|
+
# Walks one parsed file with its definedness guards and `def` visibilities (issue #1708) in reach of
|
|
194
|
+
# {record_def_node}.
|
|
195
|
+
def walk_file(root, path, entries)
|
|
196
|
+
Thread.current[:rigor_guarded_defs] = DefinednessGuard.defs(root)
|
|
197
|
+
Thread.current[:rigor_def_visibilities] = Visibility.defs(root)
|
|
198
|
+
walk_node(root, [], false, path, entries)
|
|
199
|
+
end
|
|
200
|
+
private_class_method :walk_file
|
|
201
|
+
|
|
170
202
|
def parse_error_diagnostic(path, errors)
|
|
171
203
|
first = errors.first
|
|
172
204
|
line = first.respond_to?(:location) ? first.location&.start_line || 1 : 1
|
|
@@ -205,6 +237,11 @@ module Rigor
|
|
|
205
237
|
when Prism::CallNode
|
|
206
238
|
record_alias(node, Source::AliasNames.alias_method_call_names(node), qualified_prefix,
|
|
207
239
|
in_singleton_class, source_path, entries)
|
|
240
|
+
if Thread.current[:rigor_def_visibilities]&.[](node).equal?(Visibility::STATEMENT)
|
|
241
|
+
pending = Visibility.pending(node, qualified_prefix, in_singleton_class, source_path)
|
|
242
|
+
entries << pending if pending
|
|
243
|
+
entries.concat(Mixins.pending(node, qualified_prefix, in_singleton_class))
|
|
244
|
+
end
|
|
208
245
|
walk_children(node, qualified_prefix, in_singleton_class, source_path, entries)
|
|
209
246
|
else
|
|
210
247
|
walk_children(node, qualified_prefix, in_singleton_class, source_path, entries)
|
|
@@ -251,7 +288,9 @@ module Rigor
|
|
|
251
288
|
source_path: source_path, source_line: line,
|
|
252
289
|
return_type: return_type
|
|
253
290
|
)
|
|
291
|
+
entry, *copies = Visibility.applied(entry, Thread.current[:rigor_def_visibilities]&.[](node) || :public)
|
|
254
292
|
entries << (Thread.current[:rigor_guarded_defs]&.key?(node) ? GuardedDef.new(entry: entry) : entry)
|
|
293
|
+
entries.concat(copies)
|
|
255
294
|
end
|
|
256
295
|
private_class_method :record_def_node
|
|
257
296
|
|