rigortype 0.1.17 → 0.1.18

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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
  4. data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
  5. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +18 -1
  6. data/lib/rigor/analysis/check_rules.rb +34 -6
  7. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  8. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  9. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  10. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  11. data/lib/rigor/analysis/runner.rb +160 -1190
  12. data/lib/rigor/analysis/worker_session.rb +47 -8
  13. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  14. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  15. data/lib/rigor/cache/store.rb +46 -13
  16. data/lib/rigor/cli/check_command.rb +705 -0
  17. data/lib/rigor/cli/ci_detector.rb +94 -0
  18. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  19. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  20. data/lib/rigor/cli/trace_command.rb +143 -0
  21. data/lib/rigor/cli/trace_renderer.rb +310 -0
  22. data/lib/rigor/cli.rb +15 -614
  23. data/lib/rigor/configuration.rb +9 -6
  24. data/lib/rigor/environment/rbs_loader.rb +53 -68
  25. data/lib/rigor/environment.rb +1 -1
  26. data/lib/rigor/inference/acceptance.rb +10 -0
  27. data/lib/rigor/inference/expression_typer.rb +28 -62
  28. data/lib/rigor/inference/flow_tracer.rb +180 -0
  29. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  31. data/lib/rigor/inference/method_dispatcher.rb +115 -54
  32. data/lib/rigor/inference/narrowing.rb +60 -0
  33. data/lib/rigor/inference/scope_indexer.rb +75 -15
  34. data/lib/rigor/inference/statement_evaluator.rb +35 -52
  35. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  36. data/lib/rigor/plugin/base.rb +282 -41
  37. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  38. data/lib/rigor/plugin/registry.rb +263 -35
  39. data/lib/rigor/plugin.rb +1 -0
  40. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  41. data/lib/rigor/scope/discovery_index.rb +58 -0
  42. data/lib/rigor/scope.rb +67 -198
  43. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  44. data/lib/rigor/source/literals.rb +14 -0
  45. data/lib/rigor/type/combinator.rb +5 -0
  46. data/lib/rigor/version.rb +1 -1
  47. data/lib/rigor.rb +0 -1
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  49. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  50. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  53. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  54. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  55. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  56. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  57. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  58. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  59. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  60. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  61. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  62. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  63. data/sig/rigor/environment.rbs +0 -2
  64. data/sig/rigor/inference.rbs +5 -0
  65. data/sig/rigor/plugin/base.rbs +1 -2
  66. data/sig/rigor/scope.rbs +41 -29
  67. data/sig/rigor/source.rbs +1 -0
  68. data/skills/rigor-ci-setup/SKILL.md +319 -0
  69. metadata +15 -2
  70. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "node_context"
