rigortype 0.3.1 → 0.3.3

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 (116) 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/04-diagnostics.md +8 -0
  15. data/docs/manual/12-caching.md +17 -5
  16. data/docs/manual/15-type-protection-coverage.md +122 -1
  17. data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
  18. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
  19. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
  20. data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
  21. data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
  22. data/lib/rigor/analysis/check_rules.rb +107 -28
  23. data/lib/rigor/analysis/incremental_session.rb +156 -11
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
  25. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  26. data/lib/rigor/analysis/run_cache_key.rb +17 -1
  27. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
  28. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
  29. data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
  30. data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
  31. data/lib/rigor/analysis/runner.rb +78 -7
  32. data/lib/rigor/analysis/severity_stamp.rb +2 -1
  33. data/lib/rigor/bleeding_edge.rb +132 -7
  34. data/lib/rigor/cache/descriptor.rb +6 -1
  35. data/lib/rigor/cache/engine_source.rb +162 -0
  36. data/lib/rigor/cache/incremental_snapshot.rb +87 -18
  37. data/lib/rigor/cli/check_command.rb +49 -28
  38. data/lib/rigor/cli/coverage_command.rb +15 -2
  39. data/lib/rigor/cli/coverage_mutation.rb +242 -11
  40. data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
  41. data/lib/rigor/cli/fused_protection_report.rb +12 -3
  42. data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
  43. data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
  44. data/lib/rigor/cli/mutation_protection_report.rb +26 -5
  45. data/lib/rigor/cli/protection_renderer.rb +13 -0
  46. data/lib/rigor/cli/protection_report.rb +11 -3
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
  48. data/lib/rigor/cli/sig_gen_command.rb +5 -4
  49. data/lib/rigor/configuration.rb +45 -6
  50. data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
  51. data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
  52. data/lib/rigor/environment/rbs_loader.rb +434 -75
  53. data/lib/rigor/environment.rb +28 -13
  54. data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
  55. data/lib/rigor/inference/expression_typer.rb +62 -4
  56. data/lib/rigor/inference/fork_map.rb +47 -4
  57. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
  58. data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
  59. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
  60. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +158 -12
  61. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
  62. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +234 -11
  63. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
  64. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
  65. data/lib/rigor/inference/method_dispatcher.rb +41 -10
  66. data/lib/rigor/inference/mutation_widening.rb +30 -25
  67. data/lib/rigor/inference/narrowing.rb +3 -1
  68. data/lib/rigor/inference/optimistic_origin.rb +123 -0
  69. data/lib/rigor/inference/protection_scanner.rb +18 -2
  70. data/lib/rigor/inference/receiver_alias.rb +57 -0
  71. data/lib/rigor/inference/scope_indexer.rb +197 -55
  72. data/lib/rigor/inference/singleton_object_constant.rb +63 -0
  73. data/lib/rigor/inference/statement_evaluator.rb +55 -4
  74. data/lib/rigor/language_server/buffer_table.rb +22 -0
  75. data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
  76. data/lib/rigor/language_server/project_context.rb +45 -0
  77. data/lib/rigor/language_server/publish_batcher.rb +82 -0
  78. data/lib/rigor/language_server/server.rb +20 -1
  79. data/lib/rigor/language_server.rb +1 -0
  80. data/lib/rigor/plugin/node_rule_walk.rb +6 -0
  81. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
  82. data/lib/rigor/protection/closure_kill_oracle.rb +190 -0
  83. data/lib/rigor/protection/dependency_closure.rb +59 -0
  84. data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
  85. data/lib/rigor/protection/discovery_seed.rb +116 -0
  86. data/lib/rigor/protection/kill_signature.rb +31 -0
  87. data/lib/rigor/protection/mutation_cache.rb +355 -0
  88. data/lib/rigor/protection/mutation_scanner.rb +55 -11
  89. data/lib/rigor/protection/mutator.rb +26 -4
  90. data/lib/rigor/runtime/jit.rb +63 -1
  91. data/lib/rigor/scope/discovery_index.rb +9 -0
  92. data/lib/rigor/scope.rb +114 -10
  93. data/lib/rigor/sig_gen/generator.rb +130 -31
  94. data/lib/rigor/sig_gen/layout_index.rb +6 -0
  95. data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
  96. data/lib/rigor/sig_gen/renderer.rb +20 -1
  97. data/lib/rigor/sig_gen/write_result.rb +6 -4
  98. data/lib/rigor/sig_gen/writer.rb +12 -0
  99. data/lib/rigor/sig_gen.rb +1 -0
  100. data/lib/rigor/source/node_walker.rb +12 -0
  101. data/lib/rigor/type/anonymous_class_name.rb +40 -0
  102. data/lib/rigor/type/nominal.rb +5 -2
  103. data/lib/rigor/type/singleton.rb +4 -1
  104. data/lib/rigor/version.rb +1 -1
  105. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
  106. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
  107. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
  108. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
  109. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
  110. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
  111. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
  112. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
  113. data/sig/rigor/inference/void_origin.rbs +6 -3
  114. data/sig/rigor/scope.rbs +12 -0
  115. data/sig/rigor.rbs +4 -0
  116. metadata +21 -1
