rigortype 0.1.16 → 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 (180) 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/self_closedness_scanner.rb +100 -0
  6. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +226 -0
  7. data/lib/rigor/analysis/check_rules.rb +180 -73
  8. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  9. data/lib/rigor/analysis/diagnostic.rb +18 -0
  10. data/lib/rigor/analysis/incremental.rb +162 -0
  11. data/lib/rigor/analysis/incremental_session.rb +337 -0
  12. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  13. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  14. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  15. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  16. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  17. data/lib/rigor/analysis/runner.rb +477 -1110
  18. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  19. data/lib/rigor/analysis/worker_session.rb +47 -8
  20. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  21. data/lib/rigor/cache/descriptor.rb +50 -49
  22. data/lib/rigor/cache/incremental_snapshot.rb +153 -0
  23. data/lib/rigor/cache/rbs_cache_producer.rb +34 -0
  24. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  25. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  26. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  27. data/lib/rigor/cache/rbs_environment.rb +2 -8
  28. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  29. data/lib/rigor/cache/store.rb +145 -14
  30. data/lib/rigor/cli/annotate_command.rb +2 -7
  31. data/lib/rigor/cli/baseline_command.rb +2 -7
  32. data/lib/rigor/cli/check_command.rb +705 -0
  33. data/lib/rigor/cli/ci_detector.rb +94 -0
  34. data/lib/rigor/cli/command.rb +47 -0
  35. data/lib/rigor/cli/coverage_command.rb +3 -23
  36. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  37. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  38. data/lib/rigor/cli/diff_command.rb +3 -7
  39. data/lib/rigor/cli/explain_command.rb +2 -7
  40. data/lib/rigor/cli/lsp_command.rb +3 -7
  41. data/lib/rigor/cli/mcp_command.rb +3 -7
  42. data/lib/rigor/cli/options.rb +57 -0
  43. data/lib/rigor/cli/plugin_command.rb +3 -7
  44. data/lib/rigor/cli/plugins_command.rb +2 -7
  45. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  46. data/lib/rigor/cli/renderable.rb +26 -0
  47. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  48. data/lib/rigor/cli/skill_command.rb +3 -7
  49. data/lib/rigor/cli/trace_command.rb +143 -0
  50. data/lib/rigor/cli/trace_renderer.rb +310 -0
  51. data/lib/rigor/cli/triage_command.rb +2 -7
  52. data/lib/rigor/cli/type_of_command.rb +5 -38
  53. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  54. data/lib/rigor/cli/type_scan_command.rb +3 -23
  55. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  56. data/lib/rigor/cli.rb +15 -532
  57. data/lib/rigor/configuration/dependencies.rb +18 -1
  58. data/lib/rigor/configuration/severity_profile.rb +22 -3
  59. data/lib/rigor/configuration.rb +16 -3
  60. data/lib/rigor/environment/rbs_loader.rb +129 -71
  61. data/lib/rigor/environment.rb +1 -1
  62. data/lib/rigor/inference/acceptance.rb +10 -0
  63. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  64. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  65. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  69. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  70. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  71. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  72. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  73. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  74. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  75. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  76. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  77. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  78. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  79. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  80. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  81. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  82. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  83. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  84. data/lib/rigor/inference/expression_typer.rb +149 -63
  85. data/lib/rigor/inference/flow_tracer.rb +180 -0
  86. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  87. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  88. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  89. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  90. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  91. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  92. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  93. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  94. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  95. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  96. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  97. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  98. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  99. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
  100. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  101. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  102. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  103. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  104. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  105. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  106. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  107. data/lib/rigor/inference/method_dispatcher.rb +185 -84
  108. data/lib/rigor/inference/narrowing.rb +262 -5
  109. data/lib/rigor/inference/scope_indexer.rb +208 -21
  110. data/lib/rigor/inference/statement_evaluator.rb +110 -48
  111. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  112. data/lib/rigor/language_server/completion_provider.rb +4 -4
  113. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  114. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  115. data/lib/rigor/language_server/hover_provider.rb +4 -4
  116. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  117. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  118. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  119. data/lib/rigor/plugin/base.rb +302 -45
  120. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  121. data/lib/rigor/plugin/registry.rb +281 -15
  122. data/lib/rigor/plugin.rb +1 -0
  123. data/lib/rigor/rbs_extended/conformance_checker.rb +293 -0
  124. data/lib/rigor/rbs_extended.rb +39 -0
  125. data/lib/rigor/scope/discovery_index.rb +58 -0
  126. data/lib/rigor/scope.rb +150 -167
  127. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  128. data/lib/rigor/source/literals.rb +14 -0
  129. data/lib/rigor/type/acceptance_router.rb +19 -0
  130. data/lib/rigor/type/accepts_result.rb +3 -10
  131. data/lib/rigor/type/app.rb +3 -7
  132. data/lib/rigor/type/bot.rb +2 -3
  133. data/lib/rigor/type/bound_method.rb +5 -12
  134. data/lib/rigor/type/combinator.rb +22 -0
  135. data/lib/rigor/type/constant.rb +2 -3
  136. data/lib/rigor/type/data_class.rb +80 -0
  137. data/lib/rigor/type/data_instance.rb +100 -0
  138. data/lib/rigor/type/difference.rb +5 -10
  139. data/lib/rigor/type/dynamic.rb +5 -10
  140. data/lib/rigor/type/hash_shape.rb +5 -15
  141. data/lib/rigor/type/integer_range.rb +5 -10
  142. data/lib/rigor/type/intersection.rb +5 -10
  143. data/lib/rigor/type/nominal.rb +5 -10
  144. data/lib/rigor/type/refined.rb +5 -10
  145. data/lib/rigor/type/singleton.rb +5 -10
  146. data/lib/rigor/type/top.rb +2 -3
  147. data/lib/rigor/type/tuple.rb +5 -10
  148. data/lib/rigor/type/union.rb +5 -10
  149. data/lib/rigor/type.rb +2 -0
  150. data/lib/rigor/value_semantics.rb +77 -0
  151. data/lib/rigor/version.rb +1 -1
  152. data/lib/rigor.rb +1 -1
  153. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  154. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  156. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  157. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  158. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  159. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  160. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  161. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  163. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  164. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  166. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  167. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  168. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  169. data/sig/rigor/cache.rbs +19 -0
  170. data/sig/rigor/environment.rbs +0 -2
  171. data/sig/rigor/inference.rbs +27 -0
  172. data/sig/rigor/plugin/base.rbs +1 -2
  173. data/sig/rigor/rbs_extended.rbs +2 -0
  174. data/sig/rigor/scope.rbs +42 -25
  175. data/sig/rigor/source.rbs +1 -0
  176. data/sig/rigor/type.rbs +58 -1
  177. data/sig/rigor.rbs +6 -1
  178. data/skills/rigor-ci-setup/SKILL.md +319 -0
  179. metadata +36 -2
  180. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -79
