rigortype 0.1.17 → 0.1.19
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 +159 -222
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
- data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
- data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
- data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
- data/lib/rigor/analysis/check_rules.rb +275 -44
- data/lib/rigor/analysis/diagnostic.rb +8 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
- data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
- data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
- data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
- data/lib/rigor/analysis/runner.rb +207 -1200
- data/lib/rigor/analysis/worker_session.rb +60 -11
- data/lib/rigor/bleeding_edge.rb +123 -0
- data/lib/rigor/cache/descriptor.rb +86 -8
- data/lib/rigor/cache/incremental_snapshot.rb +10 -4
- data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
- data/lib/rigor/cache/rbs_descriptor.rb +2 -1
- data/lib/rigor/cache/store.rb +46 -13
- data/lib/rigor/cli/annotate_command.rb +100 -15
- data/lib/rigor/cli/check_command.rb +708 -0
- data/lib/rigor/cli/ci_detector.rb +94 -0
- data/lib/rigor/cli/diagnostic_formats.rb +345 -0
- data/lib/rigor/cli/plugins_command.rb +2 -4
- data/lib/rigor/cli/plugins_renderer.rb +0 -2
- data/lib/rigor/cli/prism_colorizer.rb +10 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
- data/lib/rigor/cli/trace_command.rb +143 -0
- data/lib/rigor/cli/trace_renderer.rb +310 -0
- data/lib/rigor/cli/triage_command.rb +6 -3
- data/lib/rigor/cli/triage_renderer.rb +15 -1
- data/lib/rigor/cli.rb +21 -612
- data/lib/rigor/configuration/severity_profile.rb +13 -1
- data/lib/rigor/configuration.rb +66 -7
- data/lib/rigor/environment/rbs_loader.rb +78 -68
- data/lib/rigor/environment.rb +1 -1
- data/lib/rigor/inference/acceptance.rb +10 -0
- data/lib/rigor/inference/body_fixpoint.rb +89 -0
- data/lib/rigor/inference/budget_trace.rb +29 -2
- data/lib/rigor/inference/expression_typer.rb +1080 -105
- data/lib/rigor/inference/flow_tracer.rb +180 -0
- data/lib/rigor/inference/macro_block_self_type.rb +11 -12
- data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
- data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
- data/lib/rigor/inference/method_dispatcher.rb +187 -55
- data/lib/rigor/inference/method_parameter_binder.rb +56 -2
- data/lib/rigor/inference/multi_target_binder.rb +46 -3
- data/lib/rigor/inference/mutation_widening.rb +142 -0
- data/lib/rigor/inference/narrowing.rb +330 -37
- data/lib/rigor/inference/scope_indexer.rb +770 -39
- data/lib/rigor/inference/statement_evaluator.rb +998 -68
- data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
- data/lib/rigor/plugin/additional_initializer.rb +61 -38
- data/lib/rigor/plugin/base.rb +517 -120
- data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
- data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
- data/lib/rigor/plugin/macro.rb +2 -3
- data/lib/rigor/plugin/manifest.rb +4 -24
- data/lib/rigor/plugin/node_rule_walk.rb +192 -0
- data/lib/rigor/plugin/registry.rb +264 -35
- data/lib/rigor/plugin.rb +1 -0
- data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
- data/lib/rigor/scope/discovery_index.rb +60 -0
- data/lib/rigor/scope.rb +199 -204
- data/lib/rigor/sig_gen/generator.rb +8 -0
- data/lib/rigor/sig_gen/observation_collector.rb +6 -6
- data/lib/rigor/source/literals.rb +14 -0
- data/lib/rigor/triage/catalogue.rb +4 -19
- data/lib/rigor/triage.rb +69 -1
- data/lib/rigor/type/combinator.rb +34 -0
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +0 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
- data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
- data/sig/rigor/analysis/fact_store.rbs +3 -0
- data/sig/rigor/environment.rbs +0 -2
- data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
- data/sig/rigor/inference.rbs +5 -0
- data/sig/rigor/plugin/base.rbs +6 -4
- data/sig/rigor/plugin/manifest.rbs +1 -2
- data/sig/rigor/scope.rbs +50 -29
- data/sig/rigor/source.rbs +1 -0
- data/sig/rigor/type.rbs +1 -0
- data/sig/rigor.rbs +1 -1
- data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
- data/skills/rigor-ci-setup/SKILL.md +319 -0
- data/skills/rigor-plugin-author/SKILL.md +6 -4
- data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
- data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
- metadata +21 -3
- data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
- data/lib/rigor/plugin/macro/external_file.rb +0 -143
|
@@ -4,40 +4,225 @@ require_relative "blueprint"
|
|
|
4
4
|
|
|
5
5
|
module Rigor
|
|
6
6
|
module Plugin
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
7
|
+
# ADR-52 WD1 — the compiled contribution table. Categorises a loaded
|
|
8
|
+
# plugin set by which per-call contribution paths each plugin
|
|
9
|
+
# actually implements, AND compiles the declarative gates (method
|
|
10
|
+
# names, `block_as_methods` method names, `owns_receivers`) into frozen
|
|
11
|
+
# lookup structures, so the engine's hot sites discover "no plugin
|
|
12
|
+
# cares about this call" in O(1) instead of O(plugins × rules) — a
|
|
13
|
+
# top hotspot on plugin-heavy projects (GitLab's 11 plugins, of
|
|
14
|
+
# which only 2 implement any per-call path). Built once per
|
|
15
|
+
# {Registry}.
|
|
16
|
+
#
|
|
17
|
+
# Ordering contract: the gates only PRUNE consultations that could
|
|
18
|
+
# not fire (every pruned rule would have failed its own `methods:` /
|
|
19
|
+
# `method_names:` check); the engine still iterates the plugin subsets in
|
|
20
|
+
# registry order and each plugin's rules in declaration order, so
|
|
21
|
+
# the surviving contributions arrive in exactly the order the
|
|
22
|
+
# ungated walk produced — diagnostics stay byte-identical. The
|
|
13
23
|
# receiver-class ancestry match for `dynamic_return` still happens
|
|
14
|
-
# per-dispatch inside `Plugin::Base#dynamic_return_type
|
|
15
|
-
# prunes plugins that *structurally* cannot contribute, preserving the
|
|
16
|
-
# exact contribution order and result.
|
|
24
|
+
# per-dispatch inside `Plugin::Base#dynamic_return_type`.
|
|
17
25
|
class ContributionIndex
|
|
18
26
|
# Ordered (registry-order) subsets relevant to each collector, and
|
|
19
|
-
# the membership sets used to gate the
|
|
20
|
-
|
|
27
|
+
# the membership sets used to gate the paths within a plugin.
|
|
28
|
+
# `for_file_diagnostics` is the subset the runner's per-file
|
|
29
|
+
# diagnostic loop visits: plugins overriding
|
|
30
|
+
# `#diagnostics_for_file` or declaring at least one `node_rule`.
|
|
31
|
+
attr_reader :for_method_dispatch, :for_statement, :for_file_diagnostics
|
|
32
|
+
|
|
33
|
+
EMPTY_BLOCK_ENTRIES = [].freeze
|
|
34
|
+
private_constant :EMPTY_BLOCK_ENTRIES
|
|
21
35
|
|
|
22
36
|
def initialize(plugins)
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@
|
|
26
|
-
@
|
|
27
|
-
|
|
37
|
+
compile_memberships(plugins)
|
|
38
|
+
compile_gates
|
|
39
|
+
@block_entries_by_method_name = build_block_entries(plugins)
|
|
40
|
+
@owns_receivers = plugins.flat_map { |p| manifest_for(p)&.owns_receivers || [] }.uniq.freeze
|
|
41
|
+
# Per-run ancestry verdict memo, keyed by environment identity
|
|
42
|
+
# then class name. Mutable inside the frozen index — sound
|
|
43
|
+
# because the class graph is fixed for the lifetime of a run.
|
|
44
|
+
@owns_receiver_memo = {}.compare_by_identity
|
|
28
45
|
freeze
|
|
29
46
|
end
|
|
30
47
|
|
|
31
|
-
def flow?(plugin) = @flow.include?(plugin)
|
|
32
48
|
def dynamic?(plugin) = @dynamic.include?(plugin)
|
|
33
49
|
def type_specifier?(plugin) = @type_specifier.include?(plugin)
|
|
34
50
|
|
|
51
|
+
# O(1) "could any plugin contribute a return type for a call named
|
|
52
|
+
# `method_name`?" — false only when every `dynamic_return` rule is
|
|
53
|
+
# `methods:`-gated on other names, in which case the ungated walk
|
|
54
|
+
# would have produced zero contributions too.
|
|
55
|
+
def dispatch_candidate?(method_name)
|
|
56
|
+
return true if @dynamic_global_gate.nil?
|
|
57
|
+
|
|
58
|
+
@dynamic_global_gate.include?(method_name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# O(1) statement-path sibling of {#dispatch_candidate?} over the
|
|
62
|
+
# `type_specifier` rules (which are always `methods:`-gated).
|
|
63
|
+
def statement_candidate?(method_name)
|
|
64
|
+
return true if @type_specifier_global_gate.nil?
|
|
65
|
+
|
|
66
|
+
@type_specifier_global_gate.include?(method_name)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Per-plugin gate: false when the plugin declares no
|
|
70
|
+
# `dynamic_return` rules at all, or when every rule is
|
|
71
|
+
# `methods:`-gated and none lists `method_name` — i.e. when
|
|
72
|
+
# `#dynamic_return_type` would return nil without ever entering a
|
|
73
|
+
# rule block. Subsumes the old `dynamic?(plugin)` membership
|
|
74
|
+
# check at the collector's call site.
|
|
75
|
+
def dynamic_candidate_for?(plugin, method_name)
|
|
76
|
+
return false unless @dynamic.include?(plugin)
|
|
77
|
+
|
|
78
|
+
gate = @dynamic_gates[plugin]
|
|
79
|
+
gate.nil? || gate.include?(method_name)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Per-plugin gate over `type_specifier` rules; same contract as
|
|
83
|
+
# {#dynamic_candidate_for?}.
|
|
84
|
+
def type_specifier_candidate_for?(plugin, method_name)
|
|
85
|
+
return false unless @type_specifier.include?(plugin)
|
|
86
|
+
|
|
87
|
+
gate = @type_specifier_gates[plugin]
|
|
88
|
+
gate.nil? || gate.include?(method_name)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The `Macro::BlockAsMethod` entries whose `method_names` include
|
|
92
|
+
# `method_name`, in (plugin registration, manifest declaration)
|
|
93
|
+
# order — the same first-match order the previous plugins ×
|
|
94
|
+
# entries walk visited.
|
|
95
|
+
def block_entries_for(method_name)
|
|
96
|
+
@block_entries_by_method_name.fetch(method_name, EMPTY_BLOCK_ENTRIES)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# True when `class_name` equals or inherits from any plugin's
|
|
100
|
+
# manifest-declared `owns_receivers:` entry. The union is compiled
|
|
101
|
+
# at build time (almost always empty → O(1) false) and per-class
|
|
102
|
+
# verdicts memoise per environment.
|
|
103
|
+
def owns_receiver?(class_name, environment)
|
|
104
|
+
return false if @owns_receivers.empty? || class_name.nil?
|
|
105
|
+
|
|
106
|
+
memo = (@owns_receiver_memo[environment] ||= {})
|
|
107
|
+
memo.fetch(class_name) do
|
|
108
|
+
memo[class_name] =
|
|
109
|
+
@owns_receivers.any? { |owner| class_matches_owner?(class_name, owner, environment) }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
35
113
|
private
|
|
36
114
|
|
|
37
|
-
#
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
115
|
+
# The categorisation sets + the ordered per-collector subsets.
|
|
116
|
+
def compile_memberships(plugins)
|
|
117
|
+
plugins.each { |p| reject_legacy_flow_hook!(p) }
|
|
118
|
+
@dynamic = plugins.reject { |p| p.class.dynamic_returns.empty? }.to_set
|
|
119
|
+
@type_specifier = plugins.reject { |p| p.class.type_specifiers.empty? }.to_set
|
|
120
|
+
compile_collector_subsets(plugins)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def compile_collector_subsets(plugins)
|
|
124
|
+
@for_method_dispatch = plugins.select { |p| @dynamic.include?(p) }.freeze
|
|
125
|
+
@for_statement = plugins.select { |p| @type_specifier.include?(p) }.freeze
|
|
126
|
+
@for_file_diagnostics =
|
|
127
|
+
plugins.select { |p| file_diagnostics_overridden?(p) || !p.class.node_rules.empty? }.freeze
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# The per-plugin and registry-global method-name gates.
|
|
131
|
+
def compile_gates
|
|
132
|
+
@dynamic_gates = build_name_gates(@dynamic) { |p| p.class.dynamic_returns }
|
|
133
|
+
@type_specifier_gates = build_name_gates(@type_specifier) { |p| p.class.type_specifiers }
|
|
134
|
+
@dynamic_global_gate = union_gate(@dynamic_gates)
|
|
135
|
+
@type_specifier_global_gate = union_gate(@type_specifier_gates)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# ADR-52 WD3 — the legacy ungated `flow_contribution_for` hook was
|
|
139
|
+
# deleted pre-1.0. A plugin still defining it would silently never
|
|
140
|
+
# be called, which is the worst failure mode for its author —
|
|
141
|
+
# surface a loud load-time error with the migration mapping
|
|
142
|
+
# instead. (The Base default is gone, so any definer wrote it
|
|
143
|
+
# themselves.)
|
|
144
|
+
def reject_legacy_flow_hook!(plugin)
|
|
145
|
+
return unless plugin.respond_to?(:flow_contribution_for)
|
|
146
|
+
|
|
147
|
+
raise ArgumentError,
|
|
148
|
+
"plugin #{(plugin.class.name || plugin.class).inspect} defines `flow_contribution_for`, " \
|
|
149
|
+
"which was removed (ADR-52). Declare the per-call return type via `dynamic_return` " \
|
|
150
|
+
"(receivers:/methods:/file_methods: gates, static or callable) and post-return narrowing " \
|
|
151
|
+
"facts via `type_specifier` — see the CHANGELOG migration note."
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Same `Method#owner` trick for the per-file diagnostics hook —
|
|
155
|
+
# the Base default returns `[]`, so a non-overriding plugin can be
|
|
156
|
+
# skipped without calling it.
|
|
157
|
+
def file_diagnostics_overridden?(plugin)
|
|
158
|
+
plugin.method(:diagnostics_for_file).owner != Rigor::Plugin::Base
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# `plugin → Set[Symbol] | nil`. A frozen Set when EVERY rule of
|
|
162
|
+
# the plugin is `methods:`-gated (the union of those names); nil
|
|
163
|
+
# when any rule is ungated (the plugin must be consulted for every
|
|
164
|
+
# method name, exactly as before).
|
|
165
|
+
def build_name_gates(members)
|
|
166
|
+
gates = {}
|
|
167
|
+
members.each do |plugin|
|
|
168
|
+
rules = yield(plugin)
|
|
169
|
+
# A static method-name Array can be compiled into the gate. A
|
|
170
|
+
# run-time callable `methods:` (ADR-52 slice 4) is unknown until
|
|
171
|
+
# after `#prepare`, and a receiver-only rule has no `methods:` —
|
|
172
|
+
# either makes the plugin ungated-by-name (nil), consulted on
|
|
173
|
+
# every dispatch and filtered in the instance path.
|
|
174
|
+
gates[plugin] =
|
|
175
|
+
if rules.all? { |rule| rule[:methods].is_a?(Array) }
|
|
176
|
+
rules.flat_map { |rule| rule[:methods] }.to_set.freeze
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
gates.freeze
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# The registry-wide union of per-plugin gates, or nil when any
|
|
183
|
+
# plugin is ungated (no global pruning possible). An empty
|
|
184
|
+
# plugin set unions to an empty frozen Set — the collectors'
|
|
185
|
+
# `relevant.empty?` early return fires before it is consulted.
|
|
186
|
+
def union_gate(gates)
|
|
187
|
+
return nil if gates.value?(nil)
|
|
188
|
+
|
|
189
|
+
gates.values.reduce(Set.new) { |acc, names| acc.merge(names) }.freeze
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# `method-name Symbol → [BlockAsMethod entries]`, insertion-ordered
|
|
193
|
+
# by (plugin, declaration). Method names are Symbol-normalised by
|
|
194
|
+
# `Macro::BlockAsMethod#initialize`.
|
|
195
|
+
def build_block_entries(plugins)
|
|
196
|
+
table = {}
|
|
197
|
+
plugins.each do |plugin|
|
|
198
|
+
entries = manifest_for(plugin)&.block_as_methods || []
|
|
199
|
+
entries.each do |entry|
|
|
200
|
+
entry.method_names.each { |name| (table[name] ||= []) << entry }
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
table.each_value(&:freeze)
|
|
204
|
+
table.freeze
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Mirrors `MethodDispatcher`'s previous per-dispatch matching:
|
|
208
|
+
# exact-name fast path, then `Environment#class_ordering`,
|
|
209
|
+
# degrading to "no match" on any resolution failure.
|
|
210
|
+
def class_matches_owner?(class_name, owner, environment)
|
|
211
|
+
return true if class_name == owner
|
|
212
|
+
return false if environment.nil?
|
|
213
|
+
|
|
214
|
+
%i[equal subclass].include?(environment.class_ordering(class_name, owner))
|
|
215
|
+
rescue StandardError
|
|
216
|
+
false
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# Manifest access tolerant of manifest-less plugin doubles in unit
|
|
220
|
+
# specs (a real loaded plugin always carries one — the loader
|
|
221
|
+
# validates it).
|
|
222
|
+
def manifest_for(plugin)
|
|
223
|
+
plugin.manifest
|
|
224
|
+
rescue StandardError
|
|
225
|
+
nil
|
|
41
226
|
end
|
|
42
227
|
end
|
|
43
228
|
|
|
@@ -78,9 +263,29 @@ module Rigor
|
|
|
78
263
|
@load_errors = load_errors.dup.freeze
|
|
79
264
|
@blueprints = blueprints.dup.freeze
|
|
80
265
|
@contribution_index = ContributionIndex.new(@plugins)
|
|
266
|
+
# ADR-52 WD1 — aggregate queries the engine issues per def /
|
|
267
|
+
# per diagnostic candidate / per path are compiled once here
|
|
268
|
+
# (the registry is frozen, so the flat_map-on-every-call
|
|
269
|
+
# versions re-derived an invariant). `@contracts_by_path` is a
|
|
270
|
+
# mutable per-path memo inside the frozen registry — safe
|
|
271
|
+
# because the contract set and the glob semantics are fixed
|
|
272
|
+
# for the lifetime of the run.
|
|
273
|
+
@additional_initializers = @plugins.flat_map { |p| safe_manifest(p)&.additional_initializers || [] }.freeze
|
|
274
|
+
@open_receivers = @plugins.flat_map { |p| (safe_manifest(p)&.open_receivers || []).map(&:to_s) }.uniq.freeze
|
|
275
|
+
@open_receivers_set = @open_receivers.to_set.freeze
|
|
276
|
+
@protocol_contracts = @plugins.flat_map { |p| safe_protocol_contracts(p) }.freeze
|
|
277
|
+
@contracts_by_path = {}
|
|
278
|
+
# ADR-52 WD4 — the single engine-owned node-rule walk, compiled
|
|
279
|
+
# once per run from the node-rule plugin subset (registry order).
|
|
280
|
+
# The runner reuses it for every file; it builds fresh per-file
|
|
281
|
+
# state internally, so it is safe to freeze and share.
|
|
282
|
+
@node_rule_walk = NodeRuleWalk.new(@plugins)
|
|
81
283
|
freeze
|
|
82
284
|
end
|
|
83
285
|
|
|
286
|
+
# ADR-52 WD4 — the per-run node-rule walk (see {NodeRuleWalk}).
|
|
287
|
+
attr_reader :node_rule_walk
|
|
288
|
+
|
|
84
289
|
# ADR-15 Phase 3 — build a fresh Registry from the supplied
|
|
85
290
|
# blueprint set by replaying {Blueprint#materialize} per
|
|
86
291
|
# entry against `services`. The returned registry carries
|
|
@@ -158,14 +363,15 @@ module Rigor
|
|
|
158
363
|
# beyond its RBS-declared method surface. `open_receiver?`
|
|
159
364
|
# is the membership predicate `Analysis::CheckRules` consults
|
|
160
365
|
# to skip the `call.undefined-method` rule for such a class.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
366
|
+
# Compiled at construction (ADR-52 WD1) — the predicate runs per
|
|
367
|
+
# undefined-method candidate, so the previous per-call flat_map
|
|
368
|
+
# re-derived a frozen invariant.
|
|
369
|
+
attr_reader :open_receivers
|
|
164
370
|
|
|
165
371
|
def open_receiver?(class_name)
|
|
166
372
|
return false if class_name.nil?
|
|
167
373
|
|
|
168
|
-
|
|
374
|
+
@open_receivers_set.include?(class_name.to_s)
|
|
169
375
|
end
|
|
170
376
|
|
|
171
377
|
# ADR-28 — flat, ordered list of every loaded plugin's
|
|
@@ -176,10 +382,10 @@ module Rigor
|
|
|
176
382
|
# Consumed by `Inference::MethodParameterBinder` (the
|
|
177
383
|
# parameter-type provision) and by contributing plugins'
|
|
178
384
|
# `#diagnostics_for_file` hooks (the presence + return-type
|
|
179
|
-
# check).
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
385
|
+
# check). Compiled at construction (ADR-52 WD1); a plugin's
|
|
386
|
+
# config-folding `#protocol_contracts` override is stable for the
|
|
387
|
+
# run because config is injected at construction.
|
|
388
|
+
attr_reader :protocol_contracts
|
|
183
389
|
|
|
184
390
|
# ADR-38 — flat, ordered list of every loaded plugin's
|
|
185
391
|
# manifest-declared `Rigor::Plugin::AdditionalInitializer`
|
|
@@ -187,9 +393,9 @@ module Rigor
|
|
|
187
393
|
# read-before-write nil soundness gate: a `def` whose name an
|
|
188
394
|
# entry covers, on a class that equals or inherits from the
|
|
189
395
|
# entry's `receiver_constraint`, is treated like `initialize`.
|
|
190
|
-
def
|
|
191
|
-
|
|
192
|
-
|
|
396
|
+
# Compiled at construction (ADR-52 WD1) — consulted per `def`
|
|
397
|
+
# node, ×2 sites in `ScopeIndexer`.
|
|
398
|
+
attr_reader :additional_initializers
|
|
193
399
|
|
|
194
400
|
# ADR-28 — the subset of `protocol_contracts` whose
|
|
195
401
|
# `path_glob` matches `path`. Contract globs are authored
|
|
@@ -201,11 +407,15 @@ module Rigor
|
|
|
201
407
|
# suffix. `File::FNM_PATHNAME` keeps `*` from crossing `/`;
|
|
202
408
|
# `File::FNM_EXTGLOB` enables `{a,b}` groups. Returns `[]` for
|
|
203
409
|
# a nil path so the binder can call this unconditionally.
|
|
410
|
+
# Memoised per path (ADR-52 WD1) — consulted per `def` node by
|
|
411
|
+
# `MethodParameterBinder`, and the fnmatch sweep over every
|
|
412
|
+
# contract is pure in (contract set, path).
|
|
204
413
|
def contracts_for_path(path)
|
|
205
414
|
return [] if path.nil?
|
|
206
415
|
|
|
207
416
|
path_s = path.to_s
|
|
208
|
-
|
|
417
|
+
@contracts_by_path[path_s] ||=
|
|
418
|
+
protocol_contracts.select { |contract| path_matches_glob?(contract.path_glob, path_s) }.freeze
|
|
209
419
|
end
|
|
210
420
|
|
|
211
421
|
# ADR-32 WD4 + WD5 — flat ordered list of
|
|
@@ -232,14 +442,33 @@ module Rigor
|
|
|
232
442
|
FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
|
|
233
443
|
private_constant :FNMATCH_FLAGS
|
|
234
444
|
|
|
235
|
-
EMPTY = new.freeze
|
|
236
|
-
|
|
237
445
|
private
|
|
238
446
|
|
|
239
447
|
def path_matches_glob?(glob, path)
|
|
240
448
|
File.fnmatch?(glob, path, FNMATCH_FLAGS) ||
|
|
241
449
|
File.fnmatch?(File.join("**", glob), path, FNMATCH_FLAGS)
|
|
242
450
|
end
|
|
451
|
+
|
|
452
|
+
# Construction-time manifest access tolerant of manifest-less
|
|
453
|
+
# plugin doubles in unit specs (a real loaded plugin always
|
|
454
|
+
# carries one — the loader validates it). Mirrors
|
|
455
|
+
# `ContributionIndex#manifest_for`.
|
|
456
|
+
def safe_manifest(plugin)
|
|
457
|
+
plugin.manifest
|
|
458
|
+
rescue StandardError
|
|
459
|
+
nil
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def safe_protocol_contracts(plugin)
|
|
463
|
+
plugin.protocol_contracts || []
|
|
464
|
+
rescue StandardError
|
|
465
|
+
[]
|
|
466
|
+
end
|
|
243
467
|
end
|
|
468
|
+
|
|
469
|
+
# Assigned after the class body completes — `Registry.new` runs at
|
|
470
|
+
# assignment time and `#initialize` calls private helpers defined
|
|
471
|
+
# late in the body.
|
|
472
|
+
Registry::EMPTY = Registry.new.freeze
|
|
244
473
|
end
|
|
245
474
|
end
|
data/lib/rigor/plugin.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "plugin/io_boundary"
|
|
|
9
9
|
require_relative "plugin/fact_store"
|
|
10
10
|
require_relative "plugin/services"
|
|
11
11
|
require_relative "plugin/base"
|
|
12
|
+
require_relative "plugin/node_rule_walk"
|
|
12
13
|
require_relative "plugin/registry"
|
|
13
14
|
require_relative "plugin/load_error"
|
|
14
15
|
require_relative "plugin/box"
|
|
@@ -118,12 +118,17 @@ module Rigor
|
|
|
118
118
|
# nil. Mirrors the ADR-35 override checks: covariant return,
|
|
119
119
|
# contravariant params, single method type only, `Dynamic[Top]`
|
|
120
120
|
# positions skipped (fires only on a proven `accepts(...).no?`).
|
|
121
|
+
# Also checks arity divergence and keyword-requiredness divergence
|
|
122
|
+
# (positional-type comparison only was the initial scope; these
|
|
123
|
+
# extend it to the cases that cause runtime ArgumentError).
|
|
121
124
|
def signature_mismatch(required_method, provided_method)
|
|
122
125
|
return nil unless required_method.method_types.size == 1
|
|
123
126
|
return nil unless provided_method.method_types.size == 1
|
|
124
127
|
|
|
125
128
|
return_detail(required_method, provided_method) ||
|
|
126
|
-
param_detail(required_method, provided_method)
|
|
129
|
+
param_detail(required_method, provided_method) ||
|
|
130
|
+
arity_detail(required_method, provided_method) ||
|
|
131
|
+
keyword_detail(required_method, provided_method)
|
|
127
132
|
end
|
|
128
133
|
|
|
129
134
|
def return_detail(required_method, provided_method)
|
|
@@ -153,6 +158,86 @@ module Rigor
|
|
|
153
158
|
nil
|
|
154
159
|
end
|
|
155
160
|
|
|
161
|
+
# Checks positional-count divergence — cases that would cause a
|
|
162
|
+
# runtime `ArgumentError` even when every declared type matches.
|
|
163
|
+
# Skipped when either side has a rest parameter (`*args`) since
|
|
164
|
+
# that makes the arity range unbounded. Two violation shapes:
|
|
165
|
+
# (a) provided requires MORE positionals than the interface allows
|
|
166
|
+
# (caller passes ≤ interface total → provided raises);
|
|
167
|
+
# (b) provided accepts FEWER positionals than the interface requires
|
|
168
|
+
# (caller passes ≥ interface required → provided raises).
|
|
169
|
+
def arity_detail(required_method, provided_method)
|
|
170
|
+
req_func = required_method.method_types.first.type
|
|
171
|
+
prov_func = provided_method.method_types.first.type
|
|
172
|
+
return nil unless req_func.respond_to?(:required_positionals)
|
|
173
|
+
return nil unless prov_func.respond_to?(:required_positionals)
|
|
174
|
+
|
|
175
|
+
req_req = req_func.required_positionals.size
|
|
176
|
+
req_opt = req_func.optional_positionals.size
|
|
177
|
+
prov_req = prov_func.required_positionals.size
|
|
178
|
+
prov_opt = prov_func.required_positionals.size + prov_func.optional_positionals.size
|
|
179
|
+
|
|
180
|
+
# (a) provided requires too many — callers may not pass enough
|
|
181
|
+
if req_func.rest_positionals.nil? && prov_req > req_req + req_opt
|
|
182
|
+
return "requires #{prov_req} positional argument#{'s' if prov_req != 1} " \
|
|
183
|
+
"but the interface allows at most #{req_req + req_opt}"
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# (b) provided accepts too few — callers will pass more than provided can handle
|
|
187
|
+
if prov_func.rest_positionals.nil? && req_req > prov_opt
|
|
188
|
+
return "accepts at most #{prov_opt} positional argument#{'s' if prov_opt != 1} " \
|
|
189
|
+
"but the interface requires at least #{req_req}"
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
nil
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Checks keyword-requiredness divergence.
|
|
196
|
+
# (a) A keyword the interface requires that the provided method does
|
|
197
|
+
# not accept at all → callers will pass it, provided will raise.
|
|
198
|
+
# (b) A keyword the provided method requires that the interface does
|
|
199
|
+
# not mention → callers following the interface won't pass it,
|
|
200
|
+
# provided will raise.
|
|
201
|
+
# Skipped when either side has a keyword rest (`**kwargs`).
|
|
202
|
+
def keyword_detail(required_method, provided_method)
|
|
203
|
+
req_func, prov_func = keyword_funcs(required_method, provided_method)
|
|
204
|
+
return nil unless req_func
|
|
205
|
+
|
|
206
|
+
prov_accepted = accepted_keywords(prov_func)
|
|
207
|
+
req_accepted = accepted_keywords(req_func)
|
|
208
|
+
|
|
209
|
+
not_accepted = req_func.required_keywords.keys - prov_accepted
|
|
210
|
+
return keyword_mismatch_message("does not accept required", not_accepted) if not_accepted.any?
|
|
211
|
+
|
|
212
|
+
extra_required = prov_func.required_keywords.keys - req_accepted
|
|
213
|
+
if extra_required.any?
|
|
214
|
+
return keyword_mismatch_message("requires", extra_required,
|
|
215
|
+
suffix: " not declared by the interface")
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def keyword_funcs(required_method, provided_method)
|
|
222
|
+
req_func = required_method.method_types.first.type
|
|
223
|
+
prov_func = provided_method.method_types.first.type
|
|
224
|
+
return [nil, nil] unless req_func.respond_to?(:required_keywords)
|
|
225
|
+
return [nil, nil] unless prov_func.respond_to?(:required_keywords)
|
|
226
|
+
return [nil, nil] unless req_func.rest_keywords.nil? && prov_func.rest_keywords.nil?
|
|
227
|
+
|
|
228
|
+
[req_func, prov_func]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def accepted_keywords(func)
|
|
232
|
+
func.required_keywords.keys + func.optional_keywords.keys
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def keyword_mismatch_message(prefix, kw_keys, suffix: "")
|
|
236
|
+
listed = kw_keys.sort.map { |k| "`#{k}:`" }.join(", ")
|
|
237
|
+
noun = kw_keys.size == 1 ? "keyword" : "keywords"
|
|
238
|
+
"#{prefix} #{noun} #{listed}#{suffix}"
|
|
239
|
+
end
|
|
240
|
+
|
|
156
241
|
def positional_param_types(method_def)
|
|
157
242
|
func = method_def.method_types.first.type
|
|
158
243
|
return nil unless func.respond_to?(:required_positionals)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
class Scope
|
|
5
|
+
# ADR-53 Track A — the seed-time discovery context every Scope snapshot
|
|
6
|
+
# carries by a single reference. Holds the tables the index-time
|
|
7
|
+
# pre-passes (`Inference::ScopeIndexer` per file, plus the cross-file
|
|
8
|
+
# project pre-pass) populate and that no control-flow transition ever
|
|
9
|
+
# varies: `Scope#==` ignores them and `Scope#join` copies them from the
|
|
10
|
+
# receiver unexamined, which is the membership litmus the ADR fixes.
|
|
11
|
+
#
|
|
12
|
+
# Immutable (`Data` instances are frozen); deriving a seeded index goes
|
|
13
|
+
# through `#with(table_name: table)`. `Scope` exposes each table through
|
|
14
|
+
# its existing reader surface, so engine call sites and plugins are
|
|
15
|
+
# unaffected by the extraction.
|
|
16
|
+
DiscoveryIndex = Data.define(
|
|
17
|
+
:declared_types,
|
|
18
|
+
:class_ivars,
|
|
19
|
+
:class_cvars,
|
|
20
|
+
:program_globals,
|
|
21
|
+
:discovered_classes,
|
|
22
|
+
:in_source_constants,
|
|
23
|
+
:discovered_methods,
|
|
24
|
+
:discovered_def_nodes,
|
|
25
|
+
:discovered_singleton_def_nodes,
|
|
26
|
+
:discovered_def_sources,
|
|
27
|
+
:discovered_method_visibilities,
|
|
28
|
+
:discovered_superclasses,
|
|
29
|
+
:discovered_includes,
|
|
30
|
+
:discovered_class_sources,
|
|
31
|
+
:data_member_layouts
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
class DiscoveryIndex
|
|
35
|
+
EMPTY_NODE_TABLE = {}.compare_by_identity.freeze
|
|
36
|
+
EMPTY_TABLE = {}.freeze
|
|
37
|
+
private_constant :EMPTY_NODE_TABLE, :EMPTY_TABLE
|
|
38
|
+
|
|
39
|
+
# The shared all-empty index `Scope.empty` (and every scope that never
|
|
40
|
+
# sees a seeding pass) points at — one allocation per process.
|
|
41
|
+
EMPTY = new(
|
|
42
|
+
declared_types: EMPTY_NODE_TABLE,
|
|
43
|
+
class_ivars: EMPTY_TABLE,
|
|
44
|
+
class_cvars: EMPTY_TABLE,
|
|
45
|
+
program_globals: EMPTY_TABLE,
|
|
46
|
+
discovered_classes: EMPTY_TABLE,
|
|
47
|
+
in_source_constants: EMPTY_TABLE,
|
|
48
|
+
discovered_methods: EMPTY_TABLE,
|
|
49
|
+
discovered_def_nodes: EMPTY_TABLE,
|
|
50
|
+
discovered_singleton_def_nodes: EMPTY_TABLE,
|
|
51
|
+
discovered_def_sources: EMPTY_TABLE,
|
|
52
|
+
discovered_method_visibilities: EMPTY_TABLE,
|
|
53
|
+
discovered_superclasses: EMPTY_TABLE,
|
|
54
|
+
discovered_includes: EMPTY_TABLE,
|
|
55
|
+
discovered_class_sources: EMPTY_TABLE,
|
|
56
|
+
data_member_layouts: EMPTY_TABLE
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|