4
+ require_relative "../source/node_walker"
5
+
6
+ module Rigor
7
+ module Plugin
8
+ # ADR-52 WD4 — one engine-owned AST walk per file for node rules.
9
+ #
10
+ # Before this, every plugin that declared a {Base.node_rule} walked
11
+ # the file's AST itself (`Base#node_rule_diagnostics` →
12
+ # `Source::NodeWalker.each_with_ancestors`), so a project with N
13
+ # node-rule plugins paid N walks per file. This folds them into a
14
+ # single walk that dispatches each visited node to every matching
15
+ # `(plugin, rule)` pair.
16
+ #
17
+ # Behaviour is preserved exactly so the diagnostics stay
18
+ # byte-identical (the WD6 gate):
19
+ #
20
+ # * Each plugin's `node_file_context` block runs once per file,
21
+ # before any of its rules fire, `instance_exec`'d on that plugin —
22
+ # same as the per-plugin walk.
23
+ # * One frozen {NodeContext} is built per node, lazily, only when at
24
+ # least one rule matches it. Because it wraps only the ancestors it
25
+ # is safe to share across plugins for the same node.
26
+ # * Each rule block is `instance_exec`'d on its own plugin instance
27
+ # with the same five arguments `(node, scope, path, file_context,
28
+ # context)`.
29
+ # * A plugin whose context block or any rule block raises has its
30
+ # whole node-rule contribution isolated — the walk records the
31
+ # error against that plugin and continues, matching the runner's
32
+ # per-plugin rescue around the old `#node_rule_diagnostics` call.
33
+ # * Diagnostics are bucketed per plugin and returned in the
34
+ # registry order the runner already iterates, so emission order is
35
+ # unchanged (plugin-major, not node-major) — order preservation is
36
+ # what keeps the gate byte-identical in this slice.
37
+ #
38
+ # The result is an ordered Array of {Result}, one per node-rule
39
+ # plugin (registry order). `Result#error` is non-nil iff that
40
+ # plugin's context or a rule block raised, in which case
41
+ # `#diagnostics` is empty; the runner turns the error into the same
42
+ # per-plugin `runtime-error` envelope it produced before.
43
+ class NodeRuleWalk
44
+ # One plugin's node-rule outcome for a single file. `error` is the
45
+ # exception raised by the plugin's context block or a rule block
46
+ # (nil on success); when set, `diagnostics` is empty.
47
+ Result = Struct.new(:plugin, :diagnostics, :error)
48
+
49
+ # Plugins that declare at least one `node_rule`, paired with their
50
+ # frozen rule list, in registry order. Built once per run and
51
+ # reused for every file.
52
+ def initialize(plugins)
53
+ @entries = plugins.filter_map do |plugin|
54
+ rules = plugin.class.node_rules
55
+ rules.empty? ? nil : [plugin, rules]
56
+ end.freeze
57
+ freeze
58
+ end
59
+
60
+ def empty?
61
+ @entries.empty?
62
+ end
63
+
64
+ # Walk `root` once, dispatching every node to each matching
65
+ # `(plugin, rule)`. Returns an Array of {Result} in plugin
66
+ # (registry) order. `root` nil yields one empty Result per plugin.
67
+ def diagnostics_for_file(path:, scope:, root:)
68
+ return @entries.map { |plugin, _| Result.new(plugin, [], nil) } if root.nil?
69
+
70
+ states = @entries.map { |plugin, rules| State.new(plugin, rules, scope, root) }
71
+ walk(path, scope, root, states)
72
+ states.map(&:result)
73
+ end
74
+
75
+ private
76
+
77
+ def walk(path, scope, root, states)
78
+ Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
79
+ context = nil
80
+ states.each do |state|
81
+ next if state.failed?
82
+
83
+ matched = state.rules_for(node)
84
+ next if matched.empty?
85
+
86
+ # One frozen NodeContext per node, built lazily and shared
87
+ # across every plugin that matches this node.
88
+ context ||= NodeContext.new(ancestors)
89
+ state.run_rules(matched, node, scope, path, context)
90
+ end
91
+ end
92
+ end
93
+
94
+ # Mutable per-(plugin, file) walk state. Kept private to the walk —
95
+ # holds the diagnostics bucket, the file context, the per-concrete-
96
+ # class match memo, and the isolation flag.
97
+ class State
98
+ def initialize(plugin, rules, scope, root)
99
+ @plugin = plugin
100
+ @rules = rules
101
+ @diagnostics = []
102
+ @match_cache = {}.compare_by_identity
103
+ @error = nil
104
+ build_file_context(scope, root)
105
+ end
106
+
107
+ def failed?
108
+ !@error.nil?
109
+ end
110
+
111
+ # Rules whose `node_type` this concrete node satisfies, memoised
112
+ # by the node's class so the `is_a?` scan runs once per class.
113
+ # Preserves `is_a?` semantics when a rule's `node_type` is a
114
+ # superclass of the concrete node.
115
+ def rules_for(node)
116
+ @match_cache[node.class] ||=
117
+ @rules.select { |rule| node.is_a?(rule[:node_type]) }
118
+ end
119
+
120
+ def run_rules(matched, node, scope, path, context)
121
+ matched.each do |rule|
122
+ result = @plugin.instance_exec(node, scope, path, @file_context, context, &rule[:block])
123
+ @diagnostics.concat(Array(result))
124
+ end
125
+ rescue StandardError => e
126
+ @error = e
127
+ @diagnostics = []
128
+ end
129
+
130
+ def result
131
+ NodeRuleWalk::Result.new(@plugin, @diagnostics, @error)
132
+ end
133
+
134
+ private
135
+
136
+ def build_file_context(scope, root)
137
+ block = @plugin.class.node_file_context_block
138
+ @file_context = block ? @plugin.instance_exec(root, scope, &block) : nil
139
+ rescue StandardError => e
140
+ @error = e
141
+ @file_context = nil
142
+ end
143
+ end
144
+ private_constant :State
145
+ end
146
+ end
147
+ end
@@ -4,40 +4,224 @@ require_relative "blueprint"
4
4
 
