rigortype 0.3.0 → 0.3.2
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 +10 -10
- data/data/builtins/ruby_core/array.yml +416 -392
- data/data/builtins/ruby_core/file.yml +42 -42
- data/data/builtins/ruby_core/hash.yml +302 -302
- data/data/builtins/ruby_core/io.yml +191 -191
- data/data/builtins/ruby_core/numeric.yml +321 -366
- data/data/builtins/ruby_core/proc.yml +124 -124
- data/data/builtins/ruby_core/range.yml +21 -21
- data/data/builtins/ruby_core/rational.yml +39 -39
- data/data/builtins/ruby_core/re.yml +65 -65
- data/data/builtins/ruby_core/set.yml +106 -106
- data/data/builtins/ruby_core/struct.yml +14 -14
- data/data/core_overlay/pathname.rbs +5 -0
- data/data/core_overlay/resolv.rbs +31 -0
- data/data/core_overlay/string_scanner.rbs +11 -5
- data/data/gem_overlay/activesupport/core_ext.rbs +10 -6
- data/data/vendored_gem_sigs/bundler/bundler.rbs +39 -27
- data/data/vendored_gem_sigs/cgi/cgi_extras.rbs +6 -0
- data/data/vendored_gem_sigs/prism/prism_supplement.rbs +7 -0
- data/data/vendored_gem_sigs/racc/racc.rbs +67 -0
- data/data/vendored_gem_sigs/rubygems/rubygems_extras.rbs +20 -9
- data/docs/handbook/01-getting-started.md +22 -34
- data/docs/handbook/06-classes.md +1 -1
- data/docs/handbook/07-rbs-and-extended.md +76 -101
- data/docs/handbook/08-understanding-errors.md +114 -247
- data/docs/handbook/09-plugins.md +54 -144
- data/docs/handbook/README.md +5 -3
- data/docs/handbook/appendix-liskov.md +4 -2
- data/docs/handbook/appendix-phpstan.md +14 -7
- data/docs/handbook/appendix-steep.md +4 -2
- data/docs/handbook/appendix-type-theory.md +3 -1
- data/docs/manual/02-cli-reference.md +103 -11
- data/docs/manual/03-configuration.md +16 -1
- data/docs/manual/04-diagnostics.md +36 -4
- data/docs/manual/06-baseline.md +35 -1
- data/docs/manual/08-skills.md +6 -1
- data/docs/manual/09-editor-integration.md +3 -2
- data/docs/manual/12-caching.md +17 -5
- data/docs/manual/15-type-protection-coverage.md +122 -1
- data/docs/manual/plugins/rigor-actioncable.md +32 -0
- data/docs/manual/plugins/rigor-devise.md +4 -2
- data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
- data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +21 -2
- data/lib/rigor/analysis/check_rules.rb +79 -19
- data/lib/rigor/analysis/incremental_session.rb +156 -11
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
- data/lib/rigor/analysis/run_cache_key.rb +27 -1
- data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +201 -0
- data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +28 -10
- data/lib/rigor/analysis/runner/pool_coordinator.rb +7 -10
- data/lib/rigor/analysis/runner/project_pre_passes.rb +3 -1
- data/lib/rigor/analysis/runner.rb +80 -8
- data/lib/rigor/bleeding_edge.rb +132 -7
- data/lib/rigor/cache/descriptor.rb +6 -1
- data/lib/rigor/cache/engine_source.rb +162 -0
- data/lib/rigor/cache/incremental_snapshot.rb +87 -18
- data/lib/rigor/cache/rbs_cache_producer.rb +11 -1
- data/lib/rigor/cache/rbs_environment_marshal_patch.rb +38 -0
- data/lib/rigor/cache/store.rb +99 -24
- data/lib/rigor/cli/check_command.rb +61 -34
- data/lib/rigor/cli/check_invocation.rb +84 -0
- data/lib/rigor/cli/coverage_command.rb +15 -2
- data/lib/rigor/cli/coverage_mutation.rb +242 -11
- data/lib/rigor/cli/doctor_command.rb +6 -8
- data/lib/rigor/cli/fused_protection_renderer.rb +10 -0
- data/lib/rigor/cli/fused_protection_report.rb +12 -3
- data/lib/rigor/cli/mutation_fork_scan.rb +64 -0
- data/lib/rigor/cli/mutation_protection_renderer.rb +12 -0
- data/lib/rigor/cli/mutation_protection_report.rb +26 -5
- data/lib/rigor/cli/protection_renderer.rb +13 -0
- data/lib/rigor/cli/protection_report.rb +11 -3
- data/lib/rigor/cli/show_bleedingedge_command.rb +17 -4
- data/lib/rigor/cli/sig_gen_command.rb +5 -4
- data/lib/rigor/cli/skill_command.rb +21 -1
- data/lib/rigor/cli/skill_deep_probe.rb +172 -0
- data/lib/rigor/cli/skill_describe.rb +75 -9
- data/lib/rigor/configuration.rb +45 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
- data/lib/rigor/environment/default_libraries.rb +5 -4
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +434 -75
- data/lib/rigor/environment.rb +38 -14
- data/lib/rigor/inference/expression_typer.rb +15 -0
- data/lib/rigor/inference/fork_map.rb +9 -0
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +50 -2
- data/lib/rigor/inference/method_dispatcher/data_folding.rb +1 -1
- data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +35 -1
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +6 -1
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +16 -0
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +137 -9
- data/lib/rigor/inference/method_dispatcher/struct_folding.rb +3 -2
- data/lib/rigor/inference/method_dispatcher/uri_folding.rb +124 -1
- data/lib/rigor/inference/mutation_widening.rb +30 -25
- data/lib/rigor/inference/narrowing.rb +3 -1
- data/lib/rigor/inference/optimistic_origin.rb +58 -0
- data/lib/rigor/inference/protection_scanner.rb +18 -2
- data/lib/rigor/inference/receiver_alias.rb +57 -0
- data/lib/rigor/inference/scope_indexer.rb +78 -27
- data/lib/rigor/inference/statement_evaluator.rb +40 -2
- data/lib/rigor/language_server/buffer_resolution.rb +6 -3
- data/lib/rigor/language_server/buffer_table.rb +68 -6
- data/lib/rigor/language_server/diagnostic_publisher.rb +207 -4
- data/lib/rigor/language_server/incremental_sync.rb +159 -0
- data/lib/rigor/language_server/project_context.rb +45 -0
- data/lib/rigor/language_server/publish_batcher.rb +82 -0
- data/lib/rigor/language_server/server.rb +38 -9
- data/lib/rigor/language_server.rb +2 -0
- data/lib/rigor/plugin/base.rb +29 -2
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
- data/lib/rigor/protection/closure_kill_oracle.rb +171 -0
- data/lib/rigor/protection/dependency_closure.rb +59 -0
- data/lib/rigor/protection/diagnostic_oracle.rb +12 -8
- data/lib/rigor/protection/discovery_seed.rb +116 -0
- data/lib/rigor/protection/kill_signature.rb +31 -0
- data/lib/rigor/protection/mutation_cache.rb +355 -0
- data/lib/rigor/protection/mutation_scanner.rb +55 -11
- data/lib/rigor/protection/mutator.rb +26 -4
- data/lib/rigor/runtime/jit.rb +63 -1
- data/lib/rigor/scope/discovery_index.rb +9 -0
- data/lib/rigor/scope.rb +52 -8
- data/lib/rigor/sig_gen/generator.rb +130 -31
- data/lib/rigor/sig_gen/layout_index.rb +6 -0
- data/lib/rigor/sig_gen/meta_class_shape.rb +84 -0
- data/lib/rigor/sig_gen/renderer.rb +20 -1
- data/lib/rigor/sig_gen/write_result.rb +6 -4
- data/lib/rigor/sig_gen/writer.rb +195 -47
- data/lib/rigor/sig_gen.rb +1 -0
- data/lib/rigor/version.rb +1 -1
- data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +51 -1
- data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +6 -6
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/result_shape.rb +146 -0
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema/schema_scanner.rb +177 -12
- data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +74 -7
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/contract_scanner.rb +257 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation/params_shape.rb +69 -0
- data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +87 -11
- data/plugins/rigor-rbs-inline/lib/rigor/plugin/rbs_inline.rb +34 -4
- data/sig/rigor/cache.rbs +6 -0
- data/sig/rigor/inference/void_origin.rbs +21 -0
- data/sig/rigor/plugin/base.rbs +4 -3
- data/sig/rigor/scope.rbs +8 -0
- metadata +23 -3
|
@@ -67,6 +67,84 @@ module Rigor
|
|
|
67
67
|
end
|
|
68
68
|
private_class_method :scan_file
|
|
69
69
|
|
|
70
|
+
# Per-row `dry-schema.unknown-type` diagnostics (ceiling slice, issue #137). Walks an
|
|
71
|
+
# ALREADY-PARSED file's root node — the engine parses every analysed file once, so this reuses
|
|
72
|
+
# that AST rather than re-reading and re-parsing the file — collecting `{node:, key:, symbol:}`
|
|
73
|
+
# issues for a `required(:key).<verb>(:sym)` / `optional(:key).<verb>(:sym)` row whose
|
|
74
|
+
# type-bearing predicate (`filled` / `value` / `maybe` / `each`) receives a literal Symbol argument
|
|
75
|
+
# OUTSIDE `CANONICAL_TYPES`. Recurses into `each do ... end` nested rows (mirrors
|
|
76
|
+
# {#each_block_type_info}) so a nested row's bad symbol is caught too.
|
|
77
|
+
#
|
|
78
|
+
# A Constant argument (`value(Types::Email)`) is never flagged: an unresolved alias already has a
|
|
79
|
+
# silent, deliberate fallback (no `:dry_type_aliases` fact, or a name the fact doesn't know) per
|
|
80
|
+
# the existing slice-1 "drops constant-type references..." behaviour, and this diagnostic firing
|
|
81
|
+
# on it would misfire on every entirely-correct `value(Types::Email)` row in a project that simply
|
|
82
|
+
# doesn't have `rigor-dry-types` loaded.
|
|
83
|
+
#
|
|
84
|
+
# `dry-schema.unknown-predicate` (the OTHER ceiling diagnostic the README named) is deliberately
|
|
85
|
+
# NOT implemented: distinguishing a genuinely-unrecognised predicate NAME from one of dry-schema's
|
|
86
|
+
# many legitimate fine-grained predicates (`size?`, `gt?`, `format?`, `included_in?`, ...) that
|
|
87
|
+
# this scanner simply doesn't model would need a complete predicate registry this plugin doesn't
|
|
88
|
+
# have, and a `required(:key)` row with NO type-bearing predicate at all is itself entirely
|
|
89
|
+
# legitimate dry-schema (a presence-only check). Guessing here risks flagging correct code — the
|
|
90
|
+
# AGENTS.md "false positives outrank worst-case reading" call, applied by declining.
|
|
91
|
+
def unknown_type_issues(root)
|
|
92
|
+
issues = []
|
|
93
|
+
walk_for_unknown_type(root, issues)
|
|
94
|
+
issues
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def walk_for_unknown_type(node, issues)
|
|
98
|
+
return if node.nil?
|
|
99
|
+
|
|
100
|
+
if node.is_a?(Prism::CallNode) && schema_entry_call?(node) && node.block
|
|
101
|
+
collect_row_issues(node.block, issues)
|
|
102
|
+
end
|
|
103
|
+
node.compact_child_nodes.each { |child| walk_for_unknown_type(child, issues) }
|
|
104
|
+
end
|
|
105
|
+
private_class_method :walk_for_unknown_type
|
|
106
|
+
|
|
107
|
+
def collect_row_issues(block_node, issues)
|
|
108
|
+
body = block_node.body
|
|
109
|
+
return if body.nil?
|
|
110
|
+
|
|
111
|
+
children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
112
|
+
children.each { |child| visit_chain_for_issues(child, issues) }
|
|
113
|
+
end
|
|
114
|
+
private_class_method :collect_row_issues
|
|
115
|
+
|
|
116
|
+
def visit_chain_for_issues(node, issues)
|
|
117
|
+
return unless node.is_a?(Prism::CallNode)
|
|
118
|
+
|
|
119
|
+
key, = extract_key_and_kind(node)
|
|
120
|
+
return if key.nil?
|
|
121
|
+
|
|
122
|
+
current = node
|
|
123
|
+
while current.is_a?(Prism::CallNode)
|
|
124
|
+
if current.name == :each && current.block
|
|
125
|
+
collect_row_issues(nested_block_body(current.block), issues)
|
|
126
|
+
return
|
|
127
|
+
end
|
|
128
|
+
if TYPE_BEARING_PREDICATES.include?(current.name)
|
|
129
|
+
record_unknown_type_issue(current, key, issues)
|
|
130
|
+
return
|
|
131
|
+
end
|
|
132
|
+
current = current.receiver
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
private_class_method :visit_chain_for_issues
|
|
136
|
+
|
|
137
|
+
def record_unknown_type_issue(call_node, key, issues)
|
|
138
|
+
arg = call_node.arguments&.arguments&.first
|
|
139
|
+
return unless arg.is_a?(Prism::SymbolNode)
|
|
140
|
+
|
|
141
|
+
symbol = arg.unescaped.to_sym
|
|
142
|
+
return if CANONICAL_TYPES.key?(symbol)
|
|
143
|
+
|
|
144
|
+
issues << { node: call_node, key: key, symbol: symbol }
|
|
145
|
+
end
|
|
146
|
+
private_class_method :record_unknown_type_issue
|
|
147
|
+
|
|
70
148
|
# Walks the AST collecting `<Const> = Dry::Schema.X { ... }` assignments at any nesting level.
|
|
71
149
|
# Tracks the enclosing constant chain so a class-level `class Foo; SCHEMA = Dry::Schema.Params
|
|
72
150
|
# { ... }; end` registers as `"Foo::SCHEMA"`.
|
|
@@ -102,7 +180,7 @@ module Rigor
|
|
|
102
180
|
return {} unless rhs.is_a?(Prism::CallNode) && rhs.block
|
|
103
181
|
|
|
104
182
|
schema_const = (qualified_prefix + [node.name.to_s]).join("::")
|
|
105
|
-
shape = collect_schema_shape(rhs.block, type_aliases)
|
|
183
|
+
shape = collect_schema_shape(rhs.block, type_aliases, nested: false)
|
|
106
184
|
{ schema_const => shape }
|
|
107
185
|
end
|
|
108
186
|
private_class_method :collect_schema_assignment
|
|
@@ -120,29 +198,51 @@ module Rigor
|
|
|
120
198
|
end
|
|
121
199
|
private_class_method :schema_entry_call?
|
|
122
200
|
|
|
123
|
-
|
|
201
|
+
# PUBLIC reuse surface (issue #137 slices 2/3): `rigor-dry-validation`'s `params { ... }` /
|
|
202
|
+
# `json { ... }` block body is the SAME dry-schema DSL a top-level `Dry::Schema.X { ... }` body
|
|
203
|
+
# is, so that plugin delegates here instead of duplicating the required/optional walk — the
|
|
204
|
+
# `docs/design/20260517-dry-validation-slicing.md` slicing note's own "delegate to
|
|
205
|
+
# rigor-dry-schema's walker" option. Only reachable from another plugin when `rigor-dry-schema`
|
|
206
|
+
# is registered (the caller checks `Rigor::Plugin.registered_for("dry-schema")` first — that is
|
|
207
|
+
# also what makes THIS class already loaded/defined for the caller to reference).
|
|
208
|
+
#
|
|
209
|
+
# `nested:` is false at the top-level `Dry::Schema.X { ... }` body and true inside an `each do
|
|
210
|
+
# ... end` row's own recursive call (see {#each_block_type_info}). It caps the recursion at ONE
|
|
211
|
+
# level deep: with `nested: true`, {#walk_predicate_chain} declines a FURTHER `each do ... end`
|
|
212
|
+
# rather than recursing again (issue #137 deliberately does not model doubly-nested arrays of
|
|
213
|
+
# arrays). This also keeps `collect_schema_shape` from calling itself — {#each_block_type_info}
|
|
214
|
+
# is the only caller that ever passes `nested: true}` — so the call graph has no cycle for the
|
|
215
|
+
# engine's return-type inference to approximate through.
|
|
216
|
+
def collect_schema_shape(block_node, type_aliases, nested:)
|
|
124
217
|
required = {}
|
|
125
218
|
optional = {}
|
|
126
|
-
|
|
219
|
+
declared = { required: [], optional: [] }
|
|
220
|
+
walk_block_body(block_node, type_aliases, nested) do |kind, key, type_info|
|
|
221
|
+
declared[kind] << key
|
|
127
222
|
(kind == :required ? required : optional)[key] = type_info if type_info
|
|
128
223
|
end
|
|
129
224
|
|
|
130
225
|
remap_aliases!(required, type_aliases)
|
|
131
226
|
remap_aliases!(optional, type_aliases)
|
|
132
227
|
|
|
133
|
-
{
|
|
228
|
+
{
|
|
229
|
+
required: required.freeze,
|
|
230
|
+
optional: optional.freeze,
|
|
231
|
+
unmodelled: unmodelled_keys(declared, required, optional)
|
|
232
|
+
}.freeze
|
|
134
233
|
end
|
|
135
|
-
private_class_method :collect_schema_shape
|
|
136
234
|
|
|
137
235
|
# Walks every top-level `required(:key).<predicate>(...)` / `optional(:key).<predicate>(...)`
|
|
138
236
|
# chain in the block body. The block's body is either a `Prism::StatementsNode` (multi-statement)
|
|
139
|
-
# or a single expression node.
|
|
140
|
-
|
|
237
|
+
# or a single expression node. `type_aliases` threads down to a nested `each do ... end` row's own
|
|
238
|
+
# recursive {#collect_schema_shape} call (slice 2's alias resolution applies at every nesting
|
|
239
|
+
# depth, not just the top level).
|
|
240
|
+
def walk_block_body(block_node, type_aliases, nested, &)
|
|
141
241
|
body = block_node.body
|
|
142
242
|
return if body.nil?
|
|
143
243
|
|
|
144
244
|
children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
145
|
-
children.each { |child| visit_chain(child, &) }
|
|
245
|
+
children.each { |child| visit_chain(child, type_aliases, nested, &) }
|
|
146
246
|
end
|
|
147
247
|
private_class_method :walk_block_body
|
|
148
248
|
|
|
@@ -151,13 +251,13 @@ module Rigor
|
|
|
151
251
|
# the chain's tail. The `each(<Type>)` predicate yields a list-of-element type info (`{type: <T>,
|
|
152
252
|
# list: true}`); other type-bearing predicates (`filled`/`value`/`maybe`) yield scalar info
|
|
153
253
|
# (`{type: <T>, list: false}`).
|
|
154
|
-
def visit_chain(node, &block)
|
|
254
|
+
def visit_chain(node, type_aliases, nested, &block)
|
|
155
255
|
return unless node.is_a?(Prism::CallNode)
|
|
156
256
|
|
|
157
257
|
key, kind = extract_key_and_kind(node)
|
|
158
258
|
return if key.nil?
|
|
159
259
|
|
|
160
|
-
type_info = walk_predicate_chain(node)
|
|
260
|
+
type_info = walk_predicate_chain(node, type_aliases, nested)
|
|
161
261
|
block.call(kind, key, type_info)
|
|
162
262
|
end
|
|
163
263
|
private_class_method :visit_chain
|
|
@@ -183,9 +283,19 @@ module Rigor
|
|
|
183
283
|
# Walks the call chain finding the first type-bearing predicate (`filled` / `value` / `maybe` /
|
|
184
284
|
# `each`) and extracts its argument type. Returns a `{type:, list:}` tuple (`each` is the only
|
|
185
285
|
# verb that produces a list) or nil when no recognisable type sits on the chain.
|
|
186
|
-
|
|
286
|
+
#
|
|
287
|
+
# `each do ... end` (a block instead of a type-symbol argument) is the ceiling's element-type
|
|
288
|
+
# recursion (issue #137): the block is itself a nested schema declaration, walked with the SAME
|
|
289
|
+
# algorithm as a top-level `Dry::Schema.X { ... }` body via {#each_block_type_info}. Already
|
|
290
|
+
# `nested` (i.e. this chain is itself inside another `each do ... end`) declines a FURTHER each —
|
|
291
|
+
# see {#collect_schema_shape}'s `nested:` doc for why the recursion caps at one level.
|
|
292
|
+
def walk_predicate_chain(node, type_aliases, nested)
|
|
187
293
|
current = node
|
|
188
294
|
while current.is_a?(Prism::CallNode)
|
|
295
|
+
if current.name == :each && current.block
|
|
296
|
+
return nested ? nil : each_block_type_info(current.block, type_aliases)
|
|
297
|
+
end
|
|
298
|
+
|
|
189
299
|
if TYPE_BEARING_PREDICATES.include?(current.name)
|
|
190
300
|
underlying = extract_type_from_predicate(current)
|
|
191
301
|
return { type: underlying, list: current.name == :each } if underlying
|
|
@@ -196,6 +306,40 @@ module Rigor
|
|
|
196
306
|
end
|
|
197
307
|
private_class_method :walk_predicate_chain
|
|
198
308
|
|
|
309
|
+
# `required(:key).each do ... end` — recurses into the each-block using {#collect_schema_shape},
|
|
310
|
+
# the identical row-collection algorithm a top-level schema body uses, so a nested `required` /
|
|
311
|
+
# `optional` row gets the same predicate vocabulary, alias resolution, and untyped-row fallback as
|
|
312
|
+
# the outer schema. Two spellings are recognised: the bare `each do required(:x)...; end` form, and
|
|
313
|
+
# `each do schema do required(:x)...; end end` (dry-schema's alternate nested-hash spelling) via
|
|
314
|
+
# {#nested_block_body}.
|
|
315
|
+
#
|
|
316
|
+
# A block that yields no row at all — a bare per-element predicate like `each { int? }`, which
|
|
317
|
+
# this scanner does not model — declines (nil) rather than returning an empty nested shape: the key
|
|
318
|
+
# falls through to {#unmodelled_keys} and renders `untyped`, the same "declined, not wrong" posture
|
|
319
|
+
# every other unresolvable row already gets.
|
|
320
|
+
def each_block_type_info(each_block, type_aliases)
|
|
321
|
+
nested_shape = collect_schema_shape(nested_block_body(each_block), type_aliases, nested: true)
|
|
322
|
+
return nil if nested_shape[:required].empty? && nested_shape[:optional].empty?
|
|
323
|
+
|
|
324
|
+
{ type: { nested: nested_shape }, list: true }
|
|
325
|
+
end
|
|
326
|
+
private_class_method :each_block_type_info
|
|
327
|
+
|
|
328
|
+
# Unwraps the `each do schema do ... end end` spelling to the inner `schema` block; the bare
|
|
329
|
+
# `each do required(...); ...; end` spelling passes the each-block through unchanged. Both declare
|
|
330
|
+
# the same nested key set, just at a different literal nesting depth.
|
|
331
|
+
def nested_block_body(each_block)
|
|
332
|
+
body = each_block.body
|
|
333
|
+
return each_block if body.nil?
|
|
334
|
+
|
|
335
|
+
children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
336
|
+
return each_block unless children.size == 1
|
|
337
|
+
|
|
338
|
+
single = children.first
|
|
339
|
+
single.is_a?(Prism::CallNode) && single.name == :schema && single.block ? single.block : each_block
|
|
340
|
+
end
|
|
341
|
+
private_class_method :nested_block_body
|
|
342
|
+
|
|
199
343
|
# Reads the first positional argument of a `filled(:string)` / `value(:integer)` /
|
|
200
344
|
# `maybe(Types::Email)` call. Returns either the canonical-type-symbol's underlying class
|
|
201
345
|
# ("String" / "Integer" / …), or the constant's qualified name for downstream type-alias
|
|
@@ -213,15 +357,36 @@ module Rigor
|
|
|
213
357
|
end
|
|
214
358
|
private_class_method :extract_type_from_predicate
|
|
215
359
|
|
|
360
|
+
# Keys the schema declares that this scanner saw but could not type — a predicate outside the
|
|
361
|
+
# canonical vocabulary (`filled(:not_a_type)`), a nested `schema do … end` row, or a constant
|
|
362
|
+
# alias no `:dry_type_aliases` fact resolved.
|
|
363
|
+
#
|
|
364
|
+
# They are recorded rather than forgotten so the schema's declared key set survives the scan even
|
|
365
|
+
# where its types do not. {ResultShape} puts them back into the synthesized `to_h` shape as untyped
|
|
366
|
+
# entries, which is what makes a hover read `{ email: String, address: Dynamic[top], ... }` instead
|
|
367
|
+
# of a shape indistinguishable from one that never declared `address`. Consumers that only want
|
|
368
|
+
# typed keys read `:required` / `:optional` and are unaffected.
|
|
369
|
+
def unmodelled_keys(declared, required, optional)
|
|
370
|
+
{
|
|
371
|
+
required: (declared[:required] - required.keys).uniq.freeze,
|
|
372
|
+
optional: (declared[:optional] - optional.keys).uniq.freeze
|
|
373
|
+
}.freeze
|
|
374
|
+
end
|
|
375
|
+
private_class_method :unmodelled_keys
|
|
376
|
+
|
|
216
377
|
# In-place: any value's `type:` slot in `bucket` that doesn't already match a canonical class
|
|
217
378
|
# (e.g. `"Types::Email"`) gets resolved through the type_aliases fact. Unresolvable values drop
|
|
218
379
|
# from the bucket (no fact contribution rather than misleading data). The `list:` slot rides along
|
|
219
380
|
# unchanged.
|
|
381
|
+
#
|
|
382
|
+
# A nested-shape row (`type: {nested: {...}}`, from an `each do ... end` element-type recursion)
|
|
383
|
+
# is already fully resolved by its own recursive {#collect_schema_shape} call — its `type:` slot is
|
|
384
|
+
# a Hash, not a class-name String, and is skipped here rather than treated as an unresolvable alias.
|
|
220
385
|
def remap_aliases!(bucket, type_aliases)
|
|
221
386
|
canonical_set = CANONICAL_TYPES.values.to_set
|
|
222
387
|
bucket.each_pair.to_a.each do |key, info|
|
|
223
388
|
type_name = info.fetch(:type)
|
|
224
|
-
next if canonical_set.include?(type_name)
|
|
389
|
+
next if type_name.is_a?(Hash) || canonical_set.include?(type_name)
|
|
225
390
|
|
|
226
391
|
resolved = type_aliases[type_name]
|
|
227
392
|
if resolved
|
|
@@ -5,6 +5,7 @@ require "prism"
|
|
|
5
5
|
require "rigor/plugin"
|
|
6
6
|
|
|
7
7
|
require_relative "dry_schema/schema_scanner"
|
|
8
|
+
require_relative "dry_schema/result_shape"
|
|
8
9
|
|
|
9
10
|
module Rigor
|
|
10
11
|
module Plugin
|
|
@@ -58,14 +59,22 @@ module Rigor
|
|
|
58
59
|
# above.
|
|
59
60
|
# - Publishes the table; no user-facing diagnostics yet.
|
|
60
61
|
#
|
|
61
|
-
#
|
|
62
|
+
# Landed since: typed `result.to_h` returns, via the {.dynamic_return} rule below rather than the
|
|
63
|
+
# ADR-16 Tier C substrate the README originally projected — Tier C synthesises methods on a class
|
|
64
|
+
# from a class-level DSL call, and this is a return type for a call chain off a constant.
|
|
62
65
|
#
|
|
63
|
-
#
|
|
64
|
-
#
|
|
65
|
-
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
66
|
+
# Landed since (issue #137): `each do ... end` element-type recursion ({SchemaScanner#each_block_type_info})
|
|
67
|
+
# and the `dry-schema.unknown-type` `:info` diagnostic ({SchemaScanner#unknown_type_issues}), wired
|
|
68
|
+
# below via {.node_file_context} + {.node_rule}.
|
|
69
|
+
#
|
|
70
|
+
# The **ceiling** (still open):
|
|
71
|
+
#
|
|
72
|
+
# - Nested schemas OUTSIDE an `each` (`required(:x).schema do ... end` with no `each` in the chain) —
|
|
73
|
+
# the key is still kept as untyped; only the `each`-wrapped nested form recurses.
|
|
74
|
+
# - `predicates(:size?)` / other fine-grained predicate constraint walks.
|
|
75
|
+
# - `dry-schema.unknown-predicate` — deliberately NOT shipped; see {SchemaScanner.unknown_type_issues}
|
|
76
|
+
# for why (no reliable static signal without a full predicate registry; risks flagging correct code).
|
|
77
|
+
# - `rigor-dry-validation` integration — landed separately in that plugin's own slices 2/3 (issue #137).
|
|
69
78
|
class DrySchema < Rigor::Plugin::Base
|
|
70
79
|
manifest(
|
|
71
80
|
id: "dry-schema",
|
|
@@ -76,6 +85,46 @@ module Rigor
|
|
|
76
85
|
consumes: [{ plugin_id: "dry-types", name: :dry_type_aliases, optional: true }]
|
|
77
86
|
)
|
|
78
87
|
|
|
88
|
+
# ADR-37 dynamic return — `NewUserSchema.call(input).to_h` yields the schema's own
|
|
89
|
+
# `HashShape[{email: String, ?nickname: String}]` instead of the untyped hash the DSL's boundary
|
|
90
|
+
# otherwise leaves behind. The shape's required/optional split and its open extra-key policy are
|
|
91
|
+
# argued in {ResultShape}.
|
|
92
|
+
#
|
|
93
|
+
# Gated on the method name alone, not on a `Dry::Schema::Result` receiver: this plugin ships no RBS
|
|
94
|
+
# overlay for dry-schema, so in an ordinary project the receiver of `.to_h` is untyped and a
|
|
95
|
+
# `receivers:` gate would never fire. The cost of the wider gate is bounded — the engine compiles
|
|
96
|
+
# `methods:` into its registry name gate, so the block is consulted only for `.to_h` calls, and its
|
|
97
|
+
# first act rejects any chain that is not `<Const>.call(...).to_h` before the table is touched.
|
|
98
|
+
dynamic_return methods: [:to_h] do |call_node, _scope|
|
|
99
|
+
result_shape_for(call_node)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# ADR-37 slice 1c two-pass: the "collect" half of `dry-schema.unknown-type` (issue #137's ceiling
|
|
103
|
+
# diagnostic). Walks the already-parsed file root once via {SchemaScanner.unknown_type_issues}; the
|
|
104
|
+
# `node_rule` below is the "validate" half that turns each collected issue into a Diagnostic.
|
|
105
|
+
node_file_context do |root, _scope|
|
|
106
|
+
SchemaScanner.unknown_type_issues(root)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Fires exactly once per file: `Prism::ProgramNode` is the AST root, and the engine's node walker
|
|
110
|
+
# visits the root itself before descending (see `Source::NodeWalker#walk_with_ancestors`). Cheaper
|
|
111
|
+
# than a `Prism::CallNode` rule re-matching every call site in the file — the collect pass already
|
|
112
|
+
# did the real walk.
|
|
113
|
+
node_rule Prism::ProgramNode do |_node, _scope, path, issues|
|
|
114
|
+
next [] if issues.nil? || issues.empty?
|
|
115
|
+
|
|
116
|
+
issues.map do |issue|
|
|
117
|
+
diagnostic(
|
|
118
|
+
issue[:node],
|
|
119
|
+
path: path,
|
|
120
|
+
message: "`#{issue[:key]}` uses `:#{issue[:symbol]}`, which is not a recognised dry-schema " \
|
|
121
|
+
"canonical type symbol (see the plugin README's predicate type table)",
|
|
122
|
+
severity: :info,
|
|
123
|
+
rule: "dry-schema.unknown-type"
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
79
128
|
# Walks every project file once during `prepare(services)` to build the schema table, then publishes
|
|
80
129
|
# via the ADR-9 fact store. Mirrors the rigor-dry-types `#prepare` shape — the walk is bounded by
|
|
81
130
|
# `paths:`, parse errors degrade silently.
|
|
@@ -93,10 +142,28 @@ module Rigor
|
|
|
93
142
|
|
|
94
143
|
def init(_services)
|
|
95
144
|
@scannable_paths = nil
|
|
145
|
+
@result_shapes = {}
|
|
96
146
|
end
|
|
97
147
|
|
|
98
148
|
private
|
|
99
149
|
|
|
150
|
+
# The HashShape for a `<Const>.call(...).to_h` chain, or nil when the chain isn't that shape, names
|
|
151
|
+
# no schema in the table, or names one that declares no key.
|
|
152
|
+
# Memoised per schema name INCLUDING nil: a carrier is a value object, and the miss is the common
|
|
153
|
+
# case on any project that calls `to_h` on something that isn't a schema result.
|
|
154
|
+
def result_shape_for(call_node)
|
|
155
|
+
name = ResultShape.schema_name(call_node)
|
|
156
|
+
return nil if name.nil?
|
|
157
|
+
|
|
158
|
+
# `||=` rather than a plain read of an `init`-assigned ivar: `dynamic_return_type` rescues every
|
|
159
|
+
# StandardError to nil, so a NoMethodError here would turn the whole feature off in silence.
|
|
160
|
+
shapes = (@result_shapes ||= {})
|
|
161
|
+
return shapes[name] if shapes.key?(name)
|
|
162
|
+
|
|
163
|
+
entry = read_fact(plugin_id: manifest.id, name: :dry_schema_table)&.[](name)
|
|
164
|
+
shapes[name] = entry.nil? ? nil : ResultShape.build(entry)
|
|
165
|
+
end
|
|
166
|
+
|
|
100
167
|
def scannable_paths(services)
|
|
101
168
|
@scannable_paths ||= services.configuration.paths.flat_map do |entry|
|
|
102
169
|
if File.directory?(entry)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
|
+
require "rigor/source/literals"
|
|
4
5
|
|
|
5
6
|
module Rigor
|
|
6
7
|
module Plugin
|
|
@@ -41,6 +42,262 @@ module Rigor
|
|
|
41
42
|
end
|
|
42
43
|
private_class_method :scan_file
|
|
43
44
|
|
|
45
|
+
# Issue #137 slices 2/3 — per-Contract `params { ... }` / `json { ... }` shapes, delegating the
|
|
46
|
+
# actual required/optional walk to `rigor-dry-schema`'s {DrySchema::SchemaScanner.collect_schema_shape}
|
|
47
|
+
# (the SAME dry-schema DSL, just under a bare `params`/`json` call instead of
|
|
48
|
+
# `Dry::Schema.Params { ... }`). Returns `{}` immediately, without touching a single file, when
|
|
49
|
+
# `rigor-dry-schema` isn't loaded — the companion plugin's absence is a hard precondition here,
|
|
50
|
+
# not a degrade-gracefully fallback, because this plugin ships no required/optional walker of
|
|
51
|
+
# its own.
|
|
52
|
+
#
|
|
53
|
+
# @return [Hash{String => Hash{Symbol => Hash}}] contract FQN => `{params: <shape>}` and/or
|
|
54
|
+
# `{json: <shape>}` (only the recognised key(s) are present; a contract with neither present
|
|
55
|
+
# contributes nothing). Each `<shape>` is exactly {DrySchema::SchemaScanner.collect_schema_shape}'s
|
|
56
|
+
# `{required:, optional:, unmodelled:}` return.
|
|
57
|
+
def scan_schema_blocks(paths:, type_aliases: {})
|
|
58
|
+
return {} unless Rigor::Plugin.registered_for("dry-schema")
|
|
59
|
+
|
|
60
|
+
table = {}
|
|
61
|
+
paths.each do |path|
|
|
62
|
+
scan_file_schema_blocks(path, type_aliases).each do |fqn, shapes|
|
|
63
|
+
table[fqn] ||= shapes
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
table.freeze
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def scan_file_schema_blocks(path, type_aliases)
|
|
70
|
+
source = File.read(path)
|
|
71
|
+
parse_result = Prism.parse(source, filepath: path)
|
|
72
|
+
return {} unless parse_result.errors.empty?
|
|
73
|
+
|
|
74
|
+
collect_params_blocks(parse_result.value, [], type_aliases)
|
|
75
|
+
rescue StandardError
|
|
76
|
+
{}
|
|
77
|
+
end
|
|
78
|
+
private_class_method :scan_file_schema_blocks
|
|
79
|
+
|
|
80
|
+
# Mirrors {#collect_contracts}'s recursive shape, additionally capturing each recognised
|
|
81
|
+
# Contract's own `params { ... }` / `json { ... }` shape.
|
|
82
|
+
def collect_params_blocks(node, qualified_prefix, type_aliases)
|
|
83
|
+
return {} if node.nil?
|
|
84
|
+
|
|
85
|
+
case node
|
|
86
|
+
when Prism::ClassNode
|
|
87
|
+
collect_class_params_blocks(node, qualified_prefix, type_aliases)
|
|
88
|
+
when Prism::ModuleNode
|
|
89
|
+
inner_name = constant_name_for(node.constant_path)
|
|
90
|
+
return {} if inner_name.nil?
|
|
91
|
+
|
|
92
|
+
collect_params_blocks(node.body, qualified_prefix + [inner_name], type_aliases)
|
|
93
|
+
else
|
|
94
|
+
node.compact_child_nodes.each_with_object({}) do |child, acc|
|
|
95
|
+
collect_params_blocks(child, qualified_prefix, type_aliases).each { |k, v| acc[k] ||= v }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
private_class_method :collect_params_blocks
|
|
100
|
+
|
|
101
|
+
def collect_class_params_blocks(node, qualified_prefix, type_aliases)
|
|
102
|
+
inner_name = constant_name_for(node.constant_path)
|
|
103
|
+
return {} if inner_name.nil?
|
|
104
|
+
|
|
105
|
+
new_prefix = qualified_prefix + [inner_name]
|
|
106
|
+
inner = collect_params_blocks(node.body, new_prefix, type_aliases)
|
|
107
|
+
|
|
108
|
+
if contract_subclass?(node)
|
|
109
|
+
shapes = class_body_schema_shapes(node.body, type_aliases)
|
|
110
|
+
inner[new_prefix.join("::")] = shapes unless shapes.empty?
|
|
111
|
+
end
|
|
112
|
+
inner
|
|
113
|
+
end
|
|
114
|
+
private_class_method :collect_class_params_blocks
|
|
115
|
+
|
|
116
|
+
# ONLY a top-level `params do ... end` / `json do ... end` call directly inside the Contract's
|
|
117
|
+
# class body — a bare call, no receiver, no positional/keyword arguments, exactly one block.
|
|
118
|
+
# A call nested inside a conditional, a method def, or wrapped in any other construct is NOT
|
|
119
|
+
# this floor; it's indistinguishable here from a dynamically-built or externally-shared schema,
|
|
120
|
+
# and the conservative read is to contribute nothing rather than guess.
|
|
121
|
+
def class_body_schema_shapes(body, type_aliases)
|
|
122
|
+
return {} if body.nil?
|
|
123
|
+
|
|
124
|
+
children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
125
|
+
shapes = {}
|
|
126
|
+
children.each do |child|
|
|
127
|
+
next unless bare_block_call?(child)
|
|
128
|
+
|
|
129
|
+
case child.name
|
|
130
|
+
when :params then shapes[:params] ||= DrySchema::SchemaScanner.collect_schema_shape(child.block, type_aliases, nested: false)
|
|
131
|
+
when :json then shapes[:json] ||= DrySchema::SchemaScanner.collect_schema_shape(child.block, type_aliases, nested: false)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
shapes
|
|
135
|
+
end
|
|
136
|
+
private_class_method :class_body_schema_shapes
|
|
137
|
+
|
|
138
|
+
def bare_block_call?(node)
|
|
139
|
+
node.is_a?(Prism::CallNode) && node.receiver.nil? && node.arguments.nil? &&
|
|
140
|
+
node.block.is_a?(Prism::BlockNode)
|
|
141
|
+
end
|
|
142
|
+
private_class_method :bare_block_call?
|
|
143
|
+
|
|
144
|
+
# `dry-validation.rule-key-mismatch` (issue #137's remaining ceiling checkbox) — walks an
|
|
145
|
+
# ALREADY-PARSED file's root node collecting `{node:, key:, contract_fqn:}` issues for a
|
|
146
|
+
# `rule(:sym, ...) do ... end` call whose Symbol argument names a key that is NOT in the
|
|
147
|
+
# Contract's OWN, cleanly-recognised `params`/`json` key set.
|
|
148
|
+
#
|
|
149
|
+
# FP-gated hard, per two independent all-or-nothing checks:
|
|
150
|
+
#
|
|
151
|
+
# - {#clean_known_keys} — the Contract's key universe is trusted ONLY when EVERY top-level
|
|
152
|
+
# statement in EVERY `params`/`json` block is a recognised `required`/`optional` row (typed or
|
|
153
|
+
# not — an unmodelled row is still a DECLARED key) or the inert `config.<x> = <literal>` idiom.
|
|
154
|
+
# A loop building keys dynamically, a splat, an `include`, a nested schema merge, or a
|
|
155
|
+
# `params`/`json(SomeSchema)` delegating to an externally-shared schema — ANY of these makes
|
|
156
|
+
# the WHOLE Contract's key universe untrustworthy, not just the offending row, and the
|
|
157
|
+
# Contract is skipped outright (no diagnostic against any of its `rule()` calls).
|
|
158
|
+
# - {#rule_arguments_or_nil} — a `rule(...)` call is checked ONLY when EVERY argument is a
|
|
159
|
+
# literal Symbol. A splat (`rule(*keys)`), a String, a local variable, or a Hash (the
|
|
160
|
+
# nested-key form `rule(user: [:name])`) makes the WHOLE call unresolvable, not just that one
|
|
161
|
+
# argument, and it is skipped rather than partially validated.
|
|
162
|
+
#
|
|
163
|
+
# Returns `[]` immediately, without touching a single file, when `rigor-dry-schema` isn't loaded
|
|
164
|
+
# — there is no key universe to check a `rule()` call against without it.
|
|
165
|
+
def rule_key_issues(root, type_aliases)
|
|
166
|
+
return [] unless Rigor::Plugin.registered_for("dry-schema")
|
|
167
|
+
|
|
168
|
+
issues = []
|
|
169
|
+
walk_for_rule_issues(root, [], type_aliases, issues)
|
|
170
|
+
issues
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def walk_for_rule_issues(node, qualified_prefix, type_aliases, issues)
|
|
174
|
+
return if node.nil?
|
|
175
|
+
|
|
176
|
+
case node
|
|
177
|
+
when Prism::ClassNode
|
|
178
|
+
inner_name = constant_name_for(node.constant_path)
|
|
179
|
+
return if inner_name.nil?
|
|
180
|
+
|
|
181
|
+
new_prefix = qualified_prefix + [inner_name]
|
|
182
|
+
check_contract_rules(node, new_prefix.join("::"), type_aliases, issues) if contract_subclass?(node)
|
|
183
|
+
walk_for_rule_issues(node.body, new_prefix, type_aliases, issues)
|
|
184
|
+
when Prism::ModuleNode
|
|
185
|
+
inner_name = constant_name_for(node.constant_path)
|
|
186
|
+
return if inner_name.nil?
|
|
187
|
+
|
|
188
|
+
walk_for_rule_issues(node.body, qualified_prefix + [inner_name], type_aliases, issues)
|
|
189
|
+
else
|
|
190
|
+
node.compact_child_nodes.each { |child| walk_for_rule_issues(child, qualified_prefix, type_aliases, issues) }
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
private_class_method :walk_for_rule_issues
|
|
194
|
+
|
|
195
|
+
def check_contract_rules(class_node, contract_fqn, type_aliases, issues)
|
|
196
|
+
body = class_node.body
|
|
197
|
+
return if body.nil?
|
|
198
|
+
|
|
199
|
+
children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
200
|
+
known_keys = clean_known_keys(children, type_aliases)
|
|
201
|
+
return if known_keys.nil?
|
|
202
|
+
|
|
203
|
+
children.each do |child|
|
|
204
|
+
next unless rule_call?(child)
|
|
205
|
+
|
|
206
|
+
check_rule_call(child, contract_fqn, known_keys, issues)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
private_class_method :check_contract_rules
|
|
210
|
+
|
|
211
|
+
# The Contract's full declared key set (required + optional + unmodelled, across BOTH `params`
|
|
212
|
+
# and `json` if both are present — a Contract having both is unusual, so the conservative read
|
|
213
|
+
# is the union rather than guessing which one a given `rule()` targets), or nil when the
|
|
214
|
+
# Contract has no `params`/`json` block at all OR either block fails {#clean_schema_block?}.
|
|
215
|
+
def clean_known_keys(children, type_aliases)
|
|
216
|
+
blocks = children.select { |c| bare_block_call?(c) && %i[params json].include?(c.name) }
|
|
217
|
+
return nil if blocks.empty?
|
|
218
|
+
|
|
219
|
+
keys = Set.new
|
|
220
|
+
blocks.each do |block_call|
|
|
221
|
+
return nil unless clean_schema_block?(block_call.block)
|
|
222
|
+
|
|
223
|
+
shape = DrySchema::SchemaScanner.collect_schema_shape(block_call.block, type_aliases, nested: false)
|
|
224
|
+
keys.merge(shape[:required].keys)
|
|
225
|
+
keys.merge(shape[:optional].keys)
|
|
226
|
+
keys.merge(shape[:unmodelled][:required])
|
|
227
|
+
keys.merge(shape[:unmodelled][:optional])
|
|
228
|
+
end
|
|
229
|
+
keys
|
|
230
|
+
end
|
|
231
|
+
private_class_method :clean_known_keys
|
|
232
|
+
|
|
233
|
+
def clean_schema_block?(block_node)
|
|
234
|
+
body = block_node.body
|
|
235
|
+
return true if body.nil? # empty block — vacuously clean, contributes no keys
|
|
236
|
+
|
|
237
|
+
children = body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
238
|
+
children.all? { |child| required_or_optional_row?(child) || inert_config_assignment?(child) }
|
|
239
|
+
end
|
|
240
|
+
private_class_method :clean_schema_block?
|
|
241
|
+
|
|
242
|
+
# A `required(:key)...` / `optional(:key)...` chain, ANY predicate suffix (even one this
|
|
243
|
+
# scanner can't type — an unmodelled row is still a row that DECLARES the key, which is all
|
|
244
|
+
# {#clean_known_keys} needs).
|
|
245
|
+
def required_or_optional_row?(node)
|
|
246
|
+
return false unless node.is_a?(Prism::CallNode)
|
|
247
|
+
|
|
248
|
+
current = node
|
|
249
|
+
while current.is_a?(Prism::CallNode)
|
|
250
|
+
if %i[required optional].include?(current.name)
|
|
251
|
+
return !Rigor::Source::Literals.symbol(current.arguments&.arguments&.first).nil?
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
current = current.receiver
|
|
255
|
+
end
|
|
256
|
+
false
|
|
257
|
+
end
|
|
258
|
+
private_class_method :required_or_optional_row?
|
|
259
|
+
|
|
260
|
+
# `config.validate_keys = true` and similar — dry-schema's own `config.<x> = <literal>` idiom.
|
|
261
|
+
# Declares no key, so it's safe to ignore rather than treat as "unrecognised" (over-declining a
|
|
262
|
+
# perfectly common line would silently drop valid diagnostics, not risk a false positive, but
|
|
263
|
+
# there's no reason to pay that cost).
|
|
264
|
+
def inert_config_assignment?(node)
|
|
265
|
+
node.is_a?(Prism::CallNode) && node.name.end_with?("=") &&
|
|
266
|
+
node.receiver.is_a?(Prism::CallNode) && node.receiver.name == :config && node.receiver.receiver.nil?
|
|
267
|
+
end
|
|
268
|
+
private_class_method :inert_config_assignment?
|
|
269
|
+
|
|
270
|
+
def rule_call?(node)
|
|
271
|
+
node.is_a?(Prism::CallNode) && node.name == :rule && node.receiver.nil? && node.block.is_a?(Prism::BlockNode)
|
|
272
|
+
end
|
|
273
|
+
private_class_method :rule_call?
|
|
274
|
+
|
|
275
|
+
def check_rule_call(rule_node, contract_fqn, known_keys, issues)
|
|
276
|
+
symbols = rule_arguments_or_nil(rule_node)
|
|
277
|
+
return if symbols.nil?
|
|
278
|
+
|
|
279
|
+
symbols.each do |arg|
|
|
280
|
+
key = arg.unescaped.to_sym
|
|
281
|
+
next if known_keys.include?(key)
|
|
282
|
+
|
|
283
|
+
issues << { node: arg, key: key, contract_fqn: contract_fqn, known_keys: known_keys }
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
private_class_method :check_rule_call
|
|
287
|
+
|
|
288
|
+
# Every positional argument of `rule(...)`, as `Prism::SymbolNode`s, or nil when there are none,
|
|
289
|
+
# or ANY of them isn't a literal Symbol (a splat, a String, a local variable, or a Hash — the
|
|
290
|
+
# `rule(user: [:name])` nested-key form). One non-Symbol argument makes the WHOLE call
|
|
291
|
+
# unresolvable, not just that argument.
|
|
292
|
+
def rule_arguments_or_nil(rule_node)
|
|
293
|
+
args = rule_node.arguments&.arguments
|
|
294
|
+
return nil if args.nil? || args.empty?
|
|
295
|
+
return nil unless args.all?(Prism::SymbolNode)
|
|
296
|
+
|
|
297
|
+
args
|
|
298
|
+
end
|
|
299
|
+
private_class_method :rule_arguments_or_nil
|
|
300
|
+
|
|
44
301
|
def collect_contracts(node, qualified_prefix)
|
|
45
302
|
return [] if node.nil?
|
|
46
303
|
|