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
@@ -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
 
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class DryValidation < Rigor::Plugin::Base
8
+ # Issue #137 slices 2/3 — recognises the `<Const>.new.call(...).to_h` call chain and builds the
9
+ # per-contract `HashShape` from the entry {ContractScanner.scan_schema_blocks} collected.
10
+ module ParamsShape
11
+ module_function
12
+
13
+ # The Contract constant `to_h_node`'s receiver chain, or nil when the chain isn't the recognised
14
+ # `<Const>.new.call(...).to_h` form.
15
+ #
16
+ # Mirrors rigor-dry-schema's `ResultShape.schema_name` with one extra hop: a schema's `.call` is a
17
+ # bare class method on the schema constant itself, but a Contract's `.call` is an INSTANCE method
18
+ # (`NewUserContract.new.call(input)`), so the chain has to walk through `.new` first. Floor,
19
+ # matching dry-schema's own posture: the contract must be named by a constant as written at the
20
+ # call site. Reached through a local (`c = NewUserContract.new; c.call(x).to_h`) or a relative
21
+ # constant path, the chain contributes nothing and `to_h` types per the RBS overlay's generic
22
+ # `Hash[Symbol, untyped]` as it did before.
23
+ def contract_name(to_h_node)
24
+ return nil unless to_h_node.is_a?(Prism::CallNode) && to_h_node.name == :to_h
25
+ return nil unless to_h_node.arguments.nil? && to_h_node.block.nil?
26
+
27
+ call_node = to_h_node.receiver
28
+ return nil unless call_node.is_a?(Prism::CallNode) && call_node.name == :call
29
+
30
+ new_node = call_node.receiver
31
+ return nil unless new_node.is_a?(Prism::CallNode) && new_node.name == :new
32
+ return nil unless new_node.arguments.nil? && new_node.block.nil?
33
+
34
+ constant_name(new_node.receiver)
35
+ end
36
+
37
+ # Delegates the entry -> HashShape build to rigor-dry-schema's own
38
+ # {DrySchema::ResultShape.build} — the identical algorithm the entry was collected with (via
39
+ # {ContractScanner.scan_schema_blocks}, which itself delegates to
40
+ # {DrySchema::SchemaScanner.collect_schema_shape}). Only ever called once the caller has already
41
+ # confirmed rigor-dry-schema is loaded.
42
+ def build(entry)
43
+ DrySchema::ResultShape.build(entry)
44
+ end
45
+
46
+ # Renders `Foo` / `Foo::Bar` / `::Foo::Bar` as the `::`-joined String {ContractScanner}'s table is
47
+ # keyed by. Duplicated from rigor-dry-schema's identical helper rather than shared — five lines,
48
+ # no dependency on anything ELSE in that plugin, and reusing it would need a `registered_for`
49
+ # guard here too even though this leaf never touches dry-schema's own type/alias machinery.
50
+ def constant_name(node)
51
+ case node
52
+ when Prism::ConstantReadNode then node.name.to_s
53
+ when Prism::ConstantPathNode then constant_path_name(node)
54
+ end
55
+ end
56
+
57
+ def constant_path_name(node)
58
+ parent = node.parent
59
+ name = node.name&.to_s
60
+ return nil if name.nil?
61
+ return name if parent.nil? # `::Foo` — the table keys are unrooted, so drop the leading `::`
62
+
63
+ prefix = constant_name(parent)
64
+ prefix.nil? ? nil : "#{prefix}::#{name}"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -5,6 +5,7 @@ require "prism"
5
5
  require "rigor/plugin"
6
6
 
7
7
  require_relative "dry_validation/contract_scanner"
8
+ require_relative "dry_validation/params_shape"
8
9
 
9
10
  module Rigor
10
11
  module Plugin
@@ -21,13 +22,21 @@ module Rigor
21
22
  # Result) and `Dry::Validation::Result#{success?, failure?, to_h}`. The manifest's
