rigortype 0.3.0 → 0.3.2

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 (145) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/builtins/ruby_core/array.yml +416 -392
  4. data/data/builtins/ruby_core/file.yml +42 -42
  5. data/data/builtins/ruby_core/hash.yml +302 -302
  6. data/data/builtins/ruby_core/io.yml +191 -191
  7. data/data/builtins/ruby_core/numeric.yml +321 -366
  8. data/data/builtins/ruby_core/proc.yml +124 -124
  9. data/data/builtins/ruby_core/range.yml +21 -21
  10. data/data/builtins/ruby_core/rational.yml +39 -39
  11. data/data/builtins/ruby_core/re.yml +65 -65
  12. data/data/builtins/ruby_core/set.yml +106 -106
  13. data/data/builtins/ruby_core/struct.yml +14 -14
  14. data/data/core_overlay/pathname.rbs +5 -0
  15. data/data/core_overlay/resolv.rbs +31 -0
  16. data/data/core_overlay/string_scanner.rbs +11 -5
  17. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  18. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  19. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  20. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  21. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  22. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  23. data/docs/handbook/01-getting-started.md +22 -34
  24. data/docs/handbook/06-classes.md +1 -1
  25. data/docs/handbook/07-rbs-and-extended.md +76 -101
  26. data/docs/handbook/08-understanding-errors.md +114 -247
  27. data/docs/handbook/09-plugins.md +54 -144
  28. data/docs/handbook/README.md +5 -3
  29. data/docs/handbook/appendix-liskov.md +4 -2
  30. data/docs/handbook/appendix-phpstan.md +14 -7
  31. data/docs/handbook/appendix-steep.md +4 -2
  32. data/docs/handbook/appendix-type-theory.md +3 -1
  33. data/docs/manual/02-cli-reference.md +103 -11
  34. data/docs/manual/03-configuration.md +16 -1
  35. data/docs/manual/04-diagnostics.md +36 -4
  36. data/docs/manual/06-baseline.md +35 -1
  37. data/docs/manual/08-skills.md +6 -1
  38. data/docs/manual/09-editor-integration.md +3 -2
  39. data/docs/manual/12-caching.md +17 -5
  40. data/docs/manual/15-type-protection-coverage.md +122 -1
  41. data/docs/manual/plugins/rigor-actioncable.md +32 -0
  42. data/docs/manual/plugins/rigor-devise.md +4 -2
  43. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  44. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  45. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
  46. data/lib/rigor/analysis/check_rules.rb +79 -19
  47. data/lib/rigor/analysis/incremental_session.rb +156 -11
  48. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  49. data/lib/rigor/analysis/run_cache_key.rb +27 -1
  50. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  51. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  52. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  53. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  54. data/lib/rigor/analysis/runner.rb +80 -8
  55. data/lib/rigor/bleeding_edge.rb +132 -7
  56. data/lib/rigor/cache/descriptor.rb +6 -1
  57. data/lib/rigor/cache/engine_source.rb +162 -0
  58. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  59. data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
  60. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
  61. data/lib/rigor/cache/store.rb +99 -24
  62. data/lib/rigor/cli/check_command.rb +61 -34
  63. data/lib/rigor/cli/check_invocation.rb +84 -0
  64. data/lib/rigor/cli/coverage_command.rb +15 -2
  65. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  66. data/lib/rigor/cli/doctor_command.rb +6 -8
  67. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  68. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  69. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  70. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  71. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  72. data/lib/rigor/cli/protection_renderer.rb +13 -0
  73. data/lib/rigor/cli/protection_report.rb +11 -3
  74. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  75. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  76. data/lib/rigor/cli/skill_command.rb +21 -1
  77. data/lib/rigor/cli/skill_deep_probe.rb +172 -0
  78. data/lib/rigor/cli/skill_describe.rb +75 -9
  79. data/lib/rigor/configuration.rb +45 -6
  80. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  81. data/lib/rigor/environment/default_libraries.rb +5 -4
  82. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  83. data/lib/rigor/environment/rbs_loader.rb +434 -75
  84. data/lib/rigor/environment.rb +38 -14
  85. data/lib/rigor/inference/expression_typer.rb +15 -0
  86. data/lib/rigor/inference/fork_map.rb +9 -0
  87. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  88. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  89. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  90. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
  91. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  92. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  93. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  94. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  95. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  96. data/lib/rigor/inference/mutation_widening.rb +30 -25
  97. data/lib/rigor/inference/narrowing.rb +3 -1
  98. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  99. data/lib/rigor/inference/protection_scanner.rb +18 -2
  100. data/lib/rigor/inference/receiver_alias.rb +57 -0
  101. data/lib/rigor/inference/scope_indexer.rb +78 -27
  102. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  103. data/lib/rigor/language_server/buffer_resolution.rb +6 -3
  104. data/lib/rigor/language_server/buffer_table.rb +68 -6
  105. data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
  106. data/lib/rigor/language_server/incremental_sync.rb +159 -0
  107. data/lib/rigor/language_server/project_context.rb +45 -0
  108. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  109. data/lib/rigor/language_server/server.rb +38 -9
  110. data/lib/rigor/language_server.rb +2 -0
  111. data/lib/rigor/plugin/base.rb +29 -2
  112. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  113. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  114. data/lib/rigor/protection/dependency_closure.rb +59 -0
  115. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  116. data/lib/rigor/protection/discovery_seed.rb +116 -0
  117. data/lib/rigor/protection/kill_signature.rb +31 -0
  118. data/lib/rigor/protection/mutation_cache.rb +355 -0
  119. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  120. data/lib/rigor/protection/mutator.rb +26 -4
  121. data/lib/rigor/runtime/jit.rb +63 -1
  122. data/lib/rigor/scope/discovery_index.rb +9 -0
  123. data/lib/rigor/scope.rb +52 -8
  124. data/lib/rigor/sig_gen/generator.rb +130 -31
  125. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  126. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  127. data/lib/rigor/sig_gen/renderer.rb +20 -1
  128. data/lib/rigor/sig_gen/write_result.rb +6 -4
  129. data/lib/rigor/sig_gen/writer.rb +195 -47
  130. data/lib/rigor/sig_gen.rb +1 -0
  131. data/lib/rigor/version.rb +1 -1
  132. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
  133. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  134. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  135. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  136. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  138. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  139. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  140. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  141. data/sig/rigor/cache.rbs +6 -0
  142. data/sig/rigor/inference/void_origin.rbs +21 -0
  143. data/sig/rigor/plugin/base.rbs +4 -3
  144. data/sig/rigor/scope.rbs +8 -0
  145. metadata +23 -3