5
5
  module Rigor
6
6
  module Plugin
7
- # Categorises a loaded plugin set by which per-call contribution
8
- # paths each plugin actually implements, so the engine's per-dispatch
9
- # `collect_plugin_contributions` can iterate only the relevant
10
- # plugins instead of calling into every plugin on every call site (a
11
- # top hotspot on plugin-heavy projects — GitLab's 11 plugins, of which
12
- # only 2 implement any per-call path). Built once per {Registry}; the
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` verbs, `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
+ # `verbs:` 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`, so this only
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 two paths within a plugin.
20
- attr_reader :for_method_dispatch, :for_statement
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
- @flow = plugins.select { |p| flow_overridden?(p) }.to_set
24
- @dynamic = plugins.reject { |p| p.class.dynamic_returns.empty? }.to_set
25
- @type_specifier = plugins.reject { |p| p.class.type_specifiers.empty? }.to_set
26
- @for_method_dispatch = plugins.select { |p| @flow.include?(p) || @dynamic.include?(p) }.freeze
27
- @for_statement = plugins.select { |p| @flow.include?(p) || @type_specifier.include?(p) }.freeze
37
+ compile_memberships(plugins)
38
+ compile_gates
39
+ @block_entries_by_verb = 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 `verbs` include `verb`,
92
+ # in (plugin registration, manifest declaration) order — the same
93
+ # first-match order the previous plugins × entries walk visited.
94
+ def block_entries_for(verb)
95
+ @block_entries_by_verb.fetch(verb, EMPTY_BLOCK_ENTRIES)
96
+ end
97
+
98
+ # True when `class_name` equals or inherits from any plugin's
99
+ # manifest-declared `owns_receivers:` entry. The union is compiled
100
+ # at build time (almost always empty → O(1) false) and per-class
101
+ # verdicts memoise per environment.
102
+ def owns_receiver?(class_name, environment)
103
+ return false if @owns_receivers.empty? || class_name.nil?
104
+
105
+ memo = (@owns_receiver_memo[environment] ||= {})
106
+ memo.fetch(class_name) do
107
+ memo[class_name] =
108
+ @owns_receivers.any? { |owner| class_matches_owner?(class_name, owner, environment) }
109
+ end
110
+ end
111
+
35
112
  private
36
113
 
