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.
Files changed (125) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +159 -222
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +24 -1
  4. data/lib/rigor/analysis/check_rules/dead_assignment_collector.rb +25 -0
  5. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +29 -0
  6. data/lib/rigor/analysis/check_rules/main_pass_collector.rb +54 -0
  7. data/lib/rigor/analysis/check_rules/rule_walk.rb +213 -0
  8. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +24 -1
  9. data/lib/rigor/analysis/check_rules.rb +275 -44
  10. data/lib/rigor/analysis/diagnostic.rb +8 -0
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +581 -0
  12. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  13. data/lib/rigor/analysis/runner/project_pre_passes.rb +321 -0
  14. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  15. data/lib/rigor/analysis/runner.rb +207 -1200
  16. data/lib/rigor/analysis/worker_session.rb +60 -11
  17. data/lib/rigor/bleeding_edge.rb +123 -0
  18. data/lib/rigor/cache/descriptor.rb +86 -8
  19. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  20. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  21. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  22. data/lib/rigor/cache/store.rb +46 -13
  23. data/lib/rigor/cli/annotate_command.rb +100 -15
  24. data/lib/rigor/cli/check_command.rb +708 -0
  25. data/lib/rigor/cli/ci_detector.rb +94 -0
  26. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  27. data/lib/rigor/cli/plugins_command.rb +2 -4
  28. data/lib/rigor/cli/plugins_renderer.rb +0 -2
  29. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  30. data/lib/rigor/cli/show_bleedingedge_command.rb +114 -0
  31. data/lib/rigor/cli/trace_command.rb +143 -0
  32. data/lib/rigor/cli/trace_renderer.rb +310 -0
  33. data/lib/rigor/cli/triage_command.rb +6 -3
  34. data/lib/rigor/cli/triage_renderer.rb +15 -1
  35. data/lib/rigor/cli.rb +21 -612
  36. data/lib/rigor/configuration/severity_profile.rb +13 -1
  37. data/lib/rigor/configuration.rb +66 -7
  38. data/lib/rigor/environment/rbs_loader.rb +78 -68
  39. data/lib/rigor/environment.rb +1 -1
  40. data/lib/rigor/inference/acceptance.rb +10 -0
  41. data/lib/rigor/inference/body_fixpoint.rb +89 -0
  42. data/lib/rigor/inference/budget_trace.rb +29 -2
  43. data/lib/rigor/inference/expression_typer.rb +1080 -105
  44. data/lib/rigor/inference/flow_tracer.rb +180 -0
  45. data/lib/rigor/inference/macro_block_self_type.rb +11 -12
  46. data/lib/rigor/inference/method_dispatcher/array_to_h_folding.rb +60 -0
  47. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +54 -14
  48. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  49. data/lib/rigor/inference/method_dispatcher/reduce_folding.rb +281 -0
  50. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +71 -0
  51. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +148 -10
  52. data/lib/rigor/inference/method_dispatcher.rb +187 -55
  53. data/lib/rigor/inference/method_parameter_binder.rb +56 -2
  54. data/lib/rigor/inference/multi_target_binder.rb +46 -3
  55. data/lib/rigor/inference/mutation_widening.rb +142 -0
  56. data/lib/rigor/inference/narrowing.rb +330 -37
  57. data/lib/rigor/inference/scope_indexer.rb +770 -39
  58. data/lib/rigor/inference/statement_evaluator.rb +998 -68
  59. data/lib/rigor/inference/synthetic_method_scanner.rb +1 -1
  60. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  61. data/lib/rigor/plugin/base.rb +517 -120
  62. data/lib/rigor/plugin/macro/block_as_method.rb +22 -21
  63. data/lib/rigor/plugin/macro/nested_class_template.rb +9 -7
  64. data/lib/rigor/plugin/macro.rb +2 -3
  65. data/lib/rigor/plugin/manifest.rb +4 -24
  66. data/lib/rigor/plugin/node_rule_walk.rb +192 -0
  67. data/lib/rigor/plugin/registry.rb +264 -35
  68. data/lib/rigor/plugin.rb +1 -0
  69. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  70. data/lib/rigor/scope/discovery_index.rb +60 -0
  71. data/lib/rigor/scope.rb +199 -204
  72. data/lib/rigor/sig_gen/generator.rb +8 -0
  73. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  74. data/lib/rigor/source/literals.rb +14 -0
  75. data/lib/rigor/triage/catalogue.rb +4 -19
  76. data/lib/rigor/triage.rb +69 -1
  77. data/lib/rigor/type/combinator.rb +34 -0
  78. data/lib/rigor/version.rb +1 -1
  79. data/lib/rigor.rb +0 -1
  80. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +13 -29
  81. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +13 -32
  82. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  83. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +27 -90
  84. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +13 -30
  85. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  86. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +90 -51
  87. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  88. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +25 -29
  89. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  90. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  91. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +11 -40
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  93. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +1 -1
  94. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +10 -21
  95. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +21 -34
  96. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +11 -18
  97. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +0 -1
  98. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  99. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  100. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +37 -31
  101. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +3 -23
  102. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +8 -21
  103. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +1 -1
  104. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  105. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  106. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  107. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +108 -36
  108. data/sig/rigor/analysis/fact_store.rbs +3 -0
  109. data/sig/rigor/environment.rbs +0 -2
  110. data/sig/rigor/inference/builtins/method_catalog.rbs +1 -1
  111. data/sig/rigor/inference.rbs +5 -0
  112. data/sig/rigor/plugin/base.rbs +6 -4
  113. data/sig/rigor/plugin/manifest.rbs +1 -2
  114. data/sig/rigor/scope.rbs +50 -29
  115. data/sig/rigor/source.rbs +1 -0
  116. data/sig/rigor/type.rbs +1 -0
  117. data/sig/rigor.rbs +1 -1
  118. data/skills/rigor-baseline-reduce/references/01-classify.md +27 -0
  119. data/skills/rigor-ci-setup/SKILL.md +319 -0
  120. data/skills/rigor-plugin-author/SKILL.md +6 -4
  121. data/skills/rigor-plugin-author/references/02-walker-and-types.md +22 -17
  122. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +18 -1
  123. metadata +21 -3
  124. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
  125. 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
- # 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` 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`, 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_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
- # 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
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
- def open_receivers
162
- plugins.flat_map { |plugin| plugin.manifest.open_receivers }
163
- end
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
- open_receivers.include?(class_name.to_s)
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
- def protocol_contracts
181
- plugins.flat_map(&:protocol_contracts)
182
- end
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 additional_initializers
191
- plugins.flat_map { |plugin| plugin.manifest.additional_initializers }
192
- end
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
- protocol_contracts.select { |contract| path_matches_glob?(contract.path_glob, path_s) }
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