@@ -67,6 +67,84 @@ module Rigor
67
67
  end
68
68
  private_class_method :scan_file
69
69
 
70
+ # Per-row `dry-schema.unknown-type` diagnostics (ceiling slice, issue #137). Walks an
71
+ # ALREADY-PARSED file's root node — the engine parses every analysed file once, so this reuses
72
+ # that AST rather than re-reading and re-parsing the file — collecting `{node:, key:, symbol:}`
73
+ # issues for a `required(:key).<verb>(:sym)` / `optional(:key).<verb>(:sym)` row whose
74
+ # type-bearing predicate (`filled` / `value` / `maybe` / `each`) receives a literal Symbol argument
75
+ # OUTSIDE `CANONICAL_TYPES`. Recurses into `each do ... end` nested rows (mirrors
76
+ # {#each_block_type_info}) so a nested row's bad symbol is caught too.
77
+ #
78
+ # A Constant argument (`value(Types::Email)`) is never flagged: an unresolved alias already has a
79
+ # silent, deliberate fallback (no `:dry_type_aliases` fact, or a name the fact doesn't know) per
80
+ # the existing slice-1 "drops constant-type references..." behaviour, and this diagnostic firing
81
+ # on it would misfire on every entirely-correct `value(Types::Email)` row in a project that simply
82
+ # doesn't have `rigor-dry-types` loaded.
83
+ #
84
+ # `dry-schema.unknown-predicate` (the OTHER ceiling diagnostic the README named) is deliberately
85
+ # NOT implemented: distinguishing a genuinely-unrecognised predicate NAME from one of dry-schema's
86
+ # many legitimate fine-grained predicates (`size?`, `gt?`, `format?`, `included_in?`, ...) that
87
+ # this scanner simply doesn't model would need a complete predicate registry this plugin doesn't
88
+ # have, and a `required(:key)` row with NO type-bearing predicate at all is itself entirely
89
+ # legitimate dry-schema (a presence-only check). Guessing here risks flagging correct code — the
90
+ # AGENTS.md "false positives outrank worst-case reading" call, applied by declining.
91
+ def unknown_type_issues(root)
92
+ issues = []
93
+ walk_for_unknown_type(root, issues)
94
+ issues
95
+ end
96
+
97
+ def walk_for_unknown_type(node, issues)
98
+ return if node.nil?
99
+
100
+ if node.is_a?(Prism::CallNode) && schema_entry_call?(node) && node.block
101
+ collect_row_issues(node.block, issues)
102
+ end
103
+ node.compact_child_nodes.each { |child| walk_for_unknown_type(child, issues) }
104
+ end
105
+ private_class_method :walk_for_unknown_type
106
+
107
+ def collect_row_issues(block_node, issues)
108
+ body = block_node.body
109
+ return if body.nil?
110
+
111
+ children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
112
+ children.each { |child| visit_chain_for_issues(child, issues) }
113
+ end
114
+ private_class_method :collect_row_issues
115
+
116
+ def visit_chain_for_issues(node, issues)
117
+ return unless node.is_a?(Prism::CallNode)
118
+
119
+ key, = extract_key_and_kind(node)
120
+ return if key.nil?
121
+
122
+ current = node
123
+ while current.is_a?(Prism::CallNode)
124
+ if current.name == :each && current.block
125
+ collect_row_issues(nested_block_body(current.block), issues)
126
+ return
127
+ end
128
+ if TYPE_BEARING_PREDICATES.include?(current.name)
129
+ record_unknown_type_issue(current, key, issues)
130
+ return
131
+ end
132
+ current = current.receiver
133
+ end
134
+ end
135
+ private_class_method :visit_chain_for_issues
136
+
137
+ def record_unknown_type_issue(call_node, key, issues)
138
+ arg = call_node.arguments&.arguments&.first
139
+ return unless arg.is_a?(Prism::SymbolNode)
140
+
141
+ symbol = arg.unescaped.to_sym
142
+ return if CANONICAL_TYPES.key?(symbol)
143
+
144
+ issues << { node: call_node, key: key, symbol: symbol }
145
+ end
146
+ private_class_method :record_unknown_type_issue
147
+
70
148
  # Walks the AST collecting `<Const> = Dry::Schema.X { ... }` assignments at any nesting level.