37
- # A plugin contributes via the legacy `flow_contribution_for` slot
38
- # only when it overrides the no-op base implementation.
39
- def flow_overridden?(plugin)
40
- plugin.method(:flow_contribution_for).owner != Rigor::Plugin::Base
114
+ # The categorisation sets + the ordered per-collector subsets.
115
+ def compile_memberships(plugins)
116
+ plugins.each { |p| reject_legacy_flow_hook!(p) }
117
+ @dynamic = plugins.reject { |p| p.class.dynamic_returns.empty? }.to_set
118
+ @type_specifier = plugins.reject { |p| p.class.type_specifiers.empty? }.to_set
119
+ compile_collector_subsets(plugins)
120
+ end
121
+
122
+ def compile_collector_subsets(plugins)
123
+ @for_method_dispatch = plugins.select { |p| @dynamic.include?(p) }.freeze
124
+ @for_statement = plugins.select { |p| @type_specifier.include?(p) }.freeze
125
+ @for_file_diagnostics =
126
+ plugins.select { |p| file_diagnostics_overridden?(p) || !p.class.node_rules.empty? }.freeze
127
+ end
128
+
129
+ # The per-plugin and registry-global method-name gates.
130
+ def compile_gates
131
+ @dynamic_gates = build_name_gates(@dynamic) { |p| p.class.dynamic_returns }
132
+ @type_specifier_gates = build_name_gates(@type_specifier) { |p| p.class.type_specifiers }
133
+ @dynamic_global_gate = union_gate(@dynamic_gates)
134
+ @type_specifier_global_gate = union_gate(@type_specifier_gates)
135
+ end
136
+
137
+ # ADR-52 WD3 — the legacy ungated `flow_contribution_for` hook was
138
+ # deleted pre-1.0. A plugin still defining it would silently never
139
+ # be called, which is the worst failure mode for its author —
140
+ # surface a loud load-time error with the migration mapping
141
+ # instead. (The Base default is gone, so any definer wrote it
142
+ # themselves.)
143
+ def reject_legacy_flow_hook!(plugin)
144
+ return unless plugin.respond_to?(:flow_contribution_for)
145
+
146
+ raise ArgumentError,
147
+ "plugin #{(plugin.class.name || plugin.class).inspect} defines `flow_contribution_for`, " \
148
+ "which was removed (ADR-52). Declare the per-call return type via `dynamic_return` " \
149
+ "(receivers:/methods:/file_methods: gates, static or callable) and post-return narrowing " \
150
+ "facts via `type_specifier` — see the CHANGELOG migration note."
151
+ end
152
+
153
+ # Same `Method#owner` trick for the per-file diagnostics hook —
154
+ # the Base default returns `[]`, so a non-overriding plugin can be
155
+ # skipped without calling it.
156
+ def file_diagnostics_overridden?(plugin)
157
+ plugin.method(:diagnostics_for_file).owner != Rigor::Plugin::Base
158
+ end
159
+
160
+ # `plugin → Set[Symbol] | nil`. A frozen Set when EVERY rule of
161
+ # the plugin is `methods:`-gated (the union of those names); nil
162
+ # when any rule is ungated (the plugin must be consulted for every
163
+ # method name, exactly as before).
164
+ def build_name_gates(members)
165
+ gates = {}
166
+ members.each do |plugin|
167
+ rules = yield(plugin)
168
+ # A static method-name Array can be compiled into the gate. A
169
+ # run-time callable `methods:` (ADR-52 slice 4) is unknown until
170
+ # after `#prepare`, and a receiver-only rule has no `methods:` —
171
+ # either makes the plugin ungated-by-name (nil), consulted on
172
+ # every dispatch and filtered in the instance path.
173
+ gates[plugin] =
174
+ if rules.all? { |rule| rule[:methods].is_a?(Array) }
175
+ rules.flat_map { |rule| rule[:methods] }.to_set.freeze
176
+ end
177
+ end
178
+ gates.freeze
179
+ end
180
+
181
+ # The registry-wide union of per-plugin gates, or nil when any
182
+ # plugin is ungated (no global pruning possible). An empty
183
+ # plugin set unions to an empty frozen Set — the collectors'
184
+ # `relevant.empty?` early return fires before it is consulted.
185
+ def union_gate(gates)
186
+ return nil if gates.value?(nil)
187
+
188
+ gates.values.reduce(Set.new) { |acc, names| acc.merge(names) }.freeze
189
+ end
190
+
191
+ # `verb Symbol → [BlockAsMethod entries]`, insertion-ordered by
192
+ # (plugin, declaration). Verbs are Symbol-normalised by
193
+ # `Macro::BlockAsMethod#initialize`.
194
+ def build_block_entries(plugins)
195
+ table = {}
196
+ plugins.each do |plugin|
197
+ entries = manifest_for(plugin)&.block_as_methods || []
198
+ entries.each do |entry|
199
+ entry.verbs.each { |verb| (table[verb] ||= []) << entry }
200
+ end
201
+ end
202
+ table.each_value(&:freeze)
203
+ table.freeze
204
+ end
205
+
206
+ # Mirrors `MethodDispatcher`'s previous per-dispatch matching:
207
+ # exact-name fast path, then `Environment#class_ordering`,
208
+ # degrading to "no match" on any resolution failure.
209
+ def class_matches_owner?(class_name, owner, environment)
210
+ return true if class_name == owner
211
+ return false if environment.nil?
212
+
213
+ %i[equal subclass].include?(environment.class_ordering(class_name, owner))
214
+ rescue StandardError
215
+ false
216
+ end
217
+
218
+ # Manifest access tolerant of manifest-less plugin doubles in unit
219
+ # specs (a real loaded plugin always carries one — the loader
220
+ # validates it).
221
+ def manifest_for(plugin)
222
+ plugin.manifest
223
+ rescue StandardError
224
+ nil
41
225
  end
