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,341 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class DryTypes < Rigor::Plugin::Base
8
+ # Walks project source for `module X; include Dry.Types(); end`
9
+ # shapes and emits a `{ "<X>::<Alias>" => "<UnderlyingClass>" }`
10
+ # alias table covering the dry-types canonical-shortcut names.
11
+ # See {DryTypes} module-docstring for the floor / ceiling
12
+ # scoping.
13
+ module AliasScanner
14
+ # The canonical-shortcut names dry-types exposes through
15
+ # `include Dry.Types()`. Mirrors `Dry::Types.type_keys`
16
+ # from the upstream gem.
17
+ CANONICAL_ALIASES = {
18
+ "String" => "String",
19
+ "Integer" => "Integer",
20
+ "Float" => "Float",
21
+ "Decimal" => "BigDecimal",
22
+ "Symbol" => "Symbol",
23
+ "Bool" => "TrueClass",
24
+ "True" => "TrueClass",
25
+ "False" => "FalseClass",
26
+ "Nil" => "NilClass",
27
+ "Date" => "Date",
28
+ "DateTime" => "DateTime",
29
+ "Time" => "Time",
30
+ "Hash" => "Hash",
31
+ "Array" => "Array",
32
+ "Any" => "Object"
33
+ }.freeze
34
+
35
+ # Slice 2 — nested-category aliases. dry-types installs
36
+ # four parallel coercion categories: `Coercible::*`
37
+ # (everything-to-target coercion), `Strict::*` (no
38
+ # coercion; raise if mismatch), `Params::*` (HTTP /
39
+ # query-string-style coercion, used by Hanami / Roda /
40
+ # dry-web in request handling), `JSON::*` (JSON-shape
41
+ # coercion). Each category exposes the same set of names
42
+ # as the canonical shortcuts above, plus a few additions
43
+ # that are category-specific (`Params::Nil`,
44
+ # `JSON::Symbol`). For Rigor's purposes the underlying
45
+ # class is the same regardless of category — coercion
46
+ # semantics are a runtime concern. We register every
47
+ # `<module>::<Category>::<Name>` mapping the upstream gem
48
+ # publishes so call-site references work uniformly.
49
+ NESTED_CATEGORIES = %w[Coercible Strict Params JSON].freeze
50
+ private_constant :NESTED_CATEGORIES
51
+
52
+ module_function
53
+
54
+ # @param paths [Array<String>] absolute paths to `.rb`
55
+ # files the project's `paths:` resolves to.
56
+ # @return [Hash{String => String}] frozen
57
+ # `{aliased_name => underlying_class_name}` map. Empty
58
+ # when no `include Dry.Types()` declaration is found.
59
+ def scan(paths:)
60
+ results = paths.flat_map { |path| scan_file(path) }
61
+ modules = results.map { |r| r[:module_name] }.uniq
62
+ return {}.freeze if modules.empty?
63
+
64
+ base = canonical_table(modules)
65
+ results.each do |result|
66
+ result[:compositions].each do |const_name, underlying|
67
+ # Each result's compositions are scoped under that
68
+ # result's enclosing module (`Types::Email`, etc.).
69
+ base["#{result[:module_name]}::#{const_name}"] ||= underlying
70
+ end
71
+ end
72
+ base.freeze
73
+ end
74
+
75
+ # Populates the canonical-shortcut + nested-category
76
+ # table (15 + 15 × 4 = 75 entries per alias module).
77
+ def canonical_table(modules)
78
+ modules.each_with_object({}) do |module_name, acc|
79
+ CANONICAL_ALIASES.each do |alias_name, underlying|
80
+ acc["#{module_name}::#{alias_name}"] = underlying
81
+ NESTED_CATEGORIES.each do |category|
82
+ acc["#{module_name}::#{category}::#{alias_name}"] = underlying
83
+ end
84
+ end
85
+ end
86
+ end
87
+ private_class_method :canonical_table
88
+
89
+ def scan_file(path)
90
+ source = File.read(path)
91
+ parse_result = Prism.parse(source, filepath: path)
92
+ return [] unless parse_result.errors.empty?
93
+
94
+ collect_alias_modules(parse_result.value, []).map do |module_info|
95
+ compositions = collect_compositions(module_info[:body])
96
+ { module_name: module_info[:module_name], compositions: compositions }
97
+ end
98
+ rescue StandardError
99
+ # Missing-file / parse failures degrade to "no
100
+ # contribution from this file"; the plugin's
101
+ # user-visible surface is the published fact, and
102
+ # dropping unparseable files keeps the fact stable.
103
+ []
104
+ end
105
+ private_class_method :scan_file
106
+
107
+ # Walks a Prism AST collecting alias-module info:
108
+ # `{module_name:, body:}` for every `module X; include
109
+ # Dry.Types(); …end` shape. Tracks the enclosing module
110
+ # chain so a nested `module App; module Types; include
111
+ # Dry.Types(); end; end` publishes `"App::Types"` as the
112
+ # alias scope. The `body:` field is the
113
+ # `Prism::StatementsNode` (or nil) we re-walk later for
114
+ # user-authored compositions (slice 3).
115
+ def collect_alias_modules(node, qualified_prefix)
116
+ return [] unless node.is_a?(Prism::Node)
117
+
118
+ case node
119
+ when Prism::ModuleNode
120
+ name = qualified_name_for(node.constant_path)
121
+ new_prefix = name ? qualified_prefix + [name] : qualified_prefix
122
+ children = node.body ? collect_alias_modules(node.body, new_prefix) : []
123
+ current = if name && contains_dry_types_include?(node.body)
124
+ [{ module_name: new_prefix.join("::"), body: node.body }]
125
+ else
126
+ []
127
+ end
128
+ current + children
129
+ when Prism::ClassNode
130
+ # Module-level declarations win; we don't recurse into
131
+ # class bodies for `include Dry.Types()` because the
132
+ # canonical pattern is module-level.
133
+ []
134
+ else
135
+ node.compact_child_nodes.flat_map { |c| collect_alias_modules(c, qualified_prefix) }
136
+ end
137
+ end
138
+ private_class_method :collect_alias_modules
139
+
140
+ # Slice 3 — user-authored composition recognition.
141
+ # Walks the alias-module body for `Email =
142
+ # String.constrained(...)` shapes. Each
143
+ # `ConstantWriteNode` whose RHS is a method chain
144
+ # rooted on a canonical-shortcut name (`String`,
145
+ # `Integer`, …) — or on a nested-category form
146
+ # (`Strict::String` etc.) — registers the LHS under
147
+ # the canonical head's underlying class. Unions
148
+ # (`String | Integer`) and intersections are skipped
149
+ # (no single underlying class).
150
+ #
151
+ # Slice 4 — transitive composition resolution. After
152
+ # the direct (slice-3) pass collects compositions
153
+ # whose RHS root is canonical, a second pass walks the
154
+ # remaining `ConstantWriteNode`s for RHS shapes that
155
+ # resolve THROUGH an already-published composition —
156
+ # e.g. `ManagerEmail = Email` (bare reference) or
157
+ # `ManagerEmail = Email.constrained(min_size: 3)`
158
+ # (method chain rooted on a composition LHS). Cycle
159
+ # detection: `A = B; B = A` resolves neither (each
160
+ # LHS's resolution walk sees itself in the visited
161
+ # set and bails). Unknown references (`ManagerEmail =
162
+ # NotAComposition`) silently drop — the user is free
163
+ # to assign any constant to any other; the plugin only
164
+ # publishes facts when the underlying class is known.
165
+ def collect_compositions(body)
166
+ return {} if body.nil?
167
+
168
+ direct = {}
169
+ ref_edges = {}
170
+ tree_walk(body).each do |child|
171
+ next unless child.is_a?(Prism::ConstantWriteNode)
172
+
173
+ head = composition_head_canonical(child.value)
174
+ if head
175
+ direct[child.name.to_s] = CANONICAL_ALIASES.fetch(head)
176
+ else
177
+ ref = transitive_reference_name(child.value)
178
+ ref_edges[child.name.to_s] = ref if ref
179
+ end
180
+ end
181
+
182
+ resolved = direct.dup
183
+ ref_edges.each_key do |lhs|
184
+ underlying = resolve_transitive_ref(lhs, direct, ref_edges, visited: ::Set.new)
185
+ resolved[lhs] = underlying if underlying
186
+ end
187
+ resolved
188
+ end
189
+ private_class_method :collect_compositions
190
+
191
+ # Slice-4 helper. Returns the un-resolved RHS root
192
+ # constant name (not necessarily a CANONICAL_ALIASES
193
+ # entry) for transitive resolution. Mirrors
194
+ # {composition_head_canonical} but accepts any
195
+ # `ConstantReadNode` / `ConstantPathNode` tail rather
196
+ # than canonical-only. Declines on union / intersection
197
+ # operators for the same single-underlying-class
198
+ # reason.
199
+ def transitive_reference_name(node)
200
+ case node
201
+ when Prism::ConstantReadNode
202
+ node.name.to_s
203
+ when Prism::ConstantPathNode
204
+ node.name.to_s
205
+ when Prism::CallNode
206
+ return nil if %i[| &].include?(node.name)
207
+ return nil if node.receiver.nil?
208
+
209
+ transitive_reference_name(node.receiver)
210
+ end
211
+ end
212
+ private_class_method :transitive_reference_name
213
+
214
+ # Slice-4 helper. Resolves `lhs`'s RHS through the
215
+ # `direct` (slice-3) compositions table, chaining
216
+ # through `ref_edges` for transitive references.
217
+ # Returns the canonical underlying-class name (e.g.
218
+ # `"String"`) or nil when no chain ends at a direct
219
+ # composition. Cycles silently return nil — every step
220
+ # adds the current lhs to `visited` and bails on
221
+ # re-entry.
222
+ def resolve_transitive_ref(lhs, direct, ref_edges, visited:)
223
+ return nil if visited.include?(lhs)
224
+
225
+ visited << lhs
226
+ target = ref_edges[lhs]
227
+ return nil if target.nil?
228
+ return direct[target] if direct.key?(target)
229
+
230
+ resolve_transitive_ref(target, direct, ref_edges, visited: visited)
231
+ end
232
+ private_class_method :resolve_transitive_ref
233
+
234
+ # Walks an RHS expression looking for the canonical
235
+ # shortcut name at the root of a method chain. Returns
236
+ # the canonical name (`"String"` etc.) or nil.
237
+ #
238
+ # Recognised shapes (recursively on `node.receiver`):
239
+ #
240
+ # - Bare `String` / `Integer` — `Prism::ConstantReadNode`
241
+ # whose name is in `CANONICAL_ALIASES`.
242
+ # - `Strict::String` / `Coercible::Integer` / etc. —
243
+ # `Prism::ConstantPathNode` whose tail is in
244
+ # `CANONICAL_ALIASES`.
245
+ # - `String.constrained(...)` / `.optional` /
246
+ # `.default(...)` / arbitrary single-arg method —
247
+ # recurse on the receiver.
248
+ #
249
+ # Declines on `String | Integer` (union, `:|`) and
250
+ # `String & Foo` (intersection, `:&`) so the alias
251
+ # table doesn't claim a single underlying class for
252
+ # a multi-class composition.
253
+ def composition_head_canonical(node)
254
+ case node
255
+ when Prism::ConstantReadNode
256
+ CANONICAL_ALIASES.key?(node.name.to_s) ? node.name.to_s : nil
257
+ when Prism::ConstantPathNode
258
+ tail = node.name.to_s
259
+ CANONICAL_ALIASES.key?(tail) ? tail : nil
260
+ when Prism::CallNode
261
+ return nil if %i[| &].include?(node.name)
262
+ return nil if node.receiver.nil?
263
+
264
+ composition_head_canonical(node.receiver)
265
+ end
266
+ end
267
+ private_class_method :composition_head_canonical
268
+
269
+ # `include Dry.Types()` at the top of the module body is the
270
+ # canonical alias declaration. We accept the call anywhere
271
+ # in the body (some projects guard it with a `if defined?`
272
+ # check). The argument list must be empty (or a kwargs-only
273
+ # `default: :nominal` style accepted by upstream; we treat
274
+ # both as "alias-installing").
275
+ def contains_dry_types_include?(body)
276
+ return false if body.nil?
277
+
278
+ tree_walk(body).any? do |child|
279
+ include_call_targeting_dry_types?(child)
280
+ end
281
+ end
282
+ private_class_method :contains_dry_types_include?
283
+
284
+ def tree_walk(node)
285
+ return [] unless node.is_a?(Prism::Node)
286
+
287
+ Enumerator.new do |y|
288
+ stack = [node]
289
+ until stack.empty?
290
+ current = stack.shift
291
+ y << current
292
+ stack.concat(current.compact_child_nodes) if current.is_a?(Prism::Node)
293
+ end
294
+ end
295
+ end
296
+ private_class_method :tree_walk
297
+
298
+ # Matches `include Dry.Types()` (with or without kwargs).
299
+ # The receiver of the include call MUST be implicit
300
+ # (i.e., called on `self`), and the argument MUST be a
301
+ # method call on the `Dry` constant naming `Types`.
302
+ def include_call_targeting_dry_types?(node)
303
+ return false unless node.is_a?(Prism::CallNode)
304
+ return false unless node.name == :include && node.receiver.nil?
305
+ return false if node.arguments.nil?
306
+ return false unless node.arguments.arguments.size == 1
307
+
308
+ arg = node.arguments.arguments.first
309
+ dry_types_call?(arg)
310
+ end
311
+ private_class_method :include_call_targeting_dry_types?
312
+
313
+ def dry_types_call?(node)
314
+ return false unless node.is_a?(Prism::CallNode)
315
+ return false unless node.name == :Types
316
+ return false unless node.receiver.is_a?(Prism::ConstantReadNode)
317
+
318
+ node.receiver.name == :Dry
319
+ end
320
+ private_class_method :dry_types_call?
321
+
322
+ # Resolves a `Prism::ConstantPathNode` /
323
+ # `Prism::ConstantReadNode` chain to its dot-separated
324
+ # name (e.g. `"App::Types"`). Returns nil for the
325
+ # dynamic-prefix shape so the scanner treats those as
326
+ # opaque rather than guessing.
327
+ def qualified_name_for(node)
328
+ case node
329
+ when Prism::ConstantReadNode then node.name.to_s
330
+ when Prism::ConstantPathNode
331
+ parent = node.parent.nil? ? nil : qualified_name_for(node.parent)
332
+ return nil if !node.parent.nil? && parent.nil?
333
+
334
+ parent.nil? ? node.name.to_s : "#{parent}::#{node.name}"
335
+ end
336
+ end
337
+ private_class_method :qualified_name_for
338
+ end
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require "rigor/plugin"
6
+
7
+ require_relative "dry_types/alias_scanner"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ # rigor-dry-types — Tier A foundation per
12
+ # [ADR-12](../../../../../docs/adr/12-dry-rb-packaging.md).
13
+ #
14
+ # Recognises the canonical dry-types alias-module declaration:
15
+ #
16
+ # module Types
17
+ # include Dry.Types()
18
+ # end
19
+ #
20
+ # and publishes the resulting `{aliased_name => underlying_class}`
21
+ # table as the `:dry_type_aliases` cross-plugin fact (ADR-9).
22
+ # Other dry-rb adapter plugins consume this fact:
23
+ #
24
+ # - `rigor-dry-struct` reads it so `attribute :city, Types::String`
25
+ # can promote `address.city` from `Dynamic[T]` to `Nominal[String]`
26
+ # (gated on the slice-6 precision-promotion work + ADR-13
27
+ # resolver chain).
28
+ # - `rigor-dry-validation` / `rigor-dry-schema` read it for
29
+ # per-key type recognition in `schema { … }` / `params { … }`
30
+ # blocks (separate plugin slice).
31
+ #
32
+ # ## Floor / ceiling (slice 1)
33
+ #
34
+ # Slice 1 ships the **floor**:
35
+ #
36
+ # - Recognises `module X; include Dry.Types(); end` for any
37
+ # constant module name `X` (commonly `Types`, sometimes
38
+ # `MyTypes` / `AppTypes`).
39
+ # - Maps the **basic** dry-types constants: `String`, `Integer`,
40
+ # `Float`, `Decimal`, `Symbol`, `Bool`, `True`, `False`, `Nil`,
41
+ # `Date`, `DateTime`, `Time`, `Hash`, `Array`, `Any`.
42
+ # - Publishes the table as `{ "<Module>::<Alias>" =>
43
+ # "<UnderlyingClass>" }` so consumers can match on the
44
+ # qualified constant name they see in source.
45
+ #
46
+ # The **ceiling** (slice 2+):
47
+ #
48
+ # - Recognises nested namespaces (`Types::Coercible::Integer`,
49
+ # `Types::Strict::Symbol`, `Types::Params::Bool`,
50
+ # `Types::JSON::Date`) — each is a separate dry-types
51
+ # "category" with its own coercion semantics.
52
+ # - Recognises user-authored compositions
53
+ # (`Types::String.constrained(min_size: 1)`,
54
+ # `Email = Types::String.constrained(format: …)`) so the
55
+ # alias surface extends beyond the canonical names.
56
+ # - Emits `dry-types.unknown-alias` / `dry-types.alias-shadow`
57
+ # diagnostics when downstream code references a name that
58
+ # wasn't published.
59
+ #
60
+ # ## Why no `diagnostics_for_file` at the floor?
61
+ #
62
+ # The plugin's user-visible value at slice 1 is the published
63
+ # fact — every downstream uplift (precision promotion in
64
+ # `address.city`, schema-key recognition in `rigor-dry-validation`)
65
+ # consumes the fact rather than the plugin itself emitting
66
+ # diagnostics. The diagnostics surface lands when the
67
+ # `dry-types.*` rule family becomes load-bearing for
68
+ # demand-driven cases.
69
+ class DryTypes < Rigor::Plugin::Base
70
+ manifest(
71
+ id: "dry-types",
72
+ version: "0.1.0",
73
+ description: "Recognises `module X; include Dry.Types(); end` and publishes the alias table.",
74
+ produces: [:dry_type_aliases]
75
+ )
76
+
77
+ # Walks every project file once during `prepare(services)` to
78
+ # build the alias table, then publishes via the ADR-9 fact
79
+ # store. The walk is bounded by the configured `paths:`
80
+ # surface; each file's parse error degrades to "no
81
+ # contribution" without polluting the user-visible
82
+ # diagnostic stream.
83
+ def prepare(services)
84
+ aliases = AliasScanner.scan(paths: scannable_paths(services))
85
+ return if aliases.empty?
86
+
87
+ services.fact_store.publish(
88
+ plugin_id: manifest.id,
89
+ name: :dry_type_aliases,
90
+ value: aliases
91
+ )
92
+ end
93
+
94
+ def init(_services)
95
+ @scannable_paths = nil
96
+ end
97
+
98
+ private
99
+
100
+ # Resolves the project's `paths:` to a flat list of `.rb`
101
+ # files the scanner walks. Mirrors `Analysis::Runner`'s
102
+ # `expand_paths` floor; we don't need the runner's full
103
+ # exclude/sort surface because the alias table is a
104
+ # union — any duplicate scan is a no-op.
105
+ def scannable_paths(services)
106
+ @scannable_paths ||= services.configuration.paths.flat_map do |entry|
107
+ if File.directory?(entry)
108
+ Dir.glob(File.join(entry, "**", "*.rb"), sort: true)
109
+ elsif File.file?(entry) && entry.end_with?(".rb")
110
+ [entry]
111
+ else
112
+ []
113
+ end
114
+ end.uniq.freeze
115
+ end
116
+ end
117
+
118
+ Rigor::Plugin.register(DryTypes)
119
+ end
120
+ 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-types` 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_types.rb` performs at load time.
8
+ require_relative "rigor/plugin/dry_types"
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Rigor
6
+ module Plugin
7
+ class DryValidation < Rigor::Plugin::Base
8
+ # Walks project source for `class T < Dry::Validation::Contract`
9
+ # subclasses and returns the contract class FQN set.
10
+ #
11
+ # Recognition tightness: the superclass match accepts EITHER
12
+ # the fully-qualified `Dry::Validation::Contract` (3-segment
13
+ # path) OR the lexical-nested `Validation::Contract`
14
+ # (2-segment path, when the class body lives inside
15
+ # `module Dry`). The bare `< Contract` form (1-segment)
16
+ # is NOT recognised — too ambiguous; users who deeply nest
17
+ # under `Dry::Validation` should use the explicit form.
18
+ # Unrelated `< MyApp::Validation::Contract` shapes with the
19
+ # same tail do NOT register.
20
+ module ContractScanner
21
+ CONTRACT_FULL_PATH = %w[Dry Validation Contract].freeze
22
+ CONTRACT_LEXICAL_DRY_PATH = %w[Validation Contract].freeze
23
+ private_constant :CONTRACT_FULL_PATH, :CONTRACT_LEXICAL_DRY_PATH
24
+
25
+ module_function
26
+
27
+ # @param paths [Array<String>] absolute paths to `.rb`
28
+ # files the project's `paths:` resolves to.
29
+ # @return [Array<String>] frozen, sorted list of
30
+ # recognized contract class FQNs (e.g.
31
+ # `["App::NewUserContract", "Types::EmailContract"]`).
32
+ def scan(paths:)
33
+ contracts = []
34
+ paths.each { |path| contracts.concat(scan_file(path)) }
35
+ contracts.uniq.sort.freeze
36
+ end
37
+
38
+ def scan_file(path)
39
+ source = File.read(path)
40
+ parse_result = Prism.parse(source, filepath: path)
41
+ return [] unless parse_result.errors.empty?
42
+
43
+ collect_contracts(parse_result.value, [])
44
+ rescue StandardError
45
+ []
46
+ end
47
+ private_class_method :scan_file
48
+
49
+ def collect_contracts(node, qualified_prefix)
50
+ return [] if node.nil?
51
+
52
+ case node
53
+ when Prism::ClassNode then collect_class_node(node, qualified_prefix)
54
+ when Prism::ModuleNode then collect_module_node(node, qualified_prefix)
55
+ else
56
+ node.compact_child_nodes.flat_map { |c| collect_contracts(c, qualified_prefix) }
57
+ end
58
+ end
59
+ private_class_method :collect_contracts
60
+
61
+ def collect_class_node(node, qualified_prefix)
62
+ inner_name = constant_name_for(node.constant_path)
63
+ return [] if inner_name.nil?
64
+
65
+ new_prefix = qualified_prefix + [inner_name]
66
+ inner = collect_contracts(node.body, new_prefix)
67
+ inner += [new_prefix.join("::")] if contract_subclass?(node)
68
+ inner
69
+ end
70
+ private_class_method :collect_class_node
71
+
72
+ def collect_module_node(node, qualified_prefix)
73
+ inner_name = constant_name_for(node.constant_path)
74
+ return [] if inner_name.nil?
75
+
76
+ collect_contracts(node.body, qualified_prefix + [inner_name])
77
+ end
78
+ private_class_method :collect_module_node
79
+
80
+ # Matches superclasses whose constant chain is EXACTLY
81
+ # `Dry::Validation::Contract` (full path) OR EXACTLY
82
+ # `Validation::Contract` (lexical-Dry path). Other shapes
83
+ # — including same-tail-but-different-root chains and
84
+ # the ambiguous bare `Contract` — do not match.
85
+ def contract_subclass?(class_node)
86
+ superclass = class_node.superclass
87
+ return false if superclass.nil?
88
+
89
+ path = constant_path_segments(superclass)
90
+ [CONTRACT_FULL_PATH, CONTRACT_LEXICAL_DRY_PATH].include?(path)
91
+ end
92
+ private_class_method :contract_subclass?
93
+
94
+ def constant_path_segments(node)
95
+ case node
96
+ when Prism::ConstantReadNode then [node.name.to_s]
97
+ when Prism::ConstantPathNode
98
+ segments = []
99
+ current = node
100
+ while current.is_a?(Prism::ConstantPathNode)
101
+ segments.unshift(current.name.to_s)
102
+ current = current.parent
103
+ end
104
+ segments.unshift(current.name.to_s) if current.is_a?(Prism::ConstantReadNode)
105
+ segments
106
+ else
107
+ []
108
+ end
109
+ end
110
+ private_class_method :constant_path_segments
111
+
112
+ def constant_name_for(node)
113
+ segments = constant_path_segments(node)
114
+ segments.empty? ? nil : segments.join("::")
115
+ end
116
+ private_class_method :constant_name_for
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ require "rigor/plugin"
6
+
7
+ require_relative "dry_validation/contract_scanner"
8
+
9
+ module Rigor
10
+ module Plugin
11
+ # rigor-dry-validation — Tier A per
12
+ # [ADR-12](../../../../../docs/adr/12-dry-rb-packaging.md) and the
13
+ # slicing plan in
14
+ # [docs/design/20260517-dry-validation-slicing.md](../../../../../docs/design/20260517-dry-validation-slicing.md).
15
+ #
16
+ # Slice 1 floor:
17
+ #
18
+ # - Walks the project for `class T < Dry::Validation::Contract`
19
+ # subclasses and publishes the resulting set of contract
20
+ # class FQNs as the `:dry_validation_contracts` ADR-9
21
+ # cross-plugin fact.
22
+ # - Ships an RBS overlay (`sig/dry_validation.rbs`) typing
23
+ # `Dry::Validation::Contract#call` (returns Result) and
24
+ # `Dry::Validation::Result#{success?, failure?, to_h}`. Users
25
+ # add the path to their `.rigor.yml`'s `signature_paths:` so
26
+ # `contract.call(input).to_h` infers cleanly. See the README
27
+ # for the wiring step.
28
+ #
29
+ # Slice 2 (deferred, per design note):
30
+ #
31
+ # - Integrate with `:dry_schema_table` (published by
32
+ # `rigor-dry-schema`) so the `params { ... }` block inside a
33
+ # Contract contributes a typed `result.to_h` shape per the
34
+ # schema. Until this lands, `result.to_h` types as
35
+ # `Hash[Symbol, untyped]` (the generic RBS overlay shape).
36
+ #
37
+ # Slice 3 (deferred): `json { ... }` adapter parity with
38
+ # `params { ... }`. Same shape as slice 2.
39
+ #
40
+ # No ADR-3 amendment is needed for the validation surface
41
+ # itself; `Dry::Validation::Result` is a generic class, not a
42
+ # sum type (the `success?` / `failure?` predicates narrow via
43
+ # existing bool flow facts).
44
+ class DryValidation < Rigor::Plugin::Base
45
+ manifest(
46
+ id: "dry-validation",
47
+ version: "0.1.0",
48
+ description: "Recognises `class T < Dry::Validation::Contract` subclasses and " \
49
+ "publishes the contract FQN set.",
50
+ produces: [:dry_validation_contracts]
51
+ )
52
+
53
+ def prepare(services)
54
+ contracts = ContractScanner.scan(paths: scannable_paths(services))
55
+ return if contracts.empty?
56
+
57
+ services.fact_store.publish(
58
+ plugin_id: manifest.id,
59
+ name: :dry_validation_contracts,
60
+ value: contracts
61
+ )
62
+ end
63
+
64
+ def init(_services)
65
+ @scannable_paths = nil
66
+ end
67
+
68
+ private
69
+
70
+ def scannable_paths(services)
71
+ @scannable_paths ||= services.configuration.paths.flat_map do |entry|
72
+ if File.directory?(entry)
73
+ Dir.glob(File.join(entry, "**", "*.rb"), sort: true)
74
+ elsif File.file?(entry) && entry.end_with?(".rb")
75
+ [entry]
76
+ else
77
+ []
78
+ end
79
+ end.uniq.freeze
80
+ end
81
+ end
82
+
83
+ Rigor::Plugin.register(DryValidation)
84
+ end
85
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Gem entry point. Required by Rigor's plugin loader when
4
+ # `.rigor.yml` lists `rigor-dry-validation` under `plugins:`.
5
+ # Side-effects a `Rigor::Plugin.register` call via the
6
+ # `lib/rigor/plugin/dry_validation.rb` class body.
7
+ require_relative "rigor/plugin/dry_validation"