71
149
  # Tracks the enclosing constant chain so a class-level `class Foo; SCHEMA = Dry::Schema.Params
72
150
  # { ... }; end` registers as `"Foo::SCHEMA"`.
@@ -102,7 +180,7 @@ module Rigor
102
180
  return {} unless rhs.is_a?(Prism::CallNode) && rhs.block
103
181
 
104
182
  schema_const = (qualified_prefix + [node.name.to_s]).join("::")
105
- shape = collect_schema_shape(rhs.block, type_aliases)
183
+ shape = collect_schema_shape(rhs.block, type_aliases, nested: false)
106
184
  { schema_const => shape }
107
185
  end
108
186
  private_class_method :collect_schema_assignment
@@ -120,29 +198,51 @@ module Rigor
120
198
  end
121
199
  private_class_method :schema_entry_call?
122
200
 
123
- def collect_schema_shape(block_node, type_aliases)
201
+ # PUBLIC reuse surface (issue #137 slices 2/3): `rigor-dry-validation`'s `params { ... }` /
202
+ # `json { ... }` block body is the SAME dry-schema DSL a top-level `Dry::Schema.X { ... }` body
203
+ # is, so that plugin delegates here instead of duplicating the required/optional walk — the
204
+ # `docs/design/20260517-dry-validation-slicing.md` slicing note's own "delegate to
205
+ # rigor-dry-schema's walker" option. Only reachable from another plugin when `rigor-dry-schema`
206
+ # is registered (the caller checks `Rigor::Plugin.registered_for("dry-schema")` first — that is
207
+ # also what makes THIS class already loaded/defined for the caller to reference).
208
+ #
209
+ # `nested:` is false at the top-level `Dry::Schema.X { ... }` body and true inside an `each do
210
+ # ... end` row's own recursive call (see {#each_block_type_info}). It caps the recursion at ONE
211
+ # level deep: with `nested: true`, {#walk_predicate_chain} declines a FURTHER `each do ... end`
212
+ # rather than recursing again (issue #137 deliberately does not model doubly-nested arrays of
213
+ # arrays). This also keeps `collect_schema_shape` from calling itself — {#each_block_type_info}
214
+ # is the only caller that ever passes `nested: true}` — so the call graph has no cycle for the
215
+ # engine's return-type inference to approximate through.
216
+ def collect_schema_shape(block_node, type_aliases, nested:)
124
217
  required = {}
125
218
  optional = {}
126
- walk_block_body(block_node) do |kind, key, type_info|
219
+ declared = { required: [], optional: [] }
220
+ walk_block_body(block_node, type_aliases, nested) do |kind, key, type_info|
221
+ declared[kind] << key
127
222
  (kind == :required ? required : optional)[key] = type_info if type_info
128
223
  end
129
224
 
130
225
  remap_aliases!(required, type_aliases)
131
226
  remap_aliases!(optional, type_aliases)
132
227
 
133
- { required: required.freeze, optional: optional.freeze }.freeze
228
+ {
229
+ required: required.freeze,
230
+ optional: optional.freeze,
231
+ unmodelled: unmodelled_keys(declared, required, optional)
232
+ }.freeze
134
233
  end
135
- private_class_method :collect_schema_shape
136
234
 
137
235
  # Walks every top-level `required(:key).<predicate>(...)` / `optional(:key).<predicate>(...)`
138
236
  # chain in the block body. The block's body is either a `Prism::StatementsNode` (multi-statement)
139
- # or a single expression node.
140
- def walk_block_body(block_node, &)
237
+ # or a single expression node. `type_aliases` threads down to a nested `each do ... end` row's own
238
+ # recursive {#collect_schema_shape} call (slice 2's alias resolution applies at every nesting
239
+ # depth, not just the top level).
240
+ def walk_block_body(block_node, type_aliases, nested, &)
141
241
  body = block_node.body
142
242
  return if body.nil?
143
243
 
144
244
  children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
145
- children.each { |child| visit_chain(child, &) }
245
+ children.each { |child| visit_chain(child, type_aliases, nested, &) }
146
246
  end
147
247
  private_class_method :walk_block_body
148
248
 
@@ -151,13 +251,13 @@ module Rigor
151
251
  # the chain's tail. The `each(<Type>)` predicate yields a list-of-element type info (`{type: <T>,
152
252
  # list: true}`); other type-bearing predicates (`filled`/`value`/`maybe`) yield scalar info
153
253
  # (`{type: <T>, list: false}`).
154
- def visit_chain(node, &block)
254
+ def visit_chain(node, type_aliases, nested, &block)
155
255
  return unless node.is_a?(Prism::CallNode)
156
256
 
157
257
  key, kind = extract_key_and_kind(node)
158
258
  return if key.nil?
159
259
 