42
226
  end
43
227
 
@@ -78,9 +262,29 @@ module Rigor
78
262
  @load_errors = load_errors.dup.freeze
79
263
  @blueprints = blueprints.dup.freeze
80
264
  @contribution_index = ContributionIndex.new(@plugins)
265
+ # ADR-52 WD1 — aggregate queries the engine issues per def /
266
+ # per diagnostic candidate / per path are compiled once here
267
+ # (the registry is frozen, so the flat_map-on-every-call
268
+ # versions re-derived an invariant). `@contracts_by_path` is a
269
+ # mutable per-path memo inside the frozen registry — safe
270
+ # because the contract set and the glob semantics are fixed
271
+ # for the lifetime of the run.
272
+ @additional_initializers = @plugins.flat_map { |p| safe_manifest(p)&.additional_initializers || [] }.freeze
273
+ @open_receivers = @plugins.flat_map { |p| (safe_manifest(p)&.open_receivers || []).map(&:to_s) }.uniq.freeze
274
+ @open_receivers_set = @open_receivers.to_set.freeze
275
+ @protocol_contracts = @plugins.flat_map { |p| safe_protocol_contracts(p) }.freeze
276
+ @contracts_by_path = {}
277
+ # ADR-52 WD4 — the single engine-owned node-rule walk, compiled
278
+ # once per run from the node-rule plugin subset (registry order).
279
+ # The runner reuses it for every file; it builds fresh per-file
280
+ # state internally, so it is safe to freeze and share.
281
+ @node_rule_walk = NodeRuleWalk.new(@plugins)
81
282
  freeze
82
283
  end
83
284
 
285
+ # ADR-52 WD4 — the per-run node-rule walk (see {NodeRuleWalk}).
286
+ attr_reader :node_rule_walk
287
+
84
288
  # ADR-15 Phase 3 — build a fresh Registry from the supplied
85
289
  # blueprint set by replaying {Blueprint#materialize} per
86
290
  # entry against `services`. The returned registry carries
@@ -158,14 +362,15 @@ module Rigor
158
362
  # beyond its RBS-declared method surface. `open_receiver?`
159
363
  # is the membership predicate `Analysis::CheckRules` consults
160
364
  # to skip the `call.undefined-method` rule for such a class.
161
- def open_receivers
162
- plugins.flat_map { |plugin| plugin.manifest.open_receivers }
163
- end
365
+ # Compiled at construction (ADR-52 WD1) — the predicate runs per
366
+ # undefined-method candidate, so the previous per-call flat_map
367
+ # re-derived a frozen invariant.
368
+ attr_reader :open_receivers
164
369
 
165
370
  def open_receiver?(class_name)
166
371
  return false if class_name.nil?
167
372
 
168
- open_receivers.include?(class_name.to_s)
373
+ @open_receivers_set.include?(class_name.to_s)
169
374
  end