22
23
  # `signature_paths: ["sig"]` auto-contributes the overlay (ADR-25) — no project-side wiring needed.
23
24
  #
24
- # Slice 2 (deferred, per design note):
25
+ # Landed since (issue #137):
25
26
  #
26
- # - Integrate with `:dry_schema_table` (published by `rigor-dry-schema`) so the `params { ... }`
27
- # block inside a Contract contributes a typed `result.to_h` shape per the schema. Until this lands,
28
- # `result.to_h` types as `Hash[Symbol, untyped]` (the generic RBS overlay shape).
27
+ # - **Slice 2** when `rigor-dry-schema` is loaded, a Contract's `params { ... }` block (the SAME
28
+ # dry-schema DSL a top-level `Dry::Schema.X { ... }` body uses) refines
29
+ # `NewUserContract.new.call(input).to_h` from the RBS overlay's generic `Hash[Symbol, untyped]` to
30
+ # the schema-typed `HashShape`. See {ContractScanner.scan_schema_blocks} + {ParamsShape}.
31
+ # - **Slice 3** — `json { ... }` adapter parity with `params { ... }`. Same mechanism, same fact.
32
+ # - **`dry-validation.rule-key-mismatch`** — a `rule(:key)` referencing a key absent from the
33
+ # Contract's own `params`/`json` block. Hard-gated: see {ContractScanner.rule_key_issues}'s doc for
34
+ # the two all-or-nothing checks that keep it silent on anything not statically resolvable.
29
35
  #
30
- # Slice 3 (deferred): `json { ... }` adapter parity with `params { ... }`. Same shape as slice 2.
36
+ # Without `rigor-dry-schema` loaded, `result.to_h` still types per the RBS overlay's generic
37
+ # `Hash[Symbol, untyped]` and `rule-key-mismatch` never fires — this plugin ships no required/optional
38
+ # walker of its own; it delegates to dry-schema's (see {ContractScanner}'s module doc for why that is
39
+ # a deliberate hard precondition, not a degrade-gracefully fallback).
31
40
  #
32
41
  # No ADR-3 amendment is needed for the validation surface itself; `Dry::Validation::Result` is a
33
42
  # generic class, not a sum type (the `success?` / `failure?` predicates narrow via existing bool flow
@@ -36,31 +45,98 @@ module Rigor
36
45
  manifest(
37
46
  id: "dry-validation",
38
47
  version: "0.1.0",
39
- description: "Recognises `class T < Dry::Validation::Contract` subclasses and " \
40
- "publishes the contract FQN set.",
41
- produces: [:dry_validation_contracts],
48
+ description: "Recognises `class T < Dry::Validation::Contract` subclasses, publishes the " \
49
+ "contract FQN set, and (with rigor-dry-schema loaded) refines each contract's " \
50
+ "`result.to_h` to its `params`/`json` schema shape.",
51
+ produces: %i[dry_validation_contracts dry_validation_params],
52
+ consumes: [{ plugin_id: "dry-types", name: :dry_type_aliases, optional: true }],
42
53
  # Auto-contribute the bundled RBS overlay (Contract#call -> Result, Result#success?/#to_h/...)
43
54
  # per ADR-25, so no project-side signature_paths wiring is needed.
44
55
  signature_paths: ["sig"]
45
56
  )
46
57
 
