rigortype 0.3.1 → 0.3.3
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/core_overlay/pathname.rbs +5 -0
- data/data/core_overlay/resolv.rbs +31 -0
- data/data/core_overlay/string_scanner.rbs +5 -0
- 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/manual/02-cli-reference.md +71 -11
- data/docs/manual/03-configuration.md +16 -1
- data/docs/manual/04-diagnostics.md +8 -0
- data/docs/manual/12-caching.md +17 -5
- data/docs/manual/15-type-protection-coverage.md +122 -1
- data/docs/manual/plugins/rigor-rbs-inline.md +22 -0
- data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +8 -0
- data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +53 -0
- data/lib/rigor/analysis/check_rules/rule_ids.rb +1 -0
- data/lib/rigor/analysis/check_rules/rule_walk.rb +7 -0
- data/lib/rigor/analysis/check_rules.rb +107 -28
- data/lib/rigor/analysis/incremental_session.rb +156 -11
- data/lib/rigor/analysis/plugin_fact_fingerprint.rb +14 -0
- data/lib/rigor/analysis/rule_catalog.rb +5 -1
- data/lib/rigor/analysis/run_cache_key.rb +17 -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 +78 -7
- data/lib/rigor/analysis/severity_stamp.rb +2 -1
- 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/cli/check_command.rb +49 -28
- data/lib/rigor/cli/coverage_command.rb +15 -2
- data/lib/rigor/cli/coverage_mutation.rb +242 -11
- 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/configuration.rb +45 -6
- data/lib/rigor/environment/bundle_sig_discovery.rb +2 -1
- data/lib/rigor/environment/rbs_coverage_report.rb +1 -1
- data/lib/rigor/environment/rbs_loader.rb +434 -75
- data/lib/rigor/environment.rb +28 -13
- data/lib/rigor/inference/anonymous_meta_class.rb +81 -0
- data/lib/rigor/inference/expression_typer.rb +62 -4
- data/lib/rigor/inference/fork_map.rb +47 -4
- 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/rbs_dispatch.rb +158 -12
- data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +64 -5
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +234 -11
- 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/method_dispatcher.rb +41 -10
- data/lib/rigor/inference/mutation_widening.rb +30 -25
- data/lib/rigor/inference/narrowing.rb +3 -1
- data/lib/rigor/inference/optimistic_origin.rb +123 -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 +197 -55
- data/lib/rigor/inference/singleton_object_constant.rb +63 -0
- data/lib/rigor/inference/statement_evaluator.rb +55 -4
- data/lib/rigor/language_server/buffer_table.rb +22 -0
- data/lib/rigor/language_server/diagnostic_publisher.rb +203 -4
- 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 +20 -1
- data/lib/rigor/language_server.rb +1 -0
- data/lib/rigor/plugin/node_rule_walk.rb +6 -0
- data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +9 -3
- data/lib/rigor/protection/closure_kill_oracle.rb +190 -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 +114 -10
- 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 +12 -0
- data/lib/rigor/sig_gen.rb +1 -0
- data/lib/rigor/source/node_walker.rb +12 -0
- data/lib/rigor/type/anonymous_class_name.rb +40 -0
- data/lib/rigor/type/nominal.rb +5 -2
- data/lib/rigor/type/singleton.rb +4 -1
- data/lib/rigor/version.rb +1 -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/inference/void_origin.rbs +6 -3
- data/sig/rigor/scope.rbs +12 -0
- data/sig/rigor.rbs +4 -0
- metadata +21 -1
|
@@ -14,6 +14,14 @@ module Rigor
|
|
|
14
14
|
#
|
|
15
15
|
# Non-Prism children (literals embedded in node attributes, virtual nodes, or `nil` slots) are silently
|
|
16
16
|
# skipped so callers can rely on every yielded value responding to the `Prism::Node` API.
|
|
17
|
+
#
|
|
18
|
+
# Issue #318 — a `Prism::DefinedNode`'s operand is never evaluated at runtime (`defined?` inspects the
|
|
19
|
+
# expression statically; it does not run it), so the walk yields the `DefinedNode` itself but does NOT
|
|
20
|
+
# descend into its `value` subtree. Every consumer of this walker treats a yielded node as "reachable,
|
|
21
|
+
# evaluated code" (mutation/break/return scans, the check-rules main-pass oracle, coverage and precision
|
|
22
|
+
# probes); walking into the operand would make them reason about code that can never run, which is
|
|
23
|
+
# exactly the false-positive class the issue reports (`defined?(@x) && ...` flagging a call that is
|
|
24
|
+
# actually inert).
|
|
17
25
|
module NodeWalker
|
|
18
26
|
module_function
|
|
19
27
|
|
|
@@ -30,6 +38,8 @@ module Rigor
|
|
|
30
38
|
return unless node.is_a?(Prism::Node)
|
|
31
39
|
|
|
32
40
|
yield node
|
|
41
|
+
return if node.is_a?(Prism::DefinedNode)
|
|
42
|
+
|
|
33
43
|
node.rigor_each_child { |child| walk(child, &) }
|
|
34
44
|
end
|
|
35
45
|
|
|
@@ -52,6 +62,8 @@ module Rigor
|
|
|
52
62
|
return unless node.is_a?(Prism::Node)
|
|
53
63
|
|
|
54
64
|
block.call(node, ancestors)
|
|
65
|
+
return if node.is_a?(Prism::DefinedNode)
|
|
66
|
+
|
|
55
67
|
ancestors.push(node)
|
|
56
68
|
node.rigor_each_child { |child| walk_with_ancestors(child, ancestors, &block) }
|
|
57
69
|
ancestors.pop
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Type
|
|
5
|
+
# #319 — the spelling of a class that Ruby created without a name: the `Class.new do ... end` /
|
|
6
|
+
# `Module.new do ... end` form away from constant-write position, which owns whatever its block body defines
|
|
7
|
+
# but has no constant to be keyed by.
|
|
8
|
+
#
|
|
9
|
+
# `Inference::AnonymousMetaClass` decides WHICH call sites get one and what goes in the key; this module owns
|
|
10
|
+
# the spelling, because the two places a name reaches a human live here. `#<Label:key>` is deliberately
|
|
11
|
+
# unspellable as a Ruby constant path, so a synthetic name can never collide with a real class in the
|
|
12
|
+
# discovery tables.
|
|
13
|
+
#
|
|
14
|
+
# Both renderings drop the key. `describe` keeps only the label (`#<Class>`) because the key is a file
|
|
15
|
+
# position — reproducing it would pin every `assert_type` fixture and precision snapshot to a line number.
|
|
16
|
+
# `erase_to_rbs` answers `untyped`: the name is not valid RBS, and emitting it from `rigor sig-gen` would
|
|
17
|
+
# produce a signature file that does not parse.
|
|
18
|
+
module AnonymousClassName
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
PREFIX = "#<"
|
|
22
|
+
|
|
23
|
+
# `build("Class", "lib/a.rb:3:17") #=> "#<Class:lib/a.rb:3:17>"`
|
|
24
|
+
def build(label, key)
|
|
25
|
+
"#{PREFIX}#{label}:#{key}>"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def match?(class_name)
|
|
29
|
+
class_name.is_a?(String) && class_name.start_with?(PREFIX)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# The display form: the label alone, key dropped.
|
|
33
|
+
def display(class_name)
|
|
34
|
+
return class_name unless match?(class_name)
|
|
35
|
+
|
|
36
|
+
"#{PREFIX}#{class_name.delete_prefix(PREFIX).split(':', 2).first}>"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/rigor/type/nominal.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../trinary"
|
|
4
4
|
require_relative "../value_semantics"
|
|
5
5
|
require_relative "acceptance_router"
|
|
6
|
+
require_relative "anonymous_class_name"
|
|
6
7
|
require_relative "plain_lattice"
|
|
7
8
|
|
|
8
9
|
module Rigor
|
|
@@ -35,13 +36,15 @@ module Rigor
|
|
|
35
36
|
end
|
|
36
37
|
|
|
37
38
|
def describe(verbosity = :short)
|
|
38
|
-
|
|
39
|
+
rendered_name = AnonymousClassName.display(class_name)
|
|
40
|
+
return rendered_name if type_args.empty?
|
|
39
41
|
|
|
40
42
|
rendered = type_args.map { |t| t.describe(verbosity) }.join(", ")
|
|
41
|
-
"#{
|
|
43
|
+
"#{rendered_name}[#{rendered}]"
|
|
42
44
|
end
|
|
43
45
|
|
|
44
46
|
def erase_to_rbs
|
|
47
|
+
return "untyped" if AnonymousClassName.match?(class_name)
|
|
45
48
|
return class_name if type_args.empty?
|
|
46
49
|
|
|
47
50
|
rendered = type_args.map(&:erase_to_rbs).join(", ")
|
data/lib/rigor/type/singleton.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../trinary"
|
|
4
4
|
require_relative "../value_semantics"
|
|
5
5
|
require_relative "acceptance_router"
|
|
6
|
+
require_relative "anonymous_class_name"
|
|
6
7
|
require_relative "plain_lattice"
|
|
7
8
|
|
|
8
9
|
module Rigor
|
|
@@ -26,10 +27,12 @@ module Rigor
|
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def describe(_verbosity = :short)
|
|
29
|
-
"singleton(#{class_name})"
|
|
30
|
+
"singleton(#{AnonymousClassName.display(class_name)})"
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
def erase_to_rbs
|
|
34
|
+
return "untyped" if AnonymousClassName.match?(class_name)
|
|
35
|
+
|
|
33
36
|
"singleton(#{class_name})"
|
|
34
37
|
end
|
|
35
38
|
|
data/lib/rigor/version.rb
CHANGED
|
@@ -248,10 +248,10 @@ class Time
|
|
|
248
248
|
def self.zone: () -> untyped # ActiveSupport::TimeZone | nil
|
|
249
249
|
def self.zone=: (String | Symbol | untyped) -> untyped
|
|
250
250
|
|
|
251
|
-
# `core_ext/time/conversions` —
|
|
252
|
-
# `time`, which
|
|
253
|
-
|
|
254
|
-
|
|
251
|
+
# `core_ext/time/conversions` — nothing to declare. `Time.parse`, `Time.httpdate` and
|
|
252
|
+
# `Time.iso8601` all come from stdlib `time`, which rbs types; ActiveSupport only re-opens them.
|
|
253
|
+
# Declaring them here raised `RBS::DuplicatedMethodDefinitionError` and collapsed the whole `Time`
|
|
254
|
+
# definition to `Dynamic[top]`.
|
|
255
255
|
|
|
256
256
|
# `core_ext/time/calculations`
|
|
257
257
|
def yesterday: () -> Time
|
|
@@ -281,7 +281,7 @@ class Time
|
|
|
281
281
|
def midday: () -> Time
|
|
282
282
|
def midnight: () -> Time
|
|
283
283
|
def noon: () -> Time
|
|
284
|
-
|
|
284
|
+
# NOTE: `Time#utc?` is NOT declared here — it is Ruby core, already typed by rbs's `core/time.rbs`.
|
|
285
285
|
def acts_like_time?: () -> true
|
|
286
286
|
# `advance(days: 1, months: -2)` and `all_day` (a `beginning_of_day..end_of_day` Range).
|
|
287
287
|
def advance: (untyped options) -> Time
|
|
@@ -468,7 +468,7 @@ end
|
|
|
468
468
|
|
|
469
469
|
class DateTime
|
|
470
470
|
def utc: () -> Time
|
|
471
|
-
|
|
471
|
+
# NOTE: `DateTime#to_time` is NOT declared here — stdlib `date` already types it.
|
|
472
472
|
def in_time_zone: (?String | Symbol zone) -> untyped
|
|
473
473
|
def yesterday: () -> DateTime
|
|
474
474
|
def tomorrow: () -> DateTime
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Plugin
|
|
7
|
+
class DrySchema < Rigor::Plugin::Base
|
|
8
|
+
# Translates one `:dry_schema_table` entry into the `Rigor::Type::HashShape` that
|
|
9
|
+
# `SomeSchema.call(input).to_h` returns, and recognises the call chain that earns it.
|
|
10
|
+
#
|
|
11
|
+
# The table entry is the shape {SchemaScanner} publishes:
|
|
12
|
+
#
|
|
13
|
+
# { required: { email: { type: "String", list: false } },
|
|
14
|
+
# optional: { nickname: { type: "String", list: true } } }
|
|
15
|
+
#
|
|
16
|
+
# ## Why the shape mirrors the declaration's own vocabulary
|
|
17
|
+
#
|
|
18
|
+
# `Dry::Schema::Result#to_h` returns the *coerced input*, so on a failed validation a
|
|
19
|
+
# `required(:email)` key can be absent. Modelling that worst case — every key optional — would be
|
|
20
|
+
# the sound reading and the wrong one: the idiomatic consumer checks `success?` first, and typing
|
|
21
|
+
# `result.to_h[:age]` as `Integer?` inside that branch draws a nil diagnostic on correct code.
|
|
22
|
+
# Rigor weighs a false positive above a worst-case static reading (AGENTS.md § Implementation
|
|
23
|
+
# Guidelines), and already took the same call for RBS's `%a{implicitly-returns-nil}`.
|
|
24
|
+
#
|
|
25
|
+
# So `required` rows become required keys and `optional` rows become optional keys — the schema's
|
|
26
|
+
# own words. A reader who wrote `optional(:nickname)` is not surprised that the key reads as
|
|
27
|
+
# possibly-absent, and one who wrote `required(:email)` is not surprised that it does not.
|
|
28
|
+
#
|
|
29
|
+
# The shape is **open**: dry-schema's key map only emits declared keys, but a closed shape would
|
|
30
|
+
# turn any read of an undeclared key into a diagnostic, and being wrong there costs more than the
|
|
31
|
+
# extra precision buys.
|
|
32
|
+
module ResultShape
|
|
33
|
+
# `:bool` rows land in the published fact as "TrueClass" — the fact's vocabulary names one
|
|
34
|
+
# underlying class per row and has no union slot. A hash value typed `TrueClass` would false-fire
|
|
35
|
+
# on every `false`, so the shape widens it back to the two-class union here rather than in the
|
|
36
|
+
# fact, whose consumers (rigor-dry-struct, rigor-dry-validation) read it for a class name.
|
|
37
|
+
BOOL_CLASSES = %w[TrueClass FalseClass].freeze
|
|
38
|
+
|
|
39
|
+
module_function
|
|
40
|
+
|
|
41
|
+
# The schema constant `to_h_node`'s receiver chain names, or nil when the chain isn't the
|
|
42
|
+
# recognised `<Const>.call(...).to_h` form.
|
|
43
|
+
#
|
|
44
|
+
# Floor: the schema must be named by a constant *as written* at the call site, because the FQN is
|
|
45
|
+
# matched against the table's keys verbatim. A schema referenced through a local
|
|
46
|
+
# (`schema = NewUserSchema; schema.call(x).to_h`) or by a relative constant path from inside the
|
|
47
|
+
# declaring module resolves to no entry and contributes nothing — the pre-slice behaviour.
|
|
48
|
+
def schema_name(to_h_node)
|
|
49
|
+
return nil unless to_h_node.is_a?(Prism::CallNode) && to_h_node.name == :to_h
|
|
50
|
+
return nil unless to_h_node.arguments.nil? && to_h_node.block.nil?
|
|
51
|
+
|
|
52
|
+
inner = to_h_node.receiver
|
|
53
|
+
return nil unless inner.is_a?(Prism::CallNode) && inner.name == :call
|
|
54
|
+
|
|
55
|
+
constant_name(inner.receiver)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The HashShape for one table entry, or nil when the schema declares no key at all. An empty open
|
|
59
|
+
# shape would be a carrier that says nothing the bare `untyped` did not already say, so declining
|
|
60
|
+
# keeps the hash's type honest about how little is known.
|
|
61
|
+
def build(entry)
|
|
62
|
+
unmodelled = entry[:unmodelled] || {}
|
|
63
|
+
required = pairs_for(entry[:required]).merge(untyped_pairs(unmodelled[:required]))
|
|
64
|
+
optional = pairs_for(entry[:optional]).merge(untyped_pairs(unmodelled[:optional]))
|
|
65
|
+
return nil if required.empty? && optional.empty?
|
|
66
|
+
|
|
67
|
+
Rigor::Type::Combinator.hash_shape_of(
|
|
68
|
+
required.merge(optional),
|
|
69
|
+
required_keys: required.keys,
|
|
70
|
+
optional_keys: optional.keys,
|
|
71
|
+
extra_keys: :open
|
|
72
|
+
)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A key the schema declares but the scanner could not type still belongs in the shape, as
|
|
76
|
+
# `untyped`.
|
|
77
|
+
#
|
|
78
|
+
# This costs nothing at a read and buys nothing at one either — measured: with and without these
|
|
79
|
+
# entries, `payload[:address]`, `payload.fetch(:address)` and every other read infer identically,
|
|
80
|
+
# because #249 made an undeclared key on an OPEN shape read as `untyped` rather than `nil`. What
|
|
81
|
+
# it buys is the rendered shape, which is what hover and `dump_type` show: `{ email: String,
|
|
82
|
+
# address: Dynamic[top], ... }` says the schema declares `address` and Rigor cannot type it, while
|
|
83
|
+
# `{ email: String, ... }` is indistinguishable from a schema that never mentioned it — the
|
|
84
|
+
# trailing `...` only ever means "keys beyond these are permitted".
|
|
85
|
+
#
|
|
86
|
+
# It was originally load-bearing for a different reason: before #249 a key outside the shape read
|
|
87
|
+
# as `nil`, so dropping `required(:address).schema { … }` put a `call.undefined-method` on the next
|
|
88
|
+
# line of correct code. That hazard is gone; the entries stay for the honesty of the rendering.
|
|
89
|
+
def untyped_pairs(keys)
|
|
90
|
+
(keys || []).to_h { |key| [key, Rigor::Type::Combinator.untyped] }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def pairs_for(rows)
|
|
94
|
+
(rows || {}).each_with_object({}) do |(key, row), pairs|
|
|
95
|
+
type = row_type(row)
|
|
96
|
+
pairs[key] = type unless type.nil?
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def row_type(row)
|
|
101
|
+
element = element_type(row[:type])
|
|
102
|
+
return nil if element.nil?
|
|
103
|
+
|
|
104
|
+
row[:list] ? Rigor::Type::Combinator.nominal_of("Array", type_args: [element]) : element
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# `type` is either a class-name String (the scalar case) or a `{nested: <shape>}` Hash — an
|
|
108
|
+
# `each do ... end` element-type recursion (issue #137's ceiling slice): {SchemaScanner} already
|
|
109
|
+
# collected the nested block with the same `collect_schema_shape` algorithm a top-level schema
|
|
110
|
+
# body uses, so building its HashShape is just a recursive {.build} call.
|
|
111
|
+
def element_type(type)
|
|
112
|
+
return build(type[:nested]) if type.is_a?(Hash)
|
|
113
|
+
|
|
114
|
+
class_type(type)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def class_type(class_name)
|
|
118
|
+
return nil if class_name.nil? || class_name.empty?
|
|
119
|
+
return Rigor::Type::Combinator.union(*BOOL_CLASSES.map { |c| Rigor::Type::Combinator.nominal_of(c) }) \
|
|
120
|
+
if class_name == "TrueClass"
|
|
121
|
+
|
|
122
|
+
Rigor::Type::Combinator.nominal_of(class_name)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Renders `Foo` / `Foo::Bar` / `::Foo::Bar` as the `::`-joined String the table is keyed by.
|
|
126
|
+
# Mirrors the helper rigor-sorbet's TypeTranslator and rigor-activerecord's ModelDiscoverer use.
|
|
127
|
+
def constant_name(node)
|
|
128
|
+
case node
|
|
129
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
130
|
+
when Prism::ConstantPathNode then constant_path_name(node)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def constant_path_name(node)
|
|
135
|
+
parent = node.parent
|
|
136
|
+
name = node.name&.to_s
|
|
137
|
+
return nil if name.nil?
|
|
138
|
+
return name if parent.nil? # `::Foo` — the table keys are unrooted, so drop the leading `::`
|
|
139
|
+
|
|
140
|
+
prefix = constant_name(parent)
|
|
141
|
+
prefix.nil? ? nil : "#{prefix}::#{name}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -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
|