@@ -4,6 +4,227 @@ require_relative "blueprint"
4
4
 
5
5
  module Rigor
6
6
  module Plugin
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
23
+ # receiver-class ancestry match for `dynamic_return` still happens
24
+ # per-dispatch inside `Plugin::Base#dynamic_return_type`.
25
+ class ContributionIndex
26
+ # Ordered (registry-order) subsets relevant to each collector, and
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
35
+
36
+ def initialize(plugins)
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
45
+ freeze
46
+ end
47
+
48
+ def dynamic?(plugin) = @dynamic.include?(plugin)
49
+ def type_specifier?(plugin) = @type_specifier.include?(plugin)
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
+
112
+ private
113
+
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
225
+ end
226
+ end
227
+
7
228
  # Read-side query API over the plugins loaded for a single
8
229
  # `Analysis::Runner.run`. Constructed by
9
230
  # {Rigor::Plugin::Loader.load} and exposed downstream so the
@@ -24,7 +245,7 @@ module Rigor
24
245
  # {.materialize} per-Ractor; the live `plugins` carriage on
25
246
  # the coordinator registry stays unchanged.
26
247
  class Registry
27
- attr_reader :plugins, :load_errors, :blueprints
248
+ attr_reader :plugins, :load_errors, :blueprints, :contribution_index
28
249
 
29
250
  # @param plugins [Array<Rigor::Plugin::Base>] instantiated
30
251
  # plugin instances in deterministic order.
@@ -40,9 +261,30 @@ module Rigor
40
261
  @plugins = plugins.dup.freeze
41
262
  @load_errors = load_errors.dup.freeze
42
263
  @blueprints = blueprints.dup.freeze
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)
43
282
  freeze
44
283
  end
45
284
 