160
- type_info = walk_predicate_chain(node)
260
+ type_info = walk_predicate_chain(node, type_aliases, nested)
161
261
  block.call(kind, key, type_info)
162
262
  end
163
263
  private_class_method :visit_chain
@@ -183,9 +283,19 @@ module Rigor
183
283
  # Walks the call chain finding the first type-bearing predicate (`filled` / `value` / `maybe` /
184
284
  # `each`) and extracts its argument type. Returns a `{type:, list:}` tuple (`each` is the only
185
285
  # verb that produces a list) or nil when no recognisable type sits on the chain.
186
- def walk_predicate_chain(node)
286
+ #
287
+ # `each do ... end` (a block instead of a type-symbol argument) is the ceiling's element-type
288
+ # recursion (issue #137): the block is itself a nested schema declaration, walked with the SAME
289
+ # algorithm as a top-level `Dry::Schema.X { ... }` body via {#each_block_type_info}. Already
290
+ # `nested` (i.e. this chain is itself inside another `each do ... end`) declines a FURTHER each —
291
+ # see {#collect_schema_shape}'s `nested:` doc for why the recursion caps at one level.
292
+ def walk_predicate_chain(node, type_aliases, nested)
187
293
  current = node
188
294
  while current.is_a?(Prism::CallNode)
295
+ if current.name == :each && current.block
296
+ return nested ? nil : each_block_type_info(current.block, type_aliases)
297
+ end
298
+
189
299
  if TYPE_BEARING_PREDICATES.include?(current.name)
190
300
  underlying = extract_type_from_predicate(current)
191
301
  return { type: underlying, list: current.name == :each } if underlying
@@ -196,6 +306,40 @@ module Rigor
196
306
  end
197
307
  private_class_method :walk_predicate_chain
198
308
 
309
+ # `required(:key).each do ... end` — recurses into the each-block using {#collect_schema_shape},
310
+ # the identical row-collection algorithm a top-level schema body uses, so a nested `required` /
311
+ # `optional` row gets the same predicate vocabulary, alias resolution, and untyped-row fallback as
312
+ # the outer schema. Two spellings are recognised: the bare `each do required(:x)...; end` form, and
313
+ # `each do schema do required(:x)...; end end` (dry-schema's alternate nested-hash spelling) via
314
+ # {#nested_block_body}.
315
+ #
316
+ # A block that yields no row at all — a bare per-element predicate like `each { int? }`, which
317
+ # this scanner does not model — declines (nil) rather than returning an empty nested shape: the key
318
+ # falls through to {#unmodelled_keys} and renders `untyped`, the same "declined, not wrong" posture
319
+ # every other unresolvable row already gets.
320
+ def each_block_type_info(each_block, type_aliases)
321
+ nested_shape = collect_schema_shape(nested_block_body(each_block), type_aliases, nested: true)
322
+ return nil if nested_shape[:required].empty? && nested_shape[:optional].empty?
323
+
324
+ { type: { nested: nested_shape }, list: true }
325
+ end
326
+ private_class_method :each_block_type_info
327
+
328
+ # Unwraps the `each do schema do ... end end` spelling to the inner `schema` block; the bare
329
+ # `each do required(...); ...; end` spelling passes the each-block through unchanged. Both declare
330
+ # the same nested key set, just at a different literal nesting depth.
331
+ def nested_block_body(each_block)
332
+ body = each_block.body
333
+ return each_block if body.nil?
334
+
335
+ children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
336
+ return each_block unless children.size == 1
337
+
338
+ single = children.first
339
+ single.is_a?(Prism::CallNode) && single.name == :schema && single.block ? single.block : each_block
340
+ end
341
+ private_class_method :nested_block_body
342
+
199
343
  # Reads the first positional argument of a `filled(:string)` / `value(:integer)` /
200
344
  # `maybe(Types::Email)` call. Returns either the canonical-type-symbol's underlying class
201
345
  # ("String" / "Integer" / …), or the constant's qualified name for downstream type-alias
@@ -213,15 +357,36 @@ module Rigor
213
357
  end
214
358
  private_class_method :extract_type_from_predicate
215
359
 
360
+ # Keys the schema declares that this scanner saw but could not type — a predicate outside the
361
+ # canonical vocabulary (`filled(:not_a_type)`), a nested `schema do … end` row, or a constant
362
+ # alias no `:dry_type_aliases` fact resolved.
363
+ #
364
+ # They are recorded rather than forgotten so the schema's declared key set survives the scan even
365
+ # where its types do not. {ResultShape} puts them back into the synthesized `to_h` shape as untyped
366
+ # entries, which is what makes a hover read `{ email: String, address: Dynamic[top], ... }` instead
367
+ # of a shape indistinguishable from one that never declared `address`. Consumers that only want
368
+ # typed keys read `:required` / `:optional` and are unaffected.
369
+ def unmodelled_keys(declared, required, optional)
370
+ {
371
+ required: (declared[:required] - required.keys).uniq.freeze,
372
+ optional: (declared[:optional] - optional.keys).uniq.freeze
373
+ }.freeze
374
+ end
375
+ private_class_method :unmodelled_keys
376
+
216
377
  # In-place: any value's `type:` slot in `bucket` that doesn't already match a canonical class
217
378
  # (e.g. `"Types::Email"`) gets resolved through the type_aliases fact. Unresolvable values drop
