rigortype 0.3.1 → 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 (101) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +10 -10
  3. data/data/core_overlay/pathname.rbs +5 -0
  4. data/data/core_overlay/resolv.rbs +31 -0
  5. data/data/core_overlay/string_scanner.rbs +5 -0
  6. data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
  7. data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
  8. data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
  9. data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
  10. data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
  11. data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
  12. data/docs/manual/02-cli-reference.md +71 -11
  13. data/docs/manual/03-configuration.md +16 -1
  14. data/docs/manual/12-caching.md +17 -5
  15. data/docs/manual/15-type-protection-coverage.md +122 -1
  16. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  18. data/lib/rigor/analysis/check_rules.rb +45 -6
  19. data/lib/rigor/analysis/incremental_session.rb +156 -11
  20. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  21. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  22. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  23. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  24. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  25. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  26. data/lib/rigor/analysis/runner.rb +78 -7
  27. data/lib/rigor/bleeding_edge.rb +132 -7
  28. data/lib/rigor/cache/descriptor.rb +6 -1
  29. data/lib/rigor/cache/engine_source.rb +162 -0
  30. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  31. data/lib/rigor/cli/check_command.rb +49 -28
  32. data/lib/rigor/cli/coverage_command.rb +15 -2
  33. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  34. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  35. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  36. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  37. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  38. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  39. data/lib/rigor/cli/protection_renderer.rb +13 -0
  40. data/lib/rigor/cli/protection_report.rb +11 -3
  41. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  42. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  43. data/lib/rigor/configuration.rb +45 -6
  44. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  45. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  46. data/lib/rigor/environment/rbs_loader.rb +434 -75
  47. data/lib/rigor/environment.rb +28 -13
  48. data/lib/rigor/inference/expression_typer.rb +15 -0
  49. data/lib/rigor/inference/fork_map.rb +9 -0
  50. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  51. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  52. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  53. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
  54. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  55. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
  56. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  57. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  58. data/lib/rigor/inference/mutation_widening.rb +30 -25
  59. data/lib/rigor/inference/narrowing.rb +3 -1
  60. data/lib/rigor/inference/optimistic_origin.rb +58 -0
  61. data/lib/rigor/inference/protection_scanner.rb +18 -2
  62. data/lib/rigor/inference/receiver_alias.rb +57 -0
  63. data/lib/rigor/inference/scope_indexer.rb +78 -27
  64. data/lib/rigor/inference/statement_evaluator.rb +40 -2
  65. data/lib/rigor/language_server/buffer_table.rb +22 -0
  66. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  67. data/lib/rigor/language_server/project_context.rb +45 -0
  68. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  69. data/lib/rigor/language_server/server.rb +20 -1
  70. data/lib/rigor/language_server.rb +1 -0
  71. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  72. data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
  73. data/lib/rigor/protection/dependency_closure.rb +59 -0
  74. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  75. data/lib/rigor/protection/discovery_seed.rb +116 -0
  76. data/lib/rigor/protection/kill_signature.rb +31 -0
  77. data/lib/rigor/protection/mutation_cache.rb +355 -0
  78. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  79. data/lib/rigor/protection/mutator.rb +26 -4
  80. data/lib/rigor/runtime/jit.rb +63 -1
  81. data/lib/rigor/scope/discovery_index.rb +9 -0
  82. data/lib/rigor/scope.rb +52 -8
  83. data/lib/rigor/sig_gen/generator.rb +130 -31
  84. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  85. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  86. data/lib/rigor/sig_gen/renderer.rb +20 -1
  87. data/lib/rigor/sig_gen/write_result.rb +6 -4
  88. data/lib/rigor/sig_gen/writer.rb +12 -0
  89. data/lib/rigor/sig_gen.rb +1 -0
  90. data/lib/rigor/version.rb +1 -1
  91. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  92. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  93. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  94. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  95. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  96. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  97. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  98. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  99. data/sig/rigor/inference/void_origin.rbs +6 -3
  100. data/sig/rigor/scope.rbs +8 -0
  101. metadata +17 -1
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class DrySchema < Rigor::Plugin::Base
8
+ # Translates one `:dry_schema_table` entry into the `Rigor::Type::HashShape` that
9
+ # `SomeSchema.call(input).to_h` returns, and recognises the call chain that earns it.
10
+ #
11
+ # The table entry is the shape {SchemaScanner} publishes:
12
+ #
13
+ # { required: { email: { type: "String", list: false } },
14
+ # optional: { nickname: { type: "String", list: true } } }
15
+ #
16
+ # ## Why the shape mirrors the declaration's own vocabulary
17
+ #
18
+ # `Dry::Schema::Result#to_h` returns the *coerced input*, so on a failed validation a
19
+ # `required(:email)` key can be absent. Modelling that worst case — every key optional — would be
20
+ # the sound reading and the wrong one: the idiomatic consumer checks `success?` first, and typing
21
+ # `result.to_h[:age]` as `Integer?` inside that branch draws a nil diagnostic on correct code.
22
+ # Rigor weighs a false positive above a worst-case static reading (AGENTS.md § Implementation
23
+ # Guidelines), and already took the same call for RBS's `%a{implicitly-returns-nil}`.
24
+ #
25
+ # So `required` rows become required keys and `optional` rows become optional keys — the schema's
26
+ # own words. A reader who wrote `optional(:nickname)` is not surprised that the key reads as
27
+ # possibly-absent, and one who wrote `required(:email)` is not surprised that it does not.
28
+ #
29
+ # The shape is **open**: dry-schema's key map only emits declared keys, but a closed shape would
30
+ # turn any read of an undeclared key into a diagnostic, and being wrong there costs more than the
31
+ # extra precision buys.
32
+ module ResultShape
33
+ # `:bool` rows land in the published fact as "TrueClass" — the fact's vocabulary names one
34
+ # underlying class per row and has no union slot. A hash value typed `TrueClass` would false-fire
35
+ # on every `false`, so the shape widens it back to the two-class union here rather than in the
36
+ # fact, whose consumers (rigor-dry-struct, rigor-dry-validation) read it for a class name.
37
+ BOOL_CLASSES = %w[TrueClass FalseClass].freeze
38
+
39
+ module_function
40
+
41
+ # The schema constant `to_h_node`'s receiver chain names, or nil when the chain isn't the
42
+ # recognised `<Const>.call(...).to_h` form.
43
+ #
44
+ # Floor: the schema must be named by a constant *as written* at the call site, because the FQN is
45
+ # matched against the table's keys verbatim. A schema referenced through a local
46
+ # (`schema = NewUserSchema; schema.call(x).to_h`) or by a relative constant path from inside the
47
+ # declaring module resolves to no entry and contributes nothing — the pre-slice behaviour.
48
+ def schema_name(to_h_node)
49
+ return nil unless to_h_node.is_a?(Prism::CallNode) && to_h_node.name == :to_h
50
+ return nil unless to_h_node.arguments.nil? && to_h_node.block.nil?
51
+
52
+ inner = to_h_node.receiver
53
+ return nil unless inner.is_a?(Prism::CallNode) && inner.name == :call
54
+
55
+ constant_name(inner.receiver)
56
+ end
57
+
58
+ # The HashShape for one table entry, or nil when the schema declares no key at all. An empty open
59
+ # shape would be a carrier that says nothing the bare `untyped` did not already say, so declining
60
+ # keeps the hash's type honest about how little is known.
61
+ def build(entry)
62
+ unmodelled = entry[:unmodelled] || {}
63
+ required = pairs_for(entry[:required]).merge(untyped_pairs(unmodelled[:required]))
64
+ optional = pairs_for(entry[:optional]).merge(untyped_pairs(unmodelled[:optional]))
65
+ return nil if required.empty? && optional.empty?
66
+
67
+ Rigor::Type::Combinator.hash_shape_of(
68
+ required.merge(optional),
69
+ required_keys: required.keys,
70
+ optional_keys: optional.keys,
71
+ extra_keys: :open
72
+ )
73
+ end
74
+
75
+ # A key the schema declares but the scanner could not type still belongs in the shape, as
76
+ # `untyped`.
77
+ #
78
+ # This costs nothing at a read and buys nothing at one either — measured: with and without these
79
+ # entries, `payload[:address]`, `payload.fetch(:address)` and every other read infer identically,
80
+ # because #249 made an undeclared key on an OPEN shape read as `untyped` rather than `nil`. What
81
+ # it buys is the rendered shape, which is what hover and `dump_type` show: `{ email: String,
82
+ # address: Dynamic[top], ... }` says the schema declares `address` and Rigor cannot type it, while
83
+ # `{ email: String, ... }` is indistinguishable from a schema that never mentioned it — the
84
+ # trailing `...` only ever means "keys beyond these are permitted".
85
+ #
86
+ # It was originally load-bearing for a different reason: before #249 a key outside the shape read
87
+ # as `nil`, so dropping `required(:address).schema { … }` put a `call.undefined-method` on the next
88
+ # line of correct code. That hazard is gone; the entries stay for the honesty of the rendering.
89
+ def untyped_pairs(keys)
90
+ (keys || []).to_h { |key| [key, Rigor::Type::Combinator.untyped] }
91
+ end
92
+
93
+ def pairs_for(rows)
94
+ (rows || {}).each_with_object({}) do |(key, row), pairs|
95
+ type = row_type(row)
96
+ pairs[key] = type unless type.nil?
97
+ end
98
+ end
99
+
100
+ def row_type(row)
101
+ element = element_type(row[:type])
102
+ return nil if element.nil?
103
+
104
+ row[:list] ? Rigor::Type::Combinator.nominal_of("Array", type_args: [element]) : element
105
+ end
106
+
107
+ # `type` is either a class-name String (the scalar case) or a `{nested: <shape>}` Hash — an
108
+ # `each do ... end` element-type recursion (issue #137's ceiling slice): {SchemaScanner} already
109
+ # collected the nested block with the same `collect_schema_shape` algorithm a top-level schema
110
+ # body uses, so building its HashShape is just a recursive {.build} call.
111
+ def element_type(type)
112
+ return build(type[:nested]) if type.is_a?(Hash)
113
+
114
+ class_type(type)
115
+ end
116
+
117
+ def class_type(class_name)
118
+ return nil if class_name.nil? || class_name.empty?
119
+ return Rigor::Type::Combinator.union(*BOOL_CLASSES.map { |c| Rigor::Type::Combinator.nominal_of(c) }) \
120
+ if class_name == "TrueClass"
121
+
122
+ Rigor::Type::Combinator.nominal_of(class_name)
123
+ end
124
+
125
+ # Renders `Foo` / `Foo::Bar` / `::Foo::Bar` as the `::`-joined String the table is keyed by.
126
+ # Mirrors the helper rigor-sorbet's TypeTranslator and rigor-activerecord's ModelDiscoverer use.
127
+ def constant_name(node)
128
+ case node
129
+ when Prism::ConstantReadNode then node.name.to_s
130
+ when Prism::ConstantPathNode then constant_path_name(node)
131
+ end
132
+ end
133
+
134
+ def constant_path_name(node)
135
+ parent = node.parent
136
+ name = node.name&.to_s
137
+ return nil if name.nil?
138
+ return name if parent.nil? # `::Foo` — the table keys are unrooted, so drop the leading `::`
139
+
140
+ prefix = constant_name(parent)
141
+ prefix.nil? ? nil : "#{prefix}::#{name}"
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -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)