285
+ # ADR-52 WD4 — the per-run node-rule walk (see {NodeRuleWalk}).
286
+ attr_reader :node_rule_walk
287
+
46
288
  # ADR-15 Phase 3 — build a fresh Registry from the supplied
47
289
  # blueprint set by replaying {Blueprint#materialize} per
48
290
  # entry against `services`. The returned registry carries
@@ -120,14 +362,15 @@ module Rigor
120
362
  # beyond its RBS-declared method surface. `open_receiver?`
121
363
  # is the membership predicate `Analysis::CheckRules` consults
122
364
  # to skip the `call.undefined-method` rule for such a class.
123
- def open_receivers
124
- plugins.flat_map { |plugin| plugin.manifest.open_receivers }
125
- 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
126
369
 
127
370
  def open_receiver?(class_name)
128
371
  return false if class_name.nil?
129
372
 
130
- open_receivers.include?(class_name.to_s)
373
+ @open_receivers_set.include?(class_name.to_s)
131
374
  end
132
375
 
133
376
  # ADR-28 — flat, ordered list of every loaded plugin's
@@ -138,10 +381,10 @@ module Rigor
138
381
  # Consumed by `Inference::MethodParameterBinder` (the
139
382
  # parameter-type provision) and by contributing plugins'
140
383
  # `#diagnostics_for_file` hooks (the presence + return-type
141
- # check).
142
- def protocol_contracts
143
- plugins.flat_map(&:protocol_contracts)
144
- 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
145
388
 
146
389
  # ADR-38 — flat, ordered list of every loaded plugin's
147
390
  # manifest-declared `Rigor::Plugin::AdditionalInitializer`
@@ -149,9 +392,9 @@ module Rigor
149
392
  # read-before-write nil soundness gate: a `def` whose name an
150
393
  # entry covers, on a class that equals or inherits from the
151
394
  # entry's `receiver_constraint`, is treated like `initialize`.
152
- def additional_initializers
153
- plugins.flat_map { |plugin| plugin.manifest.additional_initializers }
154
- end
395
+ # Compiled at construction (ADR-52 WD1) — consulted per `def`
396
+ # node, ×2 sites in `ScopeIndexer`.
397
+ attr_reader :additional_initializers
155
398
 
156
399
  # ADR-28 — the subset of `protocol_contracts` whose
157
400
  # `path_glob` matches `path`. Contract globs are authored
@@ -163,11 +406,15 @@ module Rigor
163
406
  # suffix. `File::FNM_PATHNAME` keeps `*` from crossing `/`;
164
407
  # `File::FNM_EXTGLOB` enables `{a,b}` groups. Returns `[]` for
165
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).
166
412
  def contracts_for_path(path)
167
413
  return [] if path.nil?
168
414
 
169
415
  path_s = path.to_s
170
- 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
171
418
  end
172
419
 
173
420
  # ADR-32 WD4 + WD5 — flat ordered list of
@@ -194,14 +441,33 @@ module Rigor
194
441
  FNMATCH_FLAGS = File::FNM_PATHNAME | File::FNM_EXTGLOB
195
442
  private_constant :FNMATCH_FLAGS
196
443
 
197
- EMPTY = new.freeze
198
-
199
444
  private
200
445
 
201
446
  def path_matches_glob?(glob, path)
202
447
  File.fnmatch?(glob, path, FNMATCH_FLAGS) ||
203
448
  File.fnmatch?(File.join("**", glob), path, FNMATCH_FLAGS)
204
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
205
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
206
472
  end