170
375
 
171
376
  # ADR-28 — flat, ordered list of every loaded plugin's
@@ -176,10 +381,10 @@ module Rigor
176
381
  # Consumed by `Inference::MethodParameterBinder` (the
177
382
  # parameter-type provision) and by contributing plugins'
178
383
  # `#diagnostics_for_file` hooks (the presence + return-type
179
- # check).
180
- def protocol_contracts
181
- plugins.flat_map(&:protocol_contracts)
182
- end
384
+ # check). Compiled at construction (ADR-52 WD1); a plugin's
385
+ # config-folding `#protocol_contracts` override is stable for the
386
+ # run because config is injected at construction.
387
+ attr_reader :protocol_contracts
183
388
 
184
389
  # ADR-38 — flat, ordered list of every loaded plugin's
185
390
  # manifest-declared `Rigor::Plugin::AdditionalInitializer`
@@ -187,9 +392,9 @@ module Rigor
187
392
  # read-before-write nil soundness gate: a `def` whose name an
188
393
  # entry covers, on a class that equals or inherits from the
189
394
  # entry's `receiver_constraint`, is treated like `initialize`.
190
- def additional_initializers
191
- plugins.flat_map { |plugin| plugin.manifest.additional_initializers }
192
- end
395
+ # Compiled at construction (ADR-52 WD1) — consulted per `def`
396
+ # node, ×2 sites in `ScopeIndexer`.
397
+ attr_reader :additional_initializers
193
398
 
194
399
  # ADR-28 — the subset of `protocol_contracts` whose
195
400
  # `path_glob` matches `path`. Contract globs are authored
@@ -201,11 +406,15 @@ module Rigor
201
406
  # suffix. `File::FNM_PATHNAME` keeps `*` from crossing `/`;
202
407
  # `File::FNM_EXTGLOB` enables `{a,b}` groups. Returns `[]` for
203
408
  # a nil path so the binder can call this unconditionally.
409
+ # Memoised per path (ADR-52 WD1) — consulted per `def` node by
410
+ # `MethodParameterBinder`, and the fnmatch sweep over every
411
+ # contract is pure in (contract set, path).
204
412
  def contracts_for_path(path)
205
413
  return [] if path.nil?
206
414
 
207
415
  path_s = path.to_s
208
- protocol_contracts.select { |contract| path_matches_glob?(contract.path_glob, path_s) }
416
+ @contracts_by_path[path_s] ||=
417
+ protocol_contracts.select { |contract| path_matches_glob?(contract.path_glob, path_s) }.freeze
209
418
  end
210
419
 
211
420
  # ADR-32 WD4 + WD5 — flat ordered list of
@@ -232,14 +441,33 @@ module Rigor
232
441
  FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
233
442
  private_constant :FNMATCH_FLAGS
234
443
 
235
- EMPTY = new.freeze
236
-
237
444
  private
238
445
 
239
446
  def path_matches_glob?(glob, path)
240
447
  File.fnmatch?(glob, path, FNMATCH_FLAGS) ||
241
448
  File.fnmatch?(File.join("**", glob), path, FNMATCH_FLAGS)
242
449
  end
450
+
451
+ # Construction-time manifest access tolerant of manifest-less
452
+ # plugin doubles in unit specs (a real loaded plugin always
453
+ # carries one — the loader validates it). Mirrors
454
+ # `ContributionIndex#manifest_for`.
455
+ def safe_manifest(plugin)
456
+ plugin.manifest
457
+ rescue StandardError
458
+ nil
459
+ end
460
+
461
+ def safe_protocol_contracts(plugin)
462
+ plugin.protocol_contracts || []
463
+ rescue StandardError
464
+ []
465
+ end
243
466
  end
467
+
468
+ # Assigned after the class body completes — `Registry.new` runs at
469
+ # assignment time and `#initialize` calls private helpers defined
470
+ # late in the body.
471
+ Registry::EMPTY = Registry.new.freeze
244
472
  end
245
473
  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)