218
379
  # from the bucket (no fact contribution rather than misleading data). The `list:` slot rides along
219
380
  # unchanged.
381
+ #
382
+ # A nested-shape row (`type: {nested: {...}}`, from an `each do ... end` element-type recursion)
383
+ # is already fully resolved by its own recursive {#collect_schema_shape} call — its `type:` slot is
384
+ # a Hash, not a class-name String, and is skipped here rather than treated as an unresolvable alias.
220
385
  def remap_aliases!(bucket, type_aliases)
221
386
  canonical_set = CANONICAL_TYPES.values.to_set
222
387
  bucket.each_pair.to_a.each do |key, info|
223
388
  type_name = info.fetch(:type)
224
- next if canonical_set.include?(type_name)
389
+ next if type_name.is_a?(Hash) || canonical_set.include?(type_name)
225
390
 
226
391
  resolved = type_aliases[type_name]
227
392
  if resolved
@@ -5,6 +5,7 @@ require "prism"
5
5
  require "rigor/plugin"
6
6
 
7
7
  require_relative "dry_schema/schema_scanner"
8
+ require_relative "dry_schema/result_shape"
8
9
 
9
10
  module Rigor
10
11
  module Plugin
@@ -58,14 +59,22 @@ module Rigor
58
59
  # above.
59
60
  # - Publishes the table; no user-facing diagnostics yet.
60
61
  #
61
- # The **ceiling** (slice 2+):
62
+ # Landed since: typed `result.to_h` returns, via the {.dynamic_return} rule below rather than the
63
+ # ADR-16 Tier C substrate the README originally projected — Tier C synthesises methods on a class
64
+ # from a class-level DSL call, and this is a return type for a call chain off a constant.
62
65
  #
63
- # - Synthesise typed `result.to_h` returns from each schema via ADR-16 Tier C heredoc-template
64
- # substrate.
65
- # - Nested schemas (`schema(do ... end)` inside another row).
66
- # - `predicates(:size?)` / `each { ... }` recursion.
67
- # - Per-row `dry-schema.unknown-predicate` / `dry-schema.unknown-type` `:info` diagnostics when a
68
- # row's predicate or type symbol isn't recognised.
66
+ # Landed since (issue #137): `each do ... end` element-type recursion ({SchemaScanner#each_block_type_info})
67
+ # and the `dry-schema.unknown-type` `:info` diagnostic ({SchemaScanner#unknown_type_issues}), wired
68
+ # below via {.node_file_context} + {.node_rule}.
69
+ #
70
+ # The **ceiling** (still open):
71
+ #
72
+ # - Nested schemas OUTSIDE an `each` (`required(:x).schema do ... end` with no `each` in the chain) —
73
+ # the key is still kept as untyped; only the `each`-wrapped nested form recurses.
74
+ # - `predicates(:size?)` / other fine-grained predicate constraint walks.
75
+ # - `dry-schema.unknown-predicate` — deliberately NOT shipped; see {SchemaScanner.unknown_type_issues}
76
+ # for why (no reliable static signal without a full predicate registry; risks flagging correct code).
77
+ # - `rigor-dry-validation` integration — landed separately in that plugin's own slices 2/3 (issue #137).
69
78
  class DrySchema < Rigor::Plugin::Base
70
79
  manifest(
71
80
  id: "dry-schema",
@@ -76,6 +85,46 @@ module Rigor
76
85
  consumes: [{ plugin_id: "dry-types", name: :dry_type_aliases, optional: true }]
77
86
  )
78
87
 
88
+ # ADR-37 dynamic return — `NewUserSchema.call(input).to_h` yields the schema's own
89
+ # `HashShape[{email: String, ?nickname: String}]` instead of the untyped hash the DSL's boundary
90
+ # otherwise leaves behind. The shape's required/optional split and its open extra-key policy are
91
+ # argued in {ResultShape}.
92
+ #
93
+ # Gated on the method name alone, not on a `Dry::Schema::Result` receiver: this plugin ships no RBS
94
+ # overlay for dry-schema, so in an ordinary project the receiver of `.to_h` is untyped and a
95
+ # `receivers:` gate would never fire. The cost of the wider gate is bounded — the engine compiles
96
+ # `methods:` into its registry name gate, so the block is consulted only for `.to_h` calls, and its
97
+ # first act rejects any chain that is not `<Const>.call(...).to_h` before the table is touched.
98
+ dynamic_return methods: [:to_h] do |call_node, _scope|
99
+ result_shape_for(call_node)
100
+ end
101
+
102
+ # ADR-37 slice 1c two-pass: the "collect" half of `dry-schema.unknown-type` (issue #137's ceiling
103
+ # diagnostic). Walks the already-parsed file root once via {SchemaScanner.unknown_type_issues}; the
104
+ # `node_rule` below is the "validate" half that turns each collected issue into a Diagnostic.
105
+ node_file_context do |root, _scope|
106
+ SchemaScanner.unknown_type_issues(root)
107
+ end
108
+
109
+ # Fires exactly once per file: `Prism::ProgramNode` is the AST root, and the engine's node walker
110
+ # visits the root itself before descending (see `Source::NodeWalker#walk_with_ancestors`). Cheaper
111
+ # than a `Prism::CallNode` rule re-matching every call site in the file — the collect pass already
112
+ # did the real walk.
113
+ node_rule Prism::ProgramNode do |_node, _scope, path, issues|
114
+ next [] if issues.nil? || issues.empty?
115
+
116
+ issues.map do |issue|
117
+ diagnostic(
118
+ issue[:node],
119
+ path: path,
120
+ message: "`#{issue[:key]}` uses `:#{issue[:symbol]}`, which is not a recognised dry-schema " \
121
+ "canonical type symbol (see the plugin README's predicate type table)",
122
+ severity: :info,
123
+ rule: "dry-schema.unknown-type"
124
+ )
125
+ end
126
+ end
127
+
79
128
  # Walks every project file once during `prepare(services)` to build the schema table, then publishes
