rigortype 0.1.9 → 0.1.11

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 (158) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/rigor/analysis/baseline.rb +51 -15
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +57 -7
  9. data/lib/rigor/cli/baseline_command.rb +4 -3
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli.rb +88 -5
  15. data/lib/rigor/environment/rbs_loader.rb +46 -5
  16. data/lib/rigor/environment/reporters.rb +3 -2
  17. data/lib/rigor/environment.rb +159 -4
  18. data/lib/rigor/inference/def_return_typer.rb +98 -0
  19. data/lib/rigor/inference/expression_typer.rb +143 -12
  20. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
  21. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  22. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
  23. data/lib/rigor/inference/precision_scanner.rb +131 -0
  24. data/lib/rigor/inference/statement_evaluator.rb +26 -2
  25. data/lib/rigor/mcp/loop.rb +43 -0
  26. data/lib/rigor/mcp/server.rb +263 -0
  27. data/lib/rigor/mcp.rb +16 -0
  28. data/lib/rigor/plugin/base.rb +28 -5
  29. data/lib/rigor/plugin/manifest.rb +33 -5
  30. data/lib/rigor/plugin/registry.rb +21 -0
  31. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  32. data/lib/rigor/sig_gen/generator.rb +150 -75
  33. data/lib/rigor/type/combinator.rb +57 -0
  34. data/lib/rigor/version.rb +1 -1
  35. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
  36. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
  37. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
  38. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
  39. data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
  40. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
  41. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
  42. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
  43. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
  44. data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
  45. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
  46. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
  47. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
  49. data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
  50. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
  51. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
  52. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
  53. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
  54. data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
  55. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
  56. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
  57. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
  58. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
  59. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
  60. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
  61. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
  62. data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
  63. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
  64. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
  65. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
  66. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
  67. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
  68. data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
  69. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
  70. data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
  71. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
  72. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
  73. data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
  74. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
  75. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
  76. data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
  77. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
  78. data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
  79. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
  80. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
  81. data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
  82. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
  83. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
  84. data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
  85. data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
  86. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
  87. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
  88. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
  89. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
  90. data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
  91. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
  92. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
  93. data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
  94. data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
  95. data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
  96. data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
  97. data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
  98. data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
  99. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
  100. data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
  101. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
  102. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
  103. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
  104. data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
  105. data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
  106. data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
  107. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
  108. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
  109. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
  110. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
  111. data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
  112. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
  113. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
  114. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
  115. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
  116. data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
  117. data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
  118. data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
  119. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
  120. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
  121. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
  122. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
  125. data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
  126. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
  127. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
  128. data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
  129. data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
  130. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
  131. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
  132. data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
  133. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
  134. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
  135. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
  136. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
  137. data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
  138. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
  139. data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
  141. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
  142. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
  143. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
  144. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
  145. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
  146. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
  147. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
  148. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
  149. data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
  150. data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
  151. data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
  152. data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
  153. data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
  154. data/sig/rigor/analysis/baseline.rbs +39 -0
  155. data/sig/rigor/environment.rbs +3 -2
  156. data/sig/rigor/type.rbs +4 -0
  157. data/sig/rigor.rbs +2 -0
  158. metadata +180 -1
