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,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"
|