80
129
  # via the ADR-9 fact store. Mirrors the rigor-dry-types `#prepare` shape — the walk is bounded by
81
130
  # `paths:`, parse errors degrade silently.
@@ -93,10 +142,28 @@ module Rigor
93
142
 
94
143
  def init(_services)
95
144
  @scannable_paths = nil
145
+ @result_shapes = {}
96
146
  end
97
147
 
98
148
  private
99
149
 
150
+ # The HashShape for a `<Const>.call(...).to_h` chain, or nil when the chain isn't that shape, names
151
+ # no schema in the table, or names one that declares no key.
152
+ # Memoised per schema name INCLUDING nil: a carrier is a value object, and the miss is the common
153
+ # case on any project that calls `to_h` on something that isn't a schema result.
154
+ def result_shape_for(call_node)
155
+ name = ResultShape.schema_name(call_node)
156
+ return nil if name.nil?
157
+
158
+ # `||=` rather than a plain read of an `init`-assigned ivar: `dynamic_return_type` rescues every
159
+ # StandardError to nil, so a NoMethodError here would turn the whole feature off in silence.
160
+ shapes = (@result_shapes ||= {})
161
+ return shapes[name] if shapes.key?(name)
162
+
163
+ entry = read_fact(plugin_id: manifest.id, name: :dry_schema_table)&.[](name)
164
+ shapes[name] = entry.nil? ? nil : ResultShape.build(entry)
165
+ end
166
+
100
167
  def scannable_paths(services)
101
168
  @scannable_paths ||= services.configuration.paths.flat_map do |entry|
102
169
  if File.directory?(entry)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "prism"
4
+ require "rigor/source/literals"
4
5
 
5
6
  module Rigor
6
7
  module Plugin
@@ -41,6 +42,262 @@ module Rigor
41
42
  end
42
43
  private_class_method :scan_file
43
44
 
45
+ # Issue #137 slices 2/3 — per-Contract `params { ... }` / `json { ... }` shapes, delegating the
46
+ # actual required/optional walk to `rigor-dry-schema`'s {DrySchema::SchemaScanner.collect_schema_shape}
47
+ # (the SAME dry-schema DSL, just under a bare `params`/`json` call instead of
48
+ # `Dry::Schema.Params { ... }`). Returns `{}` immediately, without touching a single file, when
49
+ # `rigor-dry-schema` isn't loaded — the companion plugin's absence is a hard precondition here,
50
+ # not a degrade-gracefully fallback, because this plugin ships no required/optional walker of
51
+ # its own.
52
+ #
53
+ # @return [Hash{String => Hash{Symbol => Hash}}] contract FQN => `{params: <shape>}` and/or
54
+ # `{json: <shape>}` (only the recognised key(s) are present; a contract with neither present
55
+ # contributes nothing). Each `<shape>` is exactly {DrySchema::SchemaScanner.collect_schema_shape}'s
56
+ # `{required:, optional:, unmodelled:}` return.
57
+ def scan_schema_blocks(paths:, type_aliases: {})
58
+ return {} unless Rigor::Plugin.registered_for("dry-schema")
59
+
60
+ table = {}
61
+ paths.each do |path|
62
+ scan_file_schema_blocks(path, type_aliases).each do |fqn, shapes|
63
+ table[fqn] ||= shapes
64
+ end
65
+ end
66
+ table.freeze
67
+ end
68
+
69
+ def scan_file_schema_blocks(path, type_aliases)
70
+ source = File.read(path)
71
+ parse_result = Prism.parse(source, filepath: path)
72
+ return {} unless parse_result.errors.empty?
73
+
74
+ collect_params_blocks(parse_result.value, [], type_aliases)
75
+ rescue StandardError
76
+ {}
77
+ end
78
+ private_class_method :scan_file_schema_blocks
79
+
80
+ # Mirrors {#collect_contracts}'s recursive shape, additionally capturing each recognised
81
+ # Contract's own `params { ... }` / `json { ... }` shape.
82
+ def collect_params_blocks(node, qualified_prefix, type_aliases)
83
+ return {} if node.nil?
84
+
85
+ case node
86
+ when Prism::ClassNode
87
+ collect_class_params_blocks(node, qualified_prefix, type_aliases)
88
+ when Prism::ModuleNode
89
+ inner_name = constant_name_for(node.constant_path)
90
+ return {} if inner_name.nil?
91
+
92
+ collect_params_blocks(node.body, qualified_prefix + [inner_name], type_aliases)
93
+ else
94
+ node.compact_child_nodes.each_with_object({}) do |child, acc|
95
+ collect_params_blocks(child, qualified_prefix, type_aliases).each { |k, v| acc[k] ||= v }
96
+ end
97
+ end
98
+ end
99
+ private_class_method :collect_params_blocks
100
+
101
+ def collect_class_params_blocks(node, qualified_prefix, type_aliases)
102
+ inner_name = constant_name_for(node.constant_path)
103
+ return {} if inner_name.nil?
104
+
105
+ new_prefix = qualified_prefix + [inner_name]
106
+ inner = collect_params_blocks(node.body, new_prefix, type_aliases)
107
+
108
+ if contract_subclass?(node)
109
+ shapes = class_body_schema_shapes(node.body, type_aliases)
110
+ inner[new_prefix.join("::")] = shapes unless shapes.empty?
111
+ end
112
+ inner
113
+ end
114
+ private_class_method :collect_class_params_blocks
115
+
116
+ # ONLY a top-level `params do ... end` / `json do ... end` call directly inside the Contract's
117
+ # class body — a bare call, no receiver, no positional/keyword arguments, exactly one block.
118
+ # A call nested inside a conditional, a method def, or wrapped in any other construct is NOT
119
+ # this floor; it's indistinguishable here from a dynamically-built or externally-shared schema,
120
+ # and the conservative read is to contribute nothing rather than guess.
121
+ def class_body_schema_shapes(body, type_aliases)
122
+ return {} if body.nil?
123
+
124
+ children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
125
+ shapes = {}
126
+ children.each do |child|
127
+ next unless bare_block_call?(child)
128
+
129
+ case child.name
130
+ when :params then shapes[:params] ||= DrySchema::SchemaScanner.collect_schema_shape(child.block, type_aliases, nested: false)
131
+ when :json then shapes[:json] ||= DrySchema::SchemaScanner.collect_schema_shape(child.block, type_aliases, nested: false)
132
+ end
133
+ end
134
+ shapes
135
+ end
136
+ private_class_method :class_body_schema_shapes
137
+
138
+ def bare_block_call?(node)
139
+ node.is_a?(Prism::CallNode) && node.receiver.nil? && node.arguments.nil? &&
140
+ node.block.is_a?(Prism::BlockNode)
141
+ end
142
+ private_class_method :bare_block_call?
143
+
144
+ # `dry-validation.rule-key-mismatch` (issue #137's remaining ceiling checkbox) — walks an
145
+ # ALREADY-PARSED file's root node collecting `{node:, key:, contract_fqn:}` issues for a
146
+ # `rule(:sym, ...) do ... end` call whose Symbol argument names a key that is NOT in the
147
+ # Contract's OWN, cleanly-recognised `params`/`json` key set.
148
+ #
149
+ # FP-gated hard, per two independent all-or-nothing checks:
150
+ #
151
+ # - {#clean_known_keys} — the Contract's key universe is trusted ONLY when EVERY top-level
152
+ # statement in EVERY `params`/`json` block is a recognised `required`/`optional` row (typed or
153
+ # not — an unmodelled row is still a DECLARED key) or the inert `config.<x> = <literal>` idiom.
154
+ # A loop building keys dynamically, a splat, an `include`, a nested schema merge, or a
155
+ # `params`/`json(SomeSchema)` delegating to an externally-shared schema — ANY of these makes
156
+ # the WHOLE Contract's key universe untrustworthy, not just the offending row, and the
157
+ # Contract is skipped outright (no diagnostic against any of its `rule()` calls).
158
+ # - {#rule_arguments_or_nil} — a `rule(...)` call is checked ONLY when EVERY argument is a
159
+ # literal Symbol. A splat (`rule(*keys)`), a String, a local variable, or a Hash (the
160
+ # nested-key form `rule(user: [:name])`) makes the WHOLE call unresolvable, not just that one
161
+ # argument, and it is skipped rather than partially validated.
162
+ #
163
+ # Returns `[]` immediately, without touching a single file, when `rigor-dry-schema` isn't loaded
164
+ # — there is no key universe to check a `rule()` call against without it.
165
+ def rule_key_issues(root, type_aliases)
166
+ return [] unless Rigor::Plugin.registered_for("dry-schema")
167
+
168
+ issues = []
169
+ walk_for_rule_issues(root, [], type_aliases, issues)
170
+ issues
171
+ end
172
+
173
+ def walk_for_rule_issues(node, qualified_prefix, type_aliases, issues)
174
+ return if node.nil?
175
+
176
+ case node
177
+ when Prism::ClassNode
178
+ inner_name = constant_name_for(node.constant_path)
179
+ return if inner_name.nil?
180
+
181
+ new_prefix = qualified_prefix + [inner_name]
182
+ check_contract_rules(node, new_prefix.join("::"), type_aliases, issues) if contract_subclass?(node)
183
+ walk_for_rule_issues(node.body, new_prefix, type_aliases, issues)
184
+ when Prism::ModuleNode
185
+ inner_name = constant_name_for(node.constant_path)
186
+ return if inner_name.nil?
187
+
188
+ walk_for_rule_issues(node.body, qualified_prefix + [inner_name], type_aliases, issues)
189
+ else
190
+ node.compact_child_nodes.each { |child| walk_for_rule_issues(child, qualified_prefix, type_aliases, issues) }
191
+ end
192
+ end
193
+ private_class_method :walk_for_rule_issues
194
+
195
+ def check_contract_rules(class_node, contract_fqn, type_aliases, issues)
196
+ body = class_node.body
197
+ return if body.nil?
198
+
199
+ children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
200
+ known_keys = clean_known_keys(children, type_aliases)
201
+ return if known_keys.nil?
202
+
203
+ children.each do |child|
204
+ next unless rule_call?(child)
205
+
206
+ check_rule_call(child, contract_fqn, known_keys, issues)
207
+ end
208
+ end
209
+ private_class_method :check_contract_rules
210
+
211
+ # The Contract's full declared key set (required + optional + unmodelled, across BOTH `params`
212
+ # and `json` if both are present — a Contract having both is unusual, so the conservative read
213
+ # is the union rather than guessing which one a given `rule()` targets), or nil when the
214
+ # Contract has no `params`/`json` block at all OR either block fails {#clean_schema_block?}.
215
+ def clean_known_keys(children, type_aliases)
216
+ blocks = children.select { |c| bare_block_call?(c) && %i[params json].include?(c.name) }
217
+ return nil if blocks.empty?
218
+
219
+ keys = Set.new
220
+ blocks.each do |block_call|
221
+ return nil unless clean_schema_block?(block_call.block)
222
+
223
+ shape = DrySchema::SchemaScanner.collect_schema_shape(block_call.block, type_aliases, nested: false)
224
+ keys.merge(shape[:required].keys)
225
+ keys.merge(shape[:optional].keys)
226
+ keys.merge(shape[:unmodelled][:required])
227
+ keys.merge(shape[:unmodelled][:optional])
228
+ end
229
+ keys
230
+ end
231
+ private_class_method :clean_known_keys
232
+
233
+ def clean_schema_block?(block_node)
234
+ body = block_node.body
235
+ return true if body.nil? # empty block — vacuously clean, contributes no keys
236
+
237
+ children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
238
+ children.all? { |child| required_or_optional_row?(child) || inert_config_assignment?(child) }
239
+ end
240
+ private_class_method :clean_schema_block?
241
+
242
+ # A `required(:key)...` / `optional(:key)...` chain, ANY predicate suffix (even one this
243
+ # scanner can't type — an unmodelled row is still a row that DECLARES the key, which is all
244
+ # {#clean_known_keys} needs).
245
+ def required_or_optional_row?(node)
246
+ return false unless node.is_a?(Prism::CallNode)
247
+
248
+ current = node
249
+ while current.is_a?(Prism::CallNode)
250
+ if %i[required optional].include?(current.name)
251
+ return !Rigor::Source::Literals.symbol(current.arguments&.arguments&.first).nil?
252
+ end
253
+
254
+ current = current.receiver
255
+ end
256
+ false
257
+ end
258
+ private_class_method :required_or_optional_row?
259
+
260
+ # `config.validate_keys = true` and similar — dry-schema's own `config.<x> = <literal>` idiom.
261
+ # Declares no key, so it's safe to ignore rather than treat as "unrecognised" (over-declining a
262
+ # perfectly common line would silently drop valid diagnostics, not risk a false positive, but
263
+ # there's no reason to pay that cost).
264
+ def inert_config_assignment?(node)
265
+ node.is_a?(Prism::CallNode) && node.name.end_with?("=") &&
266
+ node.receiver.is_a?(Prism::CallNode) && node.receiver.name == :config && node.receiver.receiver.nil?
267
+ end
268
+ private_class_method :inert_config_assignment?
269
+
270
+ def rule_call?(node)
271
+ node.is_a?(Prism::CallNode) && node.name == :rule && node.receiver.nil? && node.block.is_a?(Prism::BlockNode)
272
+ end
273
+ private_class_method :rule_call?
274
+
275
+ def check_rule_call(rule_node, contract_fqn, known_keys, issues)
276
+ symbols = rule_arguments_or_nil(rule_node)
277
+ return if symbols.nil?
278
+
279
+ symbols.each do |arg|
280
+ key = arg.unescaped.to_sym
281
+ next if known_keys.include?(key)
282
+
283
+ issues << { node: arg, key: key, contract_fqn: contract_fqn, known_keys: known_keys }
284
+ end
285
+ end
286
+ private_class_method :check_rule_call
287
+
288
+ # Every positional argument of `rule(...)`, as `Prism::SymbolNode`s, or nil when there are none,
289
+ # or ANY of them isn't a literal Symbol (a splat, a String, a local variable, or a Hash — the
290
+ # `rule(user: [:name])` nested-key form). One non-Symbol argument makes the WHOLE call
291
+ # unresolvable, not just that argument.
292
+ def rule_arguments_or_nil(rule_node)
293
+ args = rule_node.arguments&.arguments
294
+ return nil if args.nil? || args.empty?
295
+ return nil unless args.all?(Prism::SymbolNode)
296
+
297
+ args
298
+ end
299
+ private_class_method :rule_arguments_or_nil
300
+
44
301
  def collect_contracts(node, qualified_prefix)
45
302
  return [] if node.nil?
46
303