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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/rigor/analysis/baseline.rb +51 -15
- data/lib/rigor/analysis/runner.rb +67 -9
- data/lib/rigor/analysis/worker_session.rb +13 -4
- data/lib/rigor/cache/rbs_descriptor.rb +21 -2
- data/lib/rigor/cache/rbs_environment.rb +2 -1
- data/lib/rigor/cli/annotate_command.rb +57 -7
- data/lib/rigor/cli/baseline_command.rb +4 -3
- data/lib/rigor/cli/coverage_command.rb +126 -0
- data/lib/rigor/cli/coverage_renderer.rb +162 -0
- data/lib/rigor/cli/coverage_report.rb +75 -0
- data/lib/rigor/cli/mcp_command.rb +70 -0
- data/lib/rigor/cli.rb +88 -5
- data/lib/rigor/environment/rbs_loader.rb +46 -5
- data/lib/rigor/environment/reporters.rb +3 -2
- data/lib/rigor/environment.rb +159 -4
- data/lib/rigor/inference/def_return_typer.rb +98 -0
- data/lib/rigor/inference/expression_typer.rb +143 -12
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +5 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +115 -7
- data/lib/rigor/inference/precision_scanner.rb +131 -0
- data/lib/rigor/inference/statement_evaluator.rb +26 -2
- data/lib/rigor/mcp/loop.rb +43 -0
- data/lib/rigor/mcp/server.rb +263 -0
- data/lib/rigor/mcp.rb +16 -0
- data/lib/rigor/plugin/base.rb +28 -5
- data/lib/rigor/plugin/manifest.rb +33 -5
- data/lib/rigor/plugin/registry.rb +21 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
- data/lib/rigor/sig_gen/generator.rb +150 -75
- data/lib/rigor/type/combinator.rb +57 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +190 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +189 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +81 -0
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +142 -0
- data/plugins/rigor-actioncable/lib/rigor-actioncable.rb +3 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +178 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +310 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +76 -0
- data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +177 -0
- data/plugins/rigor-actionmailer/lib/rigor-actionmailer.rb +3 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +589 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +150 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +123 -0
- data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +247 -0
- data/plugins/rigor-actionpack/lib/rigor-actionpack.rb +3 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +114 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +177 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +65 -0
- data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +117 -0
- data/plugins/rigor-activejob/lib/rigor-activejob.rb +3 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +273 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/inflector.rb +114 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +561 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +194 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +240 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +94 -0
- data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +514 -0
- data/plugins/rigor-activerecord/lib/rigor-activerecord.rb +8 -0
- data/plugins/rigor-activerecord/sig/active_record/relation.rbs +182 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +78 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +162 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +43 -0
- data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +170 -0
- data/plugins/rigor-activestorage/lib/rigor-activestorage.rb +8 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +34 -0
- data/plugins/rigor-activesupport-core-ext/lib/rigor-activesupport-core-ext.rb +20 -0
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +463 -0
- data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +108 -0
- data/plugins/rigor-devise/lib/rigor-devise.rb +8 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +285 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +124 -0
- data/plugins/rigor-dry-schema/lib/rigor-dry-schema.rb +8 -0
- data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +116 -0
- data/plugins/rigor-dry-struct/lib/rigor-dry-struct.rb +8 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types/alias_scanner.rb +341 -0
- data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +120 -0
- data/plugins/rigor-dry-types/lib/rigor-dry-types.rb +8 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +120 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +85 -0
- data/plugins/rigor-dry-validation/lib/rigor-dry-validation.rb +7 -0
- data/plugins/rigor-dry-validation/sig/dry_validation.rbs +25 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +177 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +242 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +56 -0
- data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +174 -0
- data/plugins/rigor-factorybot/lib/rigor-factorybot.rb +3 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +409 -0
- data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +114 -0
- data/plugins/rigor-graphql/lib/rigor-graphql.rb +8 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami/action_checker.rb +124 -0
- data/plugins/rigor-hanami/lib/rigor/plugin/hanami.rb +111 -0
- data/plugins/rigor-hanami/lib/rigor-hanami.rb +3 -0
- data/plugins/rigor-hanami/sig/hanami_action.rbs +78 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest/assertion_analyzer.rb +302 -0
- data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +72 -0
- data/plugins/rigor-minitest/lib/rigor-minitest.rb +3 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/analyzer.rb +194 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +140 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_index.rb +65 -0
- data/plugins/rigor-pundit/lib/rigor/plugin/pundit.rb +130 -0
- data/plugins/rigor-pundit/lib/rigor-pundit.rb +3 -0
- data/plugins/rigor-rails/lib/rigor-rails.rb +31 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +277 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +108 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +138 -0
- data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +167 -0
- data/plugins/rigor-rails-i18n/lib/rigor-rails-i18n.rb +3 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +161 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/helper_table.rb +103 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +490 -0
- data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +158 -0
- data/plugins/rigor-rails-routes/lib/rigor-rails-routes.rb +3 -0
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +163 -0
- data/plugins/rigor-rbs-inline/lib/rigor-rbs-inline.rb +24 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/analyzer.rb +110 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +200 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +170 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +233 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec/scope_walker.rb +190 -0
- data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +188 -0
- data/plugins/rigor-rspec/lib/rigor-rspec.rb +3 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/have_http_status_analyzer.rb +128 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails/http_status_codes.rb +60 -0
- data/plugins/rigor-rspec-rails/lib/rigor/plugin/rspec_rails.rb +75 -0
- data/plugins/rigor-rspec-rails/lib/rigor-rspec-rails.rb +3 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +266 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers.rb +113 -0
- data/plugins/rigor-shoulda-matchers/lib/rigor-shoulda-matchers.rb +3 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +152 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +190 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +61 -0
- data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +124 -0
- data/plugins/rigor-sidekiq/lib/rigor-sidekiq.rb +3 -0
- data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +85 -0
- data/plugins/rigor-sinatra/lib/rigor-sinatra.rb +8 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +108 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +250 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +95 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +226 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +28 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +154 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +100 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +323 -0
- data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +660 -0
- data/plugins/rigor-sorbet/lib/rigor-sorbet.rb +3 -0
- data/plugins/rigor-statesman/lib/rigor/plugin/statesman.rb +209 -0
- data/plugins/rigor-statesman/lib/rigor-statesman.rb +8 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor/plugin/typescript_utility_types.rb +163 -0
- data/plugins/rigor-typescript-utility-types/lib/rigor-typescript-utility-types.rb +9 -0
- data/sig/rigor/analysis/baseline.rbs +39 -0
- data/sig/rigor/environment.rbs +3 -2
- data/sig/rigor/type.rbs +4 -0
- data/sig/rigor.rbs +2 -0
- 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"
|