@@ -0,0 +1,285 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class DrySchema < Rigor::Plugin::Base
8
+ # Walks project source for `Foo = Dry::Schema.{Params,JSON,define}
9
+ # { ... }` shapes and emits a
10
+ # `{schema_const_fqn => {required: {key => underlying_class}, optional: {…}}}`
11
+ # table covering each schema's typed-key surface.
12
+ module SchemaScanner
13
+ # The dry-schema canonical-type symbols accepted as
14
+ # predicate arguments. Maps each to the underlying Ruby
15
+ # class name (the same vocabulary `rigor-dry-types`
16
+ # uses for `CANONICAL_ALIASES`, intersected with what
17
+ # dry-schema's predicate engine accepts).
18
+ CANONICAL_TYPES = {
19
+ string: "String",
20
+ integer: "Integer",
21
+ float: "Float",
22
+ decimal: "BigDecimal",
23
+ symbol: "Symbol",
24
+ bool: "TrueClass",
25
+ date: "Date",
26
+ date_time: "DateTime",
27
+ time: "Time",
28
+ hash: "Hash",
29
+ array: "Array"
30
+ }.tap { |h| h[:nil] = "NilClass" }.freeze
31
+
32
+ # The dry-schema predicate verbs that accept a type
33
+ # argument. Each verb has a slightly different runtime
34
+ # semantic (`filled` = present + non-empty; `value` =
35
+ # present; `maybe` = present-or-nil; `each` = collection
36
+ # element type) but for Rigor's purposes they all
37
+ # contribute the same underlying class for the key.
38
+ TYPE_BEARING_PREDICATES = %i[filled value maybe each].to_set.freeze
39
+ private_constant :TYPE_BEARING_PREDICATES
40
+
41
+ # The three entry-point method names on `Dry::Schema`.
42
+ SCHEMA_ENTRY_NAMES = %i[Params JSON define].to_set.freeze
43
+ private_constant :SCHEMA_ENTRY_NAMES
44
+
45
+ module_function
46
+
47
+ # @param paths [Array<String>] absolute paths to `.rb`
48
+ # files the project's `paths:` resolves to.
49
+ # @param type_aliases [Hash{String => String}] the
50
+ # ADR-9 `:dry_type_aliases` fact published by
51
+ # `rigor-dry-types` when loaded. Used to resolve
52
+ # `value(Types::Email)` references to their
53
+ # underlying class. Empty when the plugin isn't
54
+ # loaded.
55
+ # @return [Hash{String => Hash{Symbol => Hash{Symbol => String}}}]
56
+ # frozen per-schema typed-key table. Empty when no
57
+ # recognisable schema declaration is found.
58
+ def scan(paths:, type_aliases: {})
59
+ table = {}
60
+ paths.each do |path|
61
+ scan_file(path, type_aliases).each do |schema_const, shape|
62
+ table[schema_const] ||= shape
63
+ end
64
+ end
65
+ table.freeze
66
+ end
67
+
68
+ def scan_file(path, type_aliases)
69
+ source = File.read(path)
70
+ parse_result = Prism.parse(source, filepath: path)
71
+ return {} unless parse_result.errors.empty?
72
+
73
+ collect_schemas(parse_result.value, [], type_aliases)
74
+ rescue StandardError
75
+ {}
76
+ end
77
+ private_class_method :scan_file
78
+
79
+ # Walks the AST collecting `<Const> = Dry::Schema.X { ... }`
80
+ # assignments at any nesting level. Tracks the enclosing
81
+ # constant chain so a class-level `class Foo; SCHEMA =
82
+ # Dry::Schema.Params { ... }; end` registers as
83
+ # `"Foo::SCHEMA"`.
84
+ def collect_schemas(node, qualified_prefix, type_aliases)
85
+ return {} if node.nil?
86
+
87
+ case node
88
+ when Prism::ConstantWriteNode
89
+ collect_schema_assignment(node, qualified_prefix, type_aliases)
90
+ when Prism::ClassNode
91
+ inner_name = constant_name_for(node.constant_path)
92
+ return {} if inner_name.nil?
93
+
94
+ collect_schemas(node.body, qualified_prefix + [inner_name], type_aliases)
95
+ when Prism::ModuleNode
96
+ inner_name = constant_name_for(node.constant_path)
97
+ return {} if inner_name.nil?
98
+
99
+ collect_schemas(node.body, qualified_prefix + [inner_name], type_aliases)
100
+ else
101
+ node.compact_child_nodes.each_with_object({}) do |child, acc|
102
+ collect_schemas(child, qualified_prefix, type_aliases).each do |k, v|
103
+ acc[k] ||= v
104
+ end
105
+ end
106
+ end
107
+ end
108
+ private_class_method :collect_schemas
109
+
110
+ def collect_schema_assignment(node, qualified_prefix, type_aliases)
111
+ rhs = node.value
112
+ return {} unless schema_entry_call?(rhs)
113
+ return {} unless rhs.is_a?(Prism::CallNode) && rhs.block
114
+
115
+ schema_const = (qualified_prefix + [node.name.to_s]).join("::")
116
+ shape = collect_schema_shape(rhs.block, type_aliases)
117
+ { schema_const => shape }
118
+ end
119
+ private_class_method :collect_schema_assignment
120
+
121
+ # Matches `Dry::Schema.Params { ... }` /
122
+ # `Dry::Schema.JSON { ... }` / `Dry::Schema.define { ... }`.
123
+ def schema_entry_call?(node)
124
+ return false unless node.is_a?(Prism::CallNode)
125
+ return false unless SCHEMA_ENTRY_NAMES.include?(node.name)
126
+
127
+ receiver = node.receiver
128
+ receiver.is_a?(Prism::ConstantPathNode) &&
129
+ receiver.name == :Schema &&
130
+ receiver.parent.is_a?(Prism::ConstantReadNode) &&
131
+ receiver.parent.name == :Dry
132
+ end
133
+ private_class_method :schema_entry_call?
134
+
135
+ def collect_schema_shape(block_node, type_aliases)
136
+ required = {}
137
+ optional = {}
138
+ walk_block_body(block_node) do |kind, key, type_info|
139
+ (kind == :required ? required : optional)[key] = type_info if type_info
140
+ end
141
+
142
+ remap_aliases!(required, type_aliases)
143
+ remap_aliases!(optional, type_aliases)
144
+
145
+ { required: required.freeze, optional: optional.freeze }.freeze
146
+ end
147
+ private_class_method :collect_schema_shape
148
+
149
+ # Walks every top-level `required(:key).<predicate>(...)` /
150
+ # `optional(:key).<predicate>(...)` chain in the block
151
+ # body. The block's body is either a `Prism::StatementsNode`
152
+ # (multi-statement) or a single expression node.
153
+ def walk_block_body(block_node, &)
154
+ body = block_node.body
155
+ return if body.nil?
156
+
157
+ children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
158
+ children.each { |child| visit_chain(child, &) }
159
+ end
160
+ private_class_method :walk_block_body
161
+
162
+ # `required(:key).filled(:string)` parses as a CallNode
163
+ # whose receiver is the `required(:key)` call. Walk the
164
+ # chain inward looking for the type-bearing predicate at
165
+ # the head; the key sits on the chain's tail. The
166
+ # `each(<Type>)` predicate yields a list-of-element
167
+ # type info (`{type: <T>, list: true}`); other type-bearing
168
+ # predicates (`filled`/`value`/`maybe`) yield scalar info
169
+ # (`{type: <T>, list: false}`).
170
+ def visit_chain(node, &block)
171
+ return unless node.is_a?(Prism::CallNode)
172
+
173
+ key, kind = extract_key_and_kind(node)
174
+ return if key.nil?
175
+
176
+ type_info = walk_predicate_chain(node)
177
+ block.call(kind, key, type_info)
178
+ end
179
+ private_class_method :visit_chain
180
+
181
+ # `required(:key).filled(:string).value(...)...` — the
182
+ # OUTERMOST call's receiver chain ends in the
183
+ # `required(:key)` / `optional(:key)` call. Recurse on
184
+ # `node.receiver` until we hit the `required` /
185
+ # `optional` call, recording the key + kind.
186
+ def extract_key_and_kind(node)
187
+ current = node
188
+ while current.is_a?(Prism::CallNode)
189
+ if %i[required optional].include?(current.name)
190
+ key_node = current.arguments&.arguments&.first
191
+ return [nil, nil] unless key_node.is_a?(Prism::SymbolNode)
192
+
193
+ return [key_node.unescaped.to_sym, current.name]
194
+ end
195
+ current = current.receiver
196
+ end
197
+ [nil, nil]
198
+ end
199
+ private_class_method :extract_key_and_kind
200
+
201
+ # Walks the call chain finding the first type-bearing
202
+ # predicate (`filled` / `value` / `maybe` / `each`) and
203
+ # extracts its argument type. Returns a `{type:, list:}`
204
+ # tuple (`each` is the only verb that produces a list)
205
+ # or nil when no recognisable type sits on the chain.
206
+ def walk_predicate_chain(node)
207
+ current = node
208
+ while current.is_a?(Prism::CallNode)
209
+ if TYPE_BEARING_PREDICATES.include?(current.name)
210
+ underlying = extract_type_from_predicate(current)
211
+ return { type: underlying, list: current.name == :each } if underlying
212
+ end
213
+ current = current.receiver
214
+ end
215
+ nil
216
+ end
217
+ private_class_method :walk_predicate_chain
218
+
219
+ # Reads the first positional argument of a `filled(:string)`
220
+ # / `value(:integer)` / `maybe(Types::Email)` call. Returns
221
+ # either the canonical-type-symbol's underlying class
222
+ # ("String" / "Integer" / …), or the constant's qualified
223
+ # name for downstream type-alias resolution. Returns nil
224
+ # for anything else.
225
+ def extract_type_from_predicate(call_node)
226
+ arg = call_node.arguments&.arguments&.first
227
+ case arg
228
+ when Prism::SymbolNode
229
+ CANONICAL_TYPES[arg.unescaped.to_sym]
230
+ when Prism::ConstantReadNode
231
+ arg.name.to_s
232
+ when Prism::ConstantPathNode
233
+ constant_name_for(arg)
234
+ end
235
+ end
236
+ private_class_method :extract_type_from_predicate
237
+
238
+ # In-place: any value's `type:` slot in `bucket` that
239
+ # doesn't already match a canonical class (e.g.
240
+ # `"Types::Email"`) gets resolved through the
241
+ # type_aliases fact. Unresolvable values drop from the
242
+ # bucket (no fact contribution rather than misleading
243
+ # data). The `list:` slot rides along unchanged.
244
+ def remap_aliases!(bucket, type_aliases)
245
+ canonical_set = CANONICAL_TYPES.values.to_set
246
+ bucket.each_pair.to_a.each do |key, info|
247
+ type_name = info.fetch(:type)
248
+ next if canonical_set.include?(type_name)
249
+
250
+ resolved = type_aliases[type_name]
251
+ if resolved
252
+ bucket[key] = info.merge(type: resolved)
253
+ else
254
+ bucket.delete(key)
255
+ end
256
+ end
257
+ end
258
+ private_class_method :remap_aliases!
259
+
260
+ # Constant-path serialiser: `Dry::Schema` -> "Dry::Schema",
261
+ # bare `Foo` -> "Foo". Returns nil for shapes Prism
262
+ # doesn't expose as ConstantRead/PathNode.
263
+ def constant_name_for(node)
264
+ return nil if node.nil?
265
+
266
+ case node
267
+ when Prism::ConstantReadNode then node.name.to_s
268
+ when Prism::ConstantPathNode
269
+ parts = []
270
+ current = node
271
+ while current.is_a?(Prism::ConstantPathNode)
272
+ parts.unshift(current.name.to_s)
273
+ current = current.parent
274
+ end
275
+ case current
276
+ when nil then "::#{parts.join('::')}"
277
+ when Prism::ConstantReadNode then "#{current.name}::#{parts.join('::')}"
278
+ end
279
+ end
280
+ end
281
+ private_class_method :constant_name_for
282
+ end
283
+ end
284
+ end
285
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require "rigor/plugin"
6
+
7
+ require_relative "dry_schema/schema_scanner"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ # rigor-dry-schema — Tier A per
12
+ # [ADR-12](../../../../../docs/adr/12-dry-rb-packaging.md) and the
13
+ # slicing plan in [docs/design/20260517-dry-validation-slicing.md](../../../../../docs/design/20260517-dry-validation-slicing.md).
14
+ #
15
+ # Recognises the canonical dry-schema declaration shapes:
16
+ #
17
+ # NewUserSchema = Dry::Schema.Params do
18
+ # required(:email).filled(:string)
19
+ # required(:age).value(:integer)
20
+ # optional(:nickname).maybe(:string)
21
+ # end
22
+ #
23
+ # ProductJSON = Dry::Schema.JSON do
24
+ # required(:sku).filled(:string)
25
+ # end
26
+ #
27
+ # RawSchema = Dry::Schema.define do
28
+ # required(:foo).value(:string)
29
+ # end
30
+ #
31
+ # and publishes the resulting
32
+ # `{schema_const_fqn => {required: {key => underlying_class}, optional: {…}}}`
33
+ # table as the `:dry_schema_table` cross-plugin fact (ADR-9).
34
+ # Downstream `rigor-dry-validation` consumes the fact for
35
+ # per-Contract typed-payload synthesis.
36
+ #
37
+ # ## Predicate type recognition
38
+ #
39
+ # Each `required(:key).<predicate>(<arg>)` row maps the predicate
40
+ # argument to an underlying Ruby class via the dry-schema
41
+ # canonical-type vocabulary:
42
+ #
43
+ # - `:string` / `:integer` / `:float` / `:decimal` / `:symbol` /
44
+ # `:bool` / `:nil` / `:date` / `:date_time` / `:time` / `:hash`
45
+ # / `:array` map to their underlying class.
46
+ # - The four predicate verbs `filled` / `value` / `maybe` /
47
+ # `each` are accepted on the same row; their semantic
48
+ # difference (whether the value is nullable or coerced) does
49
+ # not change the underlying class for Rigor's purposes.
50
+ # - References to dry-types aliases (`value(Types::Email)`,
51
+ # `filled(Types::String)`) resolve through the
52
+ # `:dry_type_aliases` ADR-9 fact published by `rigor-dry-types`
53
+ # when that plugin is loaded; without it the row degrades to
54
+ # "no type contribution from this key".
55
+ #
56
+ # ## Floor / ceiling (slice 1)
57
+ #
58
+ # Slice 1 ships the **floor**:
59
+ #
60
+ # - Top-level `Foo = Dry::Schema.{Params,JSON,define} { ... }`
61
+ # assignments. Class-level constants (`class Bar; SCHEMA =
62
+ # Dry::Schema.Params { ... }; end`) work too — the walker
63
+ # prefixes the enclosing constant chain.
64
+ # - `required(:key).<predicate>(:type_symbol_or_constant)` rows
65
+ # for the canonical-type vocabulary above.
66
+ # - Publishes the table; no user-facing diagnostics yet.
67
+ #
68
+ # The **ceiling** (slice 2+):
69
+ #
70
+ # - Synthesise typed `result.to_h` returns from each schema
71
+ # via ADR-16 Tier C heredoc-template substrate.
72
+ # - Nested schemas (`schema(do ... end)` inside another row).
73
+ # - `predicates(:size?)` / `each { ... }` recursion.
74
+ # - Per-row `dry-schema.unknown-predicate` /
75
+ # `dry-schema.unknown-type` `:info` diagnostics when a
76
+ # row's predicate or type symbol isn't recognised.
77
+ class DrySchema < Rigor::Plugin::Base
78
+ manifest(
79
+ id: "dry-schema",
80
+ version: "0.1.0",
81
+ description: "Recognises `Dry::Schema.{Params,JSON,define} { ... }` declarations " \
82
+ "and publishes the per-schema typed-key table.",
83
+ produces: [:dry_schema_table],
84
+ consumes: [{ plugin_id: "dry-types", name: :dry_type_aliases, optional: true }]
85
+ )
86
+
87
+ # Walks every project file once during `prepare(services)` to
88
+ # build the schema table, then publishes via the ADR-9 fact
89
+ # store. Mirrors the rigor-dry-types `#prepare` shape — the
90
+ # walk is bounded by `paths:`, parse errors degrade silently.
91
+ def prepare(services)
92
+ type_aliases = services.fact_store.read(plugin_id: "dry-types", name: :dry_type_aliases) || {}
93
+ table = SchemaScanner.scan(paths: scannable_paths(services), type_aliases: type_aliases)
94
+ return if table.empty?
95
+
96
+ services.fact_store.publish(
97
+ plugin_id: manifest.id,
98
+ name: :dry_schema_table,
99
+ value: table
100
+ )
101
+ end
102
+
103
+ def init(_services)
104
+ @scannable_paths = nil
105
+ end
106
+
107
+ private
108
+
109
+ def scannable_paths(services)
110
+ @scannable_paths ||= services.configuration.paths.flat_map do |entry|
111
+ if File.directory?(entry)
112
+ Dir.glob(File.join(entry, "**", "*.rb"), sort: true)
113
+ elsif File.file?(entry) && entry.end_with?(".rb")
114
+ [entry]
115
+ else
116
+ []
117
+ end
118
+ end.uniq.freeze
119
+ end
120
+ end
121
+
122
+ Rigor::Plugin.register(DrySchema)
123
+ end
124
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Gem entry point. Required by Rigor's plugin loader when
4
+ # `.rigor.yml` lists `rigor-dry-schema` under `plugins:`. The
5
+ # loader expects this `require` to side-effect a call to
6
+ # `Rigor::Plugin.register`, which the body of
7
+ # `lib/rigor/plugin/dry_schema.rb` performs at load time.
8
+ require_relative "rigor/plugin/dry_schema"
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rigor/plugin"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ # ADR-16 Tier C worked plugin: recognises dry-struct's class-
8
+ # level `attribute :name, T` DSL and synthesises a reader on
9
+ # the enclosing `Dry::Struct` subclass.
10
+ #
11
+ # dry-struct's `Dry::Struct::ClassInterface#attribute` (per the
12
+ # per-library survey, `lib/dry/struct/class_interface.rb:86-88`
13
+ # in the upstream gem) is the textbook Tier C target — a
14
+ # class-level DSL call enumerates a literal Symbol argument, and
15
+ # `class_interface.rb:452-464` `class_eval`s a heredoc
16
+ # interpolating that Symbol into a getter:
17
+ #
18
+ # class_eval(<<-RUBY, __FILE__, __LINE__ + 1)
19
+ # def #{key} # def city
20
+ # @attributes[#{key.inspect}] # @attributes[:city]
21
+ # end
22
+ # RUBY
23
+ #
24
+ # The substrate replays the same contract statically. For
25
+ # source like:
26
+ #
27
+ # class Address < Dry::Struct
28
+ # attribute :city, Types::String
29
+ # attribute :country, Types::String
30
+ # end
31
+ #
32
+ # the pre-pass scans the file, sees `attribute :city, ...`, and
33
+ # synthesises `Address#city` as a SyntheticMethod the dispatcher
34
+ # surfaces below RBS dispatch. Bare `address.city` calls in
35
+ # other files then dispatch through the synthetic record rather
36
+ # than falling through to `call.undefined-method`.
37
+ #
38
+ # ## Floor / ceiling per ADR-16 WD13
39
+ #
40
+ # Slice 2 ships at the **floor**: the synthetic reader's return
41
+ # type degrades to `Dynamic[T]`. The manifest's `returns: "Object"`
42
+ # is recorded but not resolved — precise return-type promotion
43
+ # (so `attribute :city, Types::String` makes `address.city`
44
+ # return `String`) is the **ceiling**, deferred to slice 6
45
+ # (ADR-13 `Plugin::TypeNodeResolver` chain). The plugin's manifest
46
+ # value of `returns:` would today be the upstream gem's reader
47
+ # return shape; slice 6 unlocks precision without re-authoring.
48
+ #
49
+ # ## Scope (slice 2c minimum)
50
+ #
51
+ # - Recognises `attribute :name, T` at class body top level.
52
+ # - Recognises `attribute? :name, T` via a separate template
53
+ # entry (omittable attribute; same reader name, `?` stripped).
54
+ # - Synthesises **only the reader** — the other survey-listed
55
+ # emit rows (`schema` key, `to_h` row, `[:key]` access, `.new`
56
+ # kwarg) are not yet wired by the substrate (they require
57
+ # either RBS-level shape synthesis or additional substrate
58
+ # primitives). Slice 2c stops at the reader.
59
+ # - Nested-block form (`attribute :details do ... end` minting
60
+ # `Address::Details`) is out of scope for slice 2c; that
61
+ # pattern needs Tier A + Tier C composition + const_set
62
+ # emission. Deferred.
63
+ class DryStruct < Rigor::Plugin::Base
64
+ manifest(
65
+ id: "dry-struct",
66
+ version: "0.2.0",
67
+ description: "Recognises dry-struct `attribute :name, T` DSL via ADR-16 Tier C; " \
68
+ "promotes the reader's return type through ADR-18's `returns_from_arg:` " \
69
+ "by consuming `rigor-dry-types`'s `:dry_type_aliases` fact.",
70
+ # ADR-9 consumption — the precision-promotion path
71
+ # below uses `:dry_type_aliases` published by
72
+ # `rigor-dry-types`. The fact is optional: when the
73
+ # `rigor-dry-types` plugin isn't loaded, the
74
+ # `returns_from_arg:` lookup misses and the synthetic
75
+ # readers fall back to `Dynamic[Top]` (the pre-ADR-18
76
+ # floor).
77
+ consumes: [{ plugin_id: "dry-types", name: :dry_type_aliases, optional: true }],
78
+ heredoc_templates: [
79
+ Rigor::Plugin::Macro::HeredocTemplate.new(
80
+ receiver_constraint: "Dry::Struct",
81
+ method_name: :attribute,
82
+ symbol_arg_position: 0,
83
+ # ADR-18 — the synthetic reader's return type comes
84
+ # from the call site's second argument
85
+ # (`Types::String` etc.), resolved through the
86
+ # `:dry_type_aliases` fact. When the lookup misses
87
+ # (e.g. inline `attribute :tag, Types::String.constrained(...)`,
88
+ # whose receiver chain head isn't currently
89
+ # extracted), the row falls back to Dynamic[Top].
90
+ emit: [{
91
+ name: "\#{name}",
92
+ returns_from_arg: {
93
+ position: 1,
94
+ lookup_via: { plugin_id: "dry-types", fact: :dry_type_aliases }
95
+ }
96
+ }]
97
+ ),
98
+ Rigor::Plugin::Macro::HeredocTemplate.new(
99
+ receiver_constraint: "Dry::Struct",
100
+ method_name: :attribute?,
101
+ symbol_arg_position: 0,
102
+ emit: [{
103
+ name: "\#{name}",
104
+ returns_from_arg: {
105
+ position: 1,
106
+ lookup_via: { plugin_id: "dry-types", fact: :dry_type_aliases }
107
+ }
108
+ }]
109
+ )
110
+ ]
111
+ )
112
+ end
113
+
114
+ Rigor::Plugin.register(DryStruct)
115
+ end
116
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Gem entry point. Required by Rigor's plugin loader when
4
+ # `.rigor.yml` lists `rigor-dry-struct` under `plugins:`. The
5
+ # loader expects this `require` to side-effect a call to
6
+ # `Rigor::Plugin.register`, which the body of
7
+ # `lib/rigor/plugin/dry_struct.rb` performs at load time.
8
+ require_relative "rigor/plugin/dry_struct"