207
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"
@@ -0,0 +1,293 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../rbs_extended"
4
+ require_relative "../inference/rbs_type_translator"
5
+
6
+ module Rigor
7
+ module RbsExtended
8
+ # Verifies every `rigor:v1:conforms-to <Interface>` class- /
9
+ # module-level directive in the loaded RBS environment (spec:
10
+ # `docs/type-specification/rbs-extended.md` § "Explicit
11
+ # conformance directive"). For each annotated class, the
12
+ # directive asserts the class satisfies the named structural
13
+ # interface as a *checked design assertion* — independent of
14
+ # whether any current call site exercises that requirement.
15
+ # Multiple directives on one class combine as an intersection:
16
+ # each interface is checked independently.
17
+ #
18
+ # ## Two checked tiers
19
+ #
20
+ # 1. **Presence** (FP-free): an interface method the class provably does
21
+ # NOT provide anywhere in its RBS-resolved method set (own, inherited,
22
+ # included) is a definitive non-conformance.
23
+ # 2. **Signature compatibility** (covariant return / contravariant
24
+ # params): for a method the class DOES provide, the provided RBS
25
+ # signature must be a behavioural subtype of the interface's required
26
+ # one. Both sides are *authored* RBS (the class in `signature_paths:`
27
+ # RBS, the interface in a loaded `sig`/library), so this is the same
28
+ # FP-safe both-sides-authored construction as the
29
+ # [ADR-35](../../../docs/adr/35-override-signature-compatibility.md)
30
+ # `def.override-*` rules — not the inferred-signature comparison whose
31
+ # FP risk justified deferring it. Conservative: single-method-type
32
+ # (non-overloaded) signatures only, `Dynamic[Top]` positions skipped,
33
+ # fires only on a proven `accepts(...).no?` violation.
34
+ #
35
+ # ## Output records (all drained by {Rigor::Analysis::Runner})
36
+ #
37
+ # - `Unsatisfied` — the class is missing one or more required interface
38
+ # methods. Surfaces as `rbs_extended.unsatisfied-conformance`.
39
+ # - `IncompatibleSignature` — a provided method's signature violates the
40
+ # interface contract (return widened, or a parameter narrowed). Same
41
+ # rule, signature-specific message.
42
+ # - `UnresolvedInterface` — the named interface is not loaded (a typo, or
43
+ # the defining library / `sig` set is not on the RBS load path).
44
+ # Surfaces as `dynamic.rbs-extended.unresolved` `:info`, the fail-soft
45
+ # channel the other directive parsers use, so a bad name never silently
46
+ # disables the author's assertion.
47
+ #
48
+ # Fail-soft throughout: a class whose own definition cannot be built (RBS
49
+ # error) is skipped rather than reported.
50
+ module ConformanceChecker
51
+ Unsatisfied = Data.define(:class_name, :interface_name, :missing_methods, :location)
52
+ IncompatibleSignature = Data.define(:class_name, :interface_name, :method_name, :detail, :location)
53
+ UnresolvedInterface = Data.define(:class_name, :interface_name, :location)
54
+
55
+ module_function
56
+
57
+ # Scans `rbs_loader` for `conforms-to` directives and returns the
58
+ # failure / unresolved records in source order. Returns an empty array
59
+ # when no directive is present, the loader is nil, or the env failed to
60
+ # build (the loader's iterators are themselves fail-soft).
61
+ def scan(rbs_loader)
62
+ return [] if rbs_loader.nil?
63
+
64
+ results = []
65
+ rbs_loader.each_class_decl_annotation_with_name do |class_name, string, location|
66
+ interface_name = RbsExtended.parse_conforms_to_annotation(string)
67
+ next if interface_name.nil?
68
+
69
+ results.concat(check_one(rbs_loader, class_name, interface_name, location))
70
+ end
71
+ results
72
+ end
73
+
74
+ # @return [Array] zero or more records for one (class, interface) pair.
75
+ def check_one(rbs_loader, class_name, interface_name, location)
76
+ interface_def = resolve_interface(rbs_loader, class_name, interface_name)
77
+ if interface_def.nil?
78
+ return [UnresolvedInterface.new(
79
+ class_name: normalize(class_name), interface_name: interface_name, location: location
80
+ )]
81
+ end
82
+
83
+ class_def = rbs_loader.instance_definition(class_name)
84
+ return [] if class_def.nil? # fail-soft: cannot prove non-conformance
85
+
86
+ required = interface_def.methods
87
+ provided = class_def.methods
88
+ records = []
89
+ collect_missing(records, class_name, interface_name, required, provided, location)
90
+ collect_incompatible(records, class_name, interface_name, required, provided, location)
91
+ records
92
+ end
93
+
94
+ def collect_missing(records, class_name, interface_name, required, provided, location)
95
+ missing = required.keys - provided.keys
96
+ return if missing.empty?
97
+
98
+ records << Unsatisfied.new(
99
+ class_name: normalize(class_name), interface_name: interface_name,
100
+ missing_methods: missing, location: location
101
+ )
102
+ end
103
+
104
+ def collect_incompatible(records, class_name, interface_name, required, provided, location)
105
+ (required.keys & provided.keys).each do |method_name|
106
+ detail = signature_mismatch(required[method_name], provided[method_name])
107
+ next if detail.nil?
108
+
109
+ records << IncompatibleSignature.new(
110
+ class_name: normalize(class_name), interface_name: interface_name,
111
+ method_name: method_name, detail: detail, location: location
112
+ )
113
+ end
114
+ end
115
+
116
+ # Returns a human-readable mismatch detail when `provided` is NOT a
117
+ # behavioural subtype of the `required` (interface) signature, else
118
+ # nil. Mirrors the ADR-35 override checks: covariant return,
119
+ # contravariant params, single method type only, `Dynamic[Top]`
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).
124
+ def signature_mismatch(required_method, provided_method)
125
+ return nil unless required_method.method_types.size == 1
126
+ return nil unless provided_method.method_types.size == 1
127
+
128
+ return_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)
132
+ end
133
+
134
+ def return_detail(required_method, provided_method)
135
+ req = translate(required_method.method_types.first.type.return_type)
136
+ prov = translate(provided_method.method_types.first.type.return_type)
137
+ return nil if req.nil? || prov.nil?
138
+ return nil if dynamic_top?(req) || dynamic_top?(prov)
139
+ return nil unless req.accepts(prov).no?
140
+
141
+ "return type #{prov.describe(:short)} is not a subtype of the required #{req.describe(:short)}"
142
+ end
143
+
144
+ def param_detail(required_method, provided_method)
145
+ req = positional_param_types(required_method)
146
+ prov = positional_param_types(provided_method)
147
+ return nil if req.nil? || prov.nil?
148
+
149
+ [req.size, prov.size].min.times do |i|
150
+ rp = req[i]
151
+ pp = prov[i]
152
+ next if rp.nil? || pp.nil? || dynamic_top?(rp) || dynamic_top?(pp)
153
+ next unless pp.accepts(rp).no?
154
+
155
+ return "parameter #{i + 1} type #{pp.describe(:short)} does not accept the " \
156
+ "required #{rp.describe(:short)}"
157
+ end
158
+ nil
159
+ end
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
+
241
+ def positional_param_types(method_def)
242
+ func = method_def.method_types.first.type
243
+ return nil unless func.respond_to?(:required_positionals)
244
+
245
+ (func.required_positionals + func.optional_positionals).map { |param| translate(param.type) }
246
+ end
247
+
248
+ # Resolves the (possibly namespace-relative) `interface_name` against
249
+ # the declaring class's namespace chain, mirroring Ruby constant
250
+ # lookup: `conforms-to _Foo` inside `Bar::Baz` tries `Bar::Baz::_Foo`,
251
+ # `Bar::_Foo`, then top-level `_Foo` (longest prefix first). Returns
252
+ # the first interface definition that resolves, or nil. (A leading
253
+ # `::` is already stripped by the parser, so an intended-absolute name
254
+ # still resolves via the trailing top-level candidate.)
255
+ def resolve_interface(rbs_loader, class_name, interface_name)
256
+ candidate_interface_names(class_name, interface_name).each do |candidate|
257
+ defn = rbs_loader.interface_definition(candidate)
258
+ return defn if defn
259
+ end
260
+ nil
261
+ end
262
+
263
+ def candidate_interface_names(class_name, interface_name)
264
+ prefixes = namespace_prefixes(class_name)
265
+ prefixes.map { |prefix| "#{prefix}::#{interface_name}" } + [interface_name]
266
+ end
267
+
268
+ # Namespace prefixes of a qualified name, longest first:
269
+ # `"Bar::Baz"` → `["Bar::Baz", "Bar"]`. Covers both the
270
+ # `class Bar::Baz` and the `module Bar; class Baz` nesting shapes
271
+ # (a superset of `Module.nesting`, which the directive's resolved
272
+ # class name does not record).
273
+ def namespace_prefixes(class_name)
274
+ parts = normalize(class_name).split("::")
275
+ (1..parts.size).to_a.reverse.map { |count| parts.first(count).join("::") }
276
+ end
277
+
278
+ def translate(rbs_type)
279
+ Inference::RbsTypeTranslator.translate(rbs_type, self_type: nil, instance_type: nil, type_vars: {})
280
+ rescue StandardError
281
+ nil
282
+ end
283
+
284
+ def dynamic_top?(type)
285
+ type.is_a?(Type::Dynamic) || (type.respond_to?(:top?) && type.top?.yes?)
286
+ end
287
+
288
+ def normalize(class_name)
289
+ class_name.to_s.sub(/\A::/, "")
290
+ end
291
+ end
292
+ end
293
+ end