@@ -14,6 +14,14 @@ module Rigor
14
14
  #
15
15
  # Non-Prism children (literals embedded in node attributes, virtual nodes, or `nil` slots) are silently
16
16
  # skipped so callers can rely on every yielded value responding to the `Prism::Node` API.
17
+ #
18
+ # Issue #318 — a `Prism::DefinedNode`'s operand is never evaluated at runtime (`defined?` inspects the
19
+ # expression statically; it does not run it), so the walk yields the `DefinedNode` itself but does NOT
20
+ # descend into its `value` subtree. Every consumer of this walker treats a yielded node as "reachable,
21
+ # evaluated code" (mutation/break/return scans, the check-rules main-pass oracle, coverage and precision
22
+ # probes); walking into the operand would make them reason about code that can never run, which is
23
+ # exactly the false-positive class the issue reports (`defined?(@x) && ...` flagging a call that is
24
+ # actually inert).
17
25
  module NodeWalker
18
26
  module_function
19
27
 
@@ -30,6 +38,8 @@ module Rigor
30
38
  return unless node.is_a?(Prism::Node)
31
39
 
32
40
  yield node
41
+ return if node.is_a?(Prism::DefinedNode)
42
+
33
43
  node.rigor_each_child { |child| walk(child, &) }
34
44
  end
35
45
 
@@ -52,6 +62,8 @@ module Rigor
52
62
  return unless node.is_a?(Prism::Node)
53
63
 
54
64
  block.call(node, ancestors)
65
+ return if node.is_a?(Prism::DefinedNode)
66
+
55
67
  ancestors.push(node)
56
68
  node.rigor_each_child { |child| walk_with_ancestors(child, ancestors, &block) }
57
69
  ancestors.pop
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Type
5
+ # #319 — the spelling of a class that Ruby created without a name: the `Class.new do ... end` /
6
+ # `Module.new do ... end` form away from constant-write position, which owns whatever its block body defines
7
+ # but has no constant to be keyed by.
8
+ #
9
+ # `Inference::AnonymousMetaClass` decides WHICH call sites get one and what goes in the key; this module owns
10
+ # the spelling, because the two places a name reaches a human live here. `#<Label:key>` is deliberately
11
+ # unspellable as a Ruby constant path, so a synthetic name can never collide with a real class in the
12
+ # discovery tables.
13
+ #
14
+ # Both renderings drop the key. `describe` keeps only the label (`#<Class>`) because the key is a file
15
+ # position — reproducing it would pin every `assert_type` fixture and precision snapshot to a line number.
16
+ # `erase_to_rbs` answers `untyped`: the name is not valid RBS, and emitting it from `rigor sig-gen` would
17
+ # produce a signature file that does not parse.
18
+ module AnonymousClassName
19
+ module_function
20
+
21
+ PREFIX = "#<"
22
+
23
+ # `build("Class", "lib/a.rb:3:17") #=> "#<Class:lib/a.rb:3:17>"`
24
+ def build(label, key)
25
+ "#{PREFIX}#{label}:#{key}>"
26
+ end
27
+
28
+ def match?(class_name)
29
+ class_name.is_a?(String) && class_name.start_with?(PREFIX)
30
+ end
31
+
32
+ # The display form: the label alone, key dropped.
33
+ def display(class_name)
34
+ return class_name unless match?(class_name)
35
+
36
+ "#{PREFIX}#{class_name.delete_prefix(PREFIX).split(':', 2).first}>"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "../trinary"
4
4
  require_relative "../value_semantics"
5
5
  require_relative "acceptance_router"
6
+ require_relative "anonymous_class_name"
6
7
  require_relative "plain_lattice"
7
8
 
8
9
  module Rigor
@@ -35,13 +36,15 @@ module Rigor
35
36
  end
36
37
 
37
38
  def describe(verbosity = :short)
38
- return class_name if type_args.empty?
39
+ rendered_name = AnonymousClassName.display(class_name)
40
+ return rendered_name if type_args.empty?
39
41
 
40
42
  rendered = type_args.map { |t| t.describe(verbosity) }.join(", ")
41
- "#{class_name}[#{rendered}]"
43
+ "#{rendered_name}[#{rendered}]"
42
44
  end
43
45
 
44
46
  def erase_to_rbs
47
+ return "untyped" if AnonymousClassName.match?(class_name)
45
48
  return class_name if type_args.empty?
46
49
 
47
50
  rendered = type_args.map(&:erase_to_rbs).join(", ")
@@ -3,6 +3,7 @@
3
3
  require_relative "../trinary"
4
4
  require_relative "../value_semantics"
5
5
  require_relative "acceptance_router"
6
+ require_relative "anonymous_class_name"
6
7
  require_relative "plain_lattice"
7
8
 
8
9
  module Rigor
@@ -26,10 +27,12 @@ module Rigor
26
27
  end
27
28
 
28
29
  def describe(_verbosity = :short)
29
- "singleton(#{class_name})"
30
+ "singleton(#{AnonymousClassName.display(class_name)})"
30
31
  end
31
32
 
32
33
  def erase_to_rbs
34
+ return "untyped" if AnonymousClassName.match?(class_name)
35
+
33
36
  "singleton(#{class_name})"
34
37
  end
35
38
 
data/lib/rigor/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Rigor
4
- VERSION = "0.3.1"
4
+ VERSION = "0.3.3"
5
5
  end
@@ -248,10 +248,10 @@ class Time
248
248
  def self.zone: () -> untyped # ActiveSupport::TimeZone | nil
249
249
  def self.zone=: (String | Symbol | untyped) -> untyped
250
250
 
251
- # `core_ext/time/conversions` — also `Time.parse` from stdlib
252
- # `time`, which is already in core RBS.
253
- def self.httpdate: (String) -> Time
254
- def self.iso8601: (String) -> Time
251
+ # `core_ext/time/conversions` — nothing to declare. `Time.parse`, `Time.httpdate` and
252
+ # `Time.iso8601` all come from stdlib `time`, which rbs types; ActiveSupport only re-opens them.
253
+ # Declaring them here raised `RBS::DuplicatedMethodDefinitionError` and collapsed the whole `Time`
254
+ # definition to `Dynamic[top]`.
255
255
 
256
256
  # `core_ext/time/calculations`
257
257
  def yesterday: () -> Time
@@ -281,7 +281,7 @@ class Time
281
281
  def midday: () -> Time
282
282
  def midnight: () -> Time
283
283
  def noon: () -> Time
284
- def utc?: () -> bool
284
+ # NOTE: `Time#utc?` is NOT declared here — it is Ruby core, already typed by rbs's `core/time.rbs`.
285
285
  def acts_like_time?: () -> true
286
286
  # `advance(days: 1, months: -2)` and `all_day` (a `beginning_of_day..end_of_day` Range).
287
287
  def advance: (untyped options) -> Time
@@ -468,7 +468,7 @@ end
468
468
 
469
469
  class DateTime
470
470
  def utc: () -> Time
471
- def to_time: () -> Time
471
+ # NOTE: `DateTime#to_time` is NOT declared here — stdlib `date` already types it.
472
472
  def in_time_zone: (?String | Symbol zone) -> untyped
473
473
  def yesterday: () -> DateTime
474
474
  def tomorrow: () -> DateTime
@@ -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