58
+ # ADR-37 dynamic return — refines `NewUserContract.new.call(input).to_h` to the per-contract
59
+ # `HashShape` when {#result_shape_for} finds one. Gated on the method name alone (mirrors
60
+ # rigor-dry-schema's own `.to_h` rule): the RBS overlay types `Contract#call`'s return as the
61
+ # generic `Result`, but that alone can't select a PER-CONTRACT shape, so the block does its own
62
+ # syntactic chain match via {ParamsShape.contract_name} before touching the table. When
63
+ # rigor-dry-schema isn't loaded, or the chain isn't a Contract call, this returns nil and the RBS
64
+ # overlay's generic `Hash[Symbol, untyped]` stands.
65
+ dynamic_return methods: [:to_h] do |call_node, _scope|
66
+ result_shape_for(call_node)
67
+ end
68
+
69
+ # ADR-37 slice 1c two-pass — the "collect" half of `dry-validation.rule-key-mismatch`. Reads the
70
+ # SAME `:dry_type_aliases` fact `#prepare` does (a plain `services.fact_store.read`, not
71
+ # `#read_fact`'s memoised wrapper — `services` at collect time is the run-wide instance, so an
72
+ # in-block memo isn't needed here the way it is across many per-call `dynamic_return` invocations).
73
+ node_file_context do |root, _scope|
74
+ type_aliases = services.fact_store.read(plugin_id: "dry-types", name: :dry_type_aliases) || {}
75
+ ContractScanner.rule_key_issues(root, type_aliases)
76
+ end
77
+
78
+ # Fires once per file (see rigor-dry-schema's identical `Prism::ProgramNode` rule for why).
79
+ node_rule Prism::ProgramNode do |_node, _scope, path, issues|
80
+ next [] if issues.nil? || issues.empty?
81
+
82
+ issues.map do |issue|
83
+ hint = Rigor::Plugin::Base.suggest(issue[:key], issue[:known_keys])
84
+ suffix = hint ? " (did you mean `:#{hint}`?)" : ""
85
+ diagnostic(
86
+ issue[:node],
87
+ path: path,
88
+ message: "rule(:#{issue[:key]}) references a key not declared by " \
89
+ "`#{issue[:contract_fqn]}`'s params/json schema#{suffix}",
90
+ severity: :error,
91
+ rule: "dry-validation.rule-key-mismatch"
92
+ )
93
+ end
94
+ end
95
+
47
96
  def prepare(services)
48
97
  contracts = ContractScanner.scan(paths: scannable_paths(services))
49
- return if contracts.empty?
98
+ unless contracts.empty?
99
+ services.fact_store.publish(
100
+ plugin_id: manifest.id,
101
+ name: :dry_validation_contracts,
102
+ value: contracts
103
+ )
104
+ end
105
+
106
+ type_aliases = services.fact_store.read(plugin_id: "dry-types", name: :dry_type_aliases) || {}
107
+ params_table = ContractScanner.scan_schema_blocks(paths: scannable_paths(services), type_aliases: type_aliases)
108
+ return if params_table.empty?
50
109
 
51
110
  services.fact_store.publish(
52
111
  plugin_id: manifest.id,
53
- name: :dry_validation_contracts,
54
- value: contracts
112
+ name: :dry_validation_params,
113
+ value: params_table
55
114
  )
56
115
  end
57
116
 
58
117
  def init(_services)
59
118
  @scannable_paths = nil
119
+ @params_shapes = {}
60
120
  end
61
121
 
62
122
  private
63
123
 
124
+ # The HashShape for a `<Const>.new.call(...).to_h` chain, or nil when the chain isn't that shape,
125
+ # names no contract in the table, or the contract's `params`/`json` shape declares no key.
126
+ # `params` wins over `json` when (unusually) a Contract declares both — mirrors dry-schema's own
127
+ # per-name memoisation, including nil, for the same reason: the miss is the common case.
128
+ def result_shape_for(call_node)
129
+ name = ParamsShape.contract_name(call_node)
130
+ return nil if name.nil?
131
+
132
+ shapes = (@params_shapes ||= {})
133
+ return shapes[name] if shapes.key?(name)
134
+
135
+ entry = read_fact(plugin_id: manifest.id, name: :dry_validation_params)&.[](name)
136
+ row = entry && (entry[:params] || entry[:json])
137
+ shapes[name] = row.nil? ? nil : ParamsShape.build(row)
138
+ end
139
+
64
140
  def scannable_paths(services)
65
141
  @scannable_paths ||= services.configuration.paths.flat_map do |entry|
66
142
  if File.directory?(entry)
@@ -60,9 +60,11 @@ module Rigor
60
60
  end
61
61
 
62
62
  # Return value contract:
63
- # - `String` (non-empty) → successful synthesis
64
- # - `nil` → no contribution
65
- # - `[:error, message_string]` → parse failed, surface info diagnostic per ADR-32 WD6
63
+ # - `String` (non-empty) → successful synthesis
64
+ # - `nil` → no contribution
65
+ # - `[:error, message_string]` → parse failed, surface info diagnostic per ADR-32 WD6
66
+ # - `[:ok, source, [message, …]]` → synthesis succeeded, but an annotation was parsed and NOT
67
+ # honoured; surface info diagnostics per ADR-32 WD12
66
68
  def call(source_file_path)
67
69
  return nil unless RBS_INLINE_AVAILABLE
68
70
  return nil unless File.file?(source_file_path)
@@ -83,7 +85,8 @@ module Rigor
83
85
  rendered = ::RBS::Inline::Writer.write(uses, decls, rbs_decls)
84
86
  return nil if rendered.nil? || rendered.strip.empty?
85
87
 
86
- rendered
88
+ notices = unhonoured_annotations(result)
89
+ notices.empty? ? rendered : [:ok, rendered, notices]
87
90
  rescue ::StandardError => e
88
91
  # WD6 fail-soft — surface a structured error tuple so the engine's `Environment.for_project` can
89
92
  # emit a `source-rbs-synthesis-failed` info diagnostic naming the file + the upstream error
@@ -113,6 +116,33 @@ module Rigor
113
116
  .any? { |parsed| parsed.each_annotation.any? }
114
117
  end
115
118
 
119
+ # ADR-32 WD12 — annotations upstream's parser ACCEPTS and its writer then contributes nothing from.
120
+ # These are invisible without a report: synthesis succeeds, and the annotation comment is even echoed
121
+ # into the generated RBS, so the omission shows up neither in the output nor at runtime.
122
+ #
123
+ # The one case today is `module-self`, where the two inline-RBS dialects disagree on spelling. rbs's
124
+ # own `docs/inline.md` documents `# @rbs module-self: Foo`; the rbs-inline gem's grammar is
125
+ # `# @rbs module-self Foo`, without the colon. Handed the colon form the gem still builds a
126
+ # `ModuleSelf` annotation but extracts no types from it, so an empty `self_types` on a parsed
127
+ # annotation is a precise signature for "the author asked for a constraint we did not apply".
128
+ # Measured both ways in `docs/notes/20260730-inline-rbs-parser-grammar-diff.md`.
129
+ #
130
+ # Deliberately narrow. A construct the gem's parser REJECTS already routes through WD6's error path,
131
+ # and one it never recognised at all is upstream's grammar to define (WD3) — guessing at those would
132
+ # make this a lint on comment prose, which is exactly the false-positive cost ADR-5 ranks first.
133
+ def unhonoured_annotations(prism_result)
134
+ ::RBS::Inline::AnnotationParser.parse(prism_result.comments).flat_map do |parsed|
135
+ parsed.each_annotation.filter_map do |annotation|
136
+ next unless annotation.is_a?(::RBS::Inline::AST::Annotations::ModuleSelf)
137
+ next unless annotation.self_types.empty?
138
+
139
+ "`@rbs module-self` contributed no self-type constraint. Rigor reads the " \
140
+ "`# @rbs module-self Foo` spelling; `# @rbs module-self: Foo` (the spelling in rbs's own " \
141
+ "inline documentation) is not honoured here."
142
+ end
143
+ end.uniq
144
+ end
145
+
116
146
  # Rewrite every RDoc directive comment to its spaced spelling (`#:nodoc:` -> `# :nodoc:`) so
117
147
  # upstream's annotation grammar never sees it, and re-parse. Two reasons this happens here rather
118
148
  # than in {#annotated?}: the directive must not gate a file in, AND it must not reach the synthesis,