sorbet_typed-props 1.5.2 → 1.5.5
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/.cz.yaml +1 -1
- data/CHANGELOG.md +20 -0
- data/CLAUDE.md +25 -0
- data/README.md +1 -4
- data/docs/phlex.md +31 -29
- data/lib/sorbet_typed/props/version.rb +1 -1
- data/lib/sorbet_typed/props/without_init_param_and_no_default_plugin.rb +13 -6
- data/lib/sorbet_typed/props.rb +237 -0
- data/lib/tapioca/dsl/compilers/sorbet_typed_props_constructor.rb +21 -1
- data/tested_gem_versions.yml +6 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e7206c19cf06589c26a6d00a673273645270a37d4957304902ee0a3eb5e5cfe9
|
|
4
|
+
data.tar.gz: 44091faa0e3dbf390cb383dbed54044230cb1416eaee7a4501003d1209622183
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d196157b6961f084f2ef8b60e77348d17b2a7d72f6a75a990bdaff20747ff80807a3ca5d091dab0a1f7e072dc36a9f44fdd510e9e88205d968cee1d9359907b6
|
|
7
|
+
data.tar.gz: ce46d5dc413eb1da669a9c98a24051c56c2cbf741952a4445e55ff237a70900ea0372f7456e59caab3f4c10746babdf57e1c0b93ff5c504ffa10b76c3fa10192
|
data/.cz.yaml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
## v1.5.5 (2026-07-17)
|
|
2
|
+
|
|
3
|
+
### Fix
|
|
4
|
+
|
|
5
|
+
- **phlex**: validate Phlex ::new calls statically like #initialize
|
|
6
|
+
|
|
7
|
+
## v1.5.4 (2026-07-05)
|
|
8
|
+
|
|
9
|
+
### Fix
|
|
10
|
+
|
|
11
|
+
- **ci**: allow Behaviour in cspell dictionary globally
|
|
12
|
+
|
|
13
|
+
## v1.5.3 (2026-07-04)
|
|
14
|
+
|
|
15
|
+
### Fix
|
|
16
|
+
|
|
17
|
+
- **ci**: silence spellchecker false-positive on minimumReleaseAgeBehaviour
|
|
18
|
+
- **ci**: skip patch versions still within minimumReleaseAge in matrix generator
|
|
19
|
+
- **ci**: install mise tools strictly from lockfile to avoid GitHub API rate limiting
|
|
20
|
+
|
|
1
21
|
## v1.5.2 (2026-07-03)
|
|
2
22
|
|
|
3
23
|
### Fix
|
data/CLAUDE.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Claude Instructions
|
|
2
|
+
|
|
3
|
+
## Conventional Commits
|
|
4
|
+
|
|
5
|
+
This project uses [Conventional Commits](https://www.conventionalcommits.org/) with `commitizen`
|
|
6
|
+
(`cz_conventional_commits`). Semver bumps are driven by commit type:
|
|
7
|
+
|
|
8
|
+
- `fix:` → patch bump
|
|
9
|
+
- `feat:` → minor bump
|
|
10
|
+
- `feat!:` / `BREAKING CHANGE:` → major bump
|
|
11
|
+
- All other types (`chore`, `ci`, `refactor`, `test`, `docs`, `style`, `perf`) → **no version bump**
|
|
12
|
+
|
|
13
|
+
**Rule:** Only use `fix` or `feat` (or their breaking variants) when the change affects the **published gem** — its
|
|
14
|
+
public API, runtime behavior, or bug fixes visible to gem consumers.
|
|
15
|
+
|
|
16
|
+
Changes that must **not** use `fix` or `feat`:
|
|
17
|
+
|
|
18
|
+
- CI pipeline configuration (`.gitlab-ci.yml`, `ci/`)
|
|
19
|
+
- Ruby version matrix or Renovate configuration
|
|
20
|
+
- Test suite changes that don't fix a gem bug
|
|
21
|
+
- Dev tooling, scripts, linting rules
|
|
22
|
+
- Dependency updates (Renovate handles these as `chore`)
|
|
23
|
+
- Documentation that describes internal/dev processes
|
|
24
|
+
|
|
25
|
+
When in doubt: if removing the change would not affect consumers of the installed gem, it is not a `fix` or `feat`.
|
data/README.md
CHANGED
|
@@ -37,6 +37,7 @@ This gem is tested against all combinations of the following dependency versions
|
|
|
37
37
|
| Ruby | 3.3, 3.4, 4.0 |
|
|
38
38
|
| sorbet-runtime | 0.6.12997, 0.6.13321 |
|
|
39
39
|
| tapioca | 0.17, 0.18, 0.19 |
|
|
40
|
+
| phlex | 1.11, 2.4 |
|
|
40
41
|
| sorbet | 0.6.12997, 0.6.13321 |
|
|
41
42
|
|
|
42
43
|
### Excluded combinations
|
|
@@ -172,10 +173,6 @@ class Components::HelloWorld < Phlex::HTML
|
|
|
172
173
|
end
|
|
173
174
|
```
|
|
174
175
|
|
|
175
|
-
> **Known limitation:** Phlex overrides `::new` rather than `#initialize` in `Phlex::SGML`, so Sorbet cannot statically
|
|
176
|
-
> validate props on Phlex components. Calling `HelloWorld.new` without required props will not raise a Sorbet error. See
|
|
177
|
-
> issue [#23](https://gitlab.com/sorbet_typed/props/-/work_items/23).
|
|
178
|
-
|
|
179
176
|
→ [Full documentation](docs/phlex.md)
|
|
180
177
|
|
|
181
178
|
### Extending initializer logic
|
data/docs/phlex.md
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
# Phlex integration
|
|
2
2
|
|
|
3
|
-
> [!warning]
|
|
4
|
-
>
|
|
5
|
-
> [Known static typing limitation](#known-static-typing-limitation)
|
|
6
|
-
|
|
7
3
|
## Table of Contents
|
|
8
4
|
|
|
9
5
|
- [Table of Contents](#table-of-contents)
|
|
@@ -11,8 +7,10 @@
|
|
|
11
7
|
- [When to use](#when-to-use)
|
|
12
8
|
- [Examples](#examples)
|
|
13
9
|
- [Phlex component with props](#phlex-component-with-props)
|
|
14
|
-
- [Known static typing limitation](#known-static-typing-limitation)
|
|
15
10
|
- [Behavior notes](#behavior-notes)
|
|
11
|
+
- [Automatic `::new` typing](#automatic-new-typing)
|
|
12
|
+
- [Phlex version compatibility](#phlex-version-compatibility)
|
|
13
|
+
- [Element-name collisions](#element-name-collisions)
|
|
16
14
|
|
|
17
15
|
## What it does
|
|
18
16
|
|
|
@@ -63,36 +61,40 @@ class Components::UserCard < Phlex::HTML
|
|
|
63
61
|
end
|
|
64
62
|
```
|
|
65
63
|
|
|
66
|
-
|
|
64
|
+
## Behavior notes
|
|
67
65
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
> Phlex components. Calling `HelloWorld.new` without required props will not raise a Sorbet error. See issue
|
|
72
|
-
> [#23](https://gitlab.com/sorbet_typed/props/-/work_items/23).
|
|
66
|
+
- Props are validated at runtime.
|
|
67
|
+
- The generated `initialize` RBI is correct — Sorbet validates calls to `#initialize` where it can reach them.
|
|
68
|
+
- Phlex `::new` calls are statically typed (see "Automatic `::new` typing" below).
|
|
73
69
|
|
|
74
|
-
|
|
75
|
-
# Sorbet does NOT catch this at static analysis time — only raises at runtime:
|
|
76
|
-
Components::UserCard.new
|
|
77
|
-
# => ArgumentError: Missing required prop `name` for class `Components::UserCard`
|
|
78
|
-
```
|
|
70
|
+
## Automatic `::new` typing
|
|
79
71
|
|
|
80
|
-
|
|
72
|
+
Phlex overrides `::new` (not `#initialize`) in `Phlex::SGML`. The Tapioca compiler shipped with this gem detects that
|
|
73
|
+
override and generates a matching `self.new` signature directly on your component class, so Sorbet validates `.new`
|
|
74
|
+
calls exactly like it validates `#initialize` calls on non-Phlex classes — no manual shim needed.
|
|
81
75
|
|
|
82
76
|
```ruby
|
|
83
|
-
#
|
|
84
|
-
|
|
85
|
-
sig { params(name: String, email: String).returns(T.attached_class) }
|
|
86
|
-
def self.new(name:, email:); end
|
|
87
|
-
end
|
|
77
|
+
# Sorbet catches this at static analysis time:
|
|
78
|
+
Components::UserCard.new # error: Missing required keyword argument `name`
|
|
88
79
|
```
|
|
89
80
|
|
|
90
|
-
|
|
91
|
-
upstream discussion.
|
|
81
|
+
## Phlex version compatibility
|
|
92
82
|
|
|
93
|
-
|
|
83
|
+
`SorbetTyped::Props` requires Phlex `>= 1.0.0` when included in a Phlex component. Versions before 1.0 predate the
|
|
84
|
+
`Phlex::SGML`-based architecture this integration targets entirely. Including `SorbetTyped::Props` in an older Phlex
|
|
85
|
+
component raises `ArgumentError` immediately.
|
|
94
86
|
|
|
95
|
-
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
87
|
+
## Element-name collisions
|
|
88
|
+
|
|
89
|
+
Phlex registers an instance method for every HTML tag (`title`, `label`, `header`, `time`, `data`, and dozens more). A
|
|
90
|
+
prop with the same name as one of these silently shadowed the tag-rendering method before — `title { ... }` in
|
|
91
|
+
`view_template` would stop working with no error. This is now caught: defining a prop whose name collides with a
|
|
92
|
+
Phlex-registered element method raises `ArgumentError` immediately.
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
class Components::Article < Phlex::HTML
|
|
96
|
+
include SorbetTyped::Props
|
|
97
|
+
|
|
98
|
+
prop :title, String # ArgumentError: collides with a Phlex element method of the same name
|
|
99
|
+
end
|
|
100
|
+
```
|
|
@@ -31,10 +31,13 @@ module SorbetTyped
|
|
|
31
31
|
private
|
|
32
32
|
|
|
33
33
|
sig { params(value: T.untyped, rules: T::Props::Decorator::Rules).returns(T::Boolean) }
|
|
34
|
+
# :reek:FeatureEnvy -- a pure predicate over its arguments, no `self`
|
|
35
|
+
# state to operate on instead; that's the whole point of the method.
|
|
34
36
|
def prop_read_before_set?(value, rules)
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
return false unless rules.dig(:extra, :initializer) == false
|
|
38
|
+
|
|
39
|
+
type_object = rules[:type_object]
|
|
40
|
+
!!(!type_object.valid?(value) && !rspec_double_valid_for_type?(value, type_object))
|
|
38
41
|
end
|
|
39
42
|
|
|
40
43
|
# NOTE: allows RSpec verifying doubles (e.g. instance_double) to be used
|
|
@@ -42,6 +45,8 @@ module SorbetTyped
|
|
|
42
45
|
# triggering the "read before being set" error. This mirrors the approach
|
|
43
46
|
# taken by the rspec-sorbet gem for T.let/T.cast type checks.
|
|
44
47
|
sig { params(value: T.untyped, type_object: T::Types::Base).returns(T::Boolean) }
|
|
48
|
+
# :reek:FeatureEnvy -- a pure predicate over its arguments, no `self`
|
|
49
|
+
# state to operate on instead; that's the whole point of the method.
|
|
45
50
|
def rspec_double_valid_for_type?(value, type_object)
|
|
46
51
|
# :nocov: -- entry guard not testable without loading a non-RSpec
|
|
47
52
|
# environment
|
|
@@ -53,7 +58,7 @@ module SorbetTyped
|
|
|
53
58
|
# :nocov: -- nil cases not really testable without constructing
|
|
54
59
|
# pathological doubles from scratch
|
|
55
60
|
target = value.instance_variable_get(:@doubled_module)&.target
|
|
56
|
-
return false
|
|
61
|
+
return false unless target
|
|
57
62
|
# :nocov:
|
|
58
63
|
|
|
59
64
|
rspec_double_target_matches_type?(target, type_object)
|
|
@@ -61,11 +66,13 @@ module SorbetTyped
|
|
|
61
66
|
|
|
62
67
|
sig { params(target: T::Module[T.anything], type_object: T::Types::Base).returns(T::Boolean) }
|
|
63
68
|
def rspec_double_target_matches_type?(target, type_object)
|
|
69
|
+
target_ancestors = target.ancestors
|
|
70
|
+
|
|
64
71
|
case type_object
|
|
65
72
|
when T::Types::Simple
|
|
66
|
-
|
|
73
|
+
target_ancestors.include?(type_object.raw_type)
|
|
67
74
|
when T::Types::Union
|
|
68
|
-
type_object.types.any? { |
|
|
75
|
+
type_object.types.any? { |candidate_type| candidate_type.is_a?(T::Types::Simple) && target_ancestors.include?(candidate_type.raw_type) }
|
|
69
76
|
# :nocov: -- else false not reachable with standard Sorbet type objects (always Simple or Union)
|
|
70
77
|
else
|
|
71
78
|
false
|
data/lib/sorbet_typed/props.rb
CHANGED
|
@@ -23,6 +23,196 @@ module SorbetTyped
|
|
|
23
23
|
|
|
24
24
|
include Kernel
|
|
25
25
|
|
|
26
|
+
# Phlex predates its current Phlex::SGML-based architecture entirely
|
|
27
|
+
# before 1.0 (confirmed by inspecting phlex 0.5.3's source: no
|
|
28
|
+
# Phlex::SGML class, different Rails-view-coupled design) - this is the
|
|
29
|
+
# only place this version literal is written; tested_gem_versions.yml
|
|
30
|
+
# references it by name via `min_constant`, never duplicates it.
|
|
31
|
+
MINIMUM_SUPPORTED_PHLEX_VERSION = '1.0.0'
|
|
32
|
+
|
|
33
|
+
sig { params(base: T::Module[T.anything]).void }
|
|
34
|
+
def self.included(base)
|
|
35
|
+
super
|
|
36
|
+
check_phlex_compatibility!(base)
|
|
37
|
+
patch_phlex_method_added!(base)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
sig { params(base: T::Module[T.anything]).void }
|
|
41
|
+
# NOTE: reflection (`const_defined?`/`const_get`), not a bare `Phlex::VERSION`
|
|
42
|
+
# reference, on purpose - a literal `Phlex::VERSION`/`Phlex::SGML` constant
|
|
43
|
+
# reference is statically resolved by Sorbet regardless of `defined?`
|
|
44
|
+
# guarding, and fails `srb tc` with "Unable to resolve constant" since this
|
|
45
|
+
# gem never depends on Phlex (no RBI shim for it exists in this project).
|
|
46
|
+
# This must stay resolvable whether or not Phlex is ever bundled.
|
|
47
|
+
#
|
|
48
|
+
# :reek:TooManyStatements -- a sequence of independent early-return guard
|
|
49
|
+
# clauses; splitting further would hurt readability more than it helps.
|
|
50
|
+
def self.check_phlex_compatibility!(base)
|
|
51
|
+
return unless base.ancestors.any? { |mod| mod.name&.start_with?('Phlex::') }
|
|
52
|
+
# :nocov: -- if an ancestor's name starts with 'Phlex::', the top-level
|
|
53
|
+
# Phlex constant must already be defined; this guard only matters if
|
|
54
|
+
# that invariant is ever violated (e.g. a hand-rolled ancestor faking
|
|
55
|
+
# the namespace), which isn't reproducible without doing exactly that
|
|
56
|
+
return unless Object.const_defined?(:Phlex)
|
|
57
|
+
# :nocov:
|
|
58
|
+
|
|
59
|
+
phlex = T.unsafe(Object).const_get(:Phlex) # rubocop:disable Sorbet/ConstantsFromStrings -- Phlex is a soft, optional dependency, never statically resolvable
|
|
60
|
+
# :nocov: -- every supported Phlex release defines Phlex::VERSION; this
|
|
61
|
+
# guards only a hypothetical broken/partial install
|
|
62
|
+
return unless phlex.const_defined?(:VERSION)
|
|
63
|
+
# :nocov:
|
|
64
|
+
|
|
65
|
+
phlex_version = phlex.const_get(:VERSION) # rubocop:disable Sorbet/ConstantsFromStrings -- see above
|
|
66
|
+
return if Gem::Version.new(phlex_version) >= Gem::Version.new(MINIMUM_SUPPORTED_PHLEX_VERSION)
|
|
67
|
+
|
|
68
|
+
raise ArgumentError,
|
|
69
|
+
'SorbetTyped::Props does not support Phlex < ' \
|
|
70
|
+
"#{MINIMUM_SUPPORTED_PHLEX_VERSION} " \
|
|
71
|
+
"(found #{phlex_version}). See docs/phlex.md."
|
|
72
|
+
end
|
|
73
|
+
private_class_method :check_phlex_compatibility!
|
|
74
|
+
|
|
75
|
+
sig { params(base: T::Module[T.anything]).void }
|
|
76
|
+
# MONKEYPATCH (Phlex::SGML.method_added), applied at runtime below via
|
|
77
|
+
# `install_phlex_method_added_super!`, only when actually needed. Not
|
|
78
|
+
# filed upstream against Phlex - this is only a problem for the narrow
|
|
79
|
+
# combination of Phlex + sorbet-runtime `sig` + running under Tapioca's
|
|
80
|
+
# RBS-comment rewriter, so it's not established that Phlex's behavior is
|
|
81
|
+
# wrong in general.
|
|
82
|
+
#
|
|
83
|
+
# What we observed: some Phlex versions' `method_added` don't forward via
|
|
84
|
+
# `super` for the `view_template` method specifically (confirmed in
|
|
85
|
+
# 1.11.1; still true in 2.3.1's `view_template`-specific branch; fixed by
|
|
86
|
+
# 2.4.1, which forwards unconditionally). On its own this is harmless - a
|
|
87
|
+
# plain `extend T::Sig` on a Phlex subclass puts sorbet-runtime's
|
|
88
|
+
# method_added hook ahead of Phlex::SGML's in the singleton ancestor
|
|
89
|
+
# chain, so it fires first regardless. But Tapioca's RBS-comment rewriter
|
|
90
|
+
# (loaded by any `tapioca dsl` run, whether or not RBS comments are used)
|
|
91
|
+
# does a *global* `Module.include(T::Sig)` so bare RBS-comment files get
|
|
92
|
+
# `sig` without their own `extend T::Sig`. Since `include`/`extend` of
|
|
93
|
+
# the same module is a no-op the second time, that global include makes
|
|
94
|
+
# our per-class `extend T::Sig` a no-op for ancestor *positioning* too -
|
|
95
|
+
# the hook ends up reachable only via `Module`, which sits *below*
|
|
96
|
+
# Phlex::SGML in a Phlex subclass's chain. method_added dispatch then
|
|
97
|
+
# never reaches sorbet-runtime's hook, leaving its pending `sig`
|
|
98
|
+
# declaration dangling until some unrelated class's next method
|
|
99
|
+
# definition wrongly consumes it, raising a confusing error that blames
|
|
100
|
+
# that unrelated class.
|
|
101
|
+
#
|
|
102
|
+
# There's no clean Phlex version-number boundary for this (the fix landed
|
|
103
|
+
# somewhere between 2.3.1 and 2.4.1, and earlier point releases aren't
|
|
104
|
+
# all verified), so this is gated on detected behavior
|
|
105
|
+
# (`phlex_method_added_forwards_view_template?`), not `Phlex::VERSION` -
|
|
106
|
+
# patch only the versions that actually need it.
|
|
107
|
+
# :reek:TooManyStatements -- a sequence of independent early-return guard
|
|
108
|
+
# clauses, same shape as check_phlex_compatibility! above; splitting
|
|
109
|
+
# further would hurt readability more than it helps.
|
|
110
|
+
# :reek:NilCheck -- phlex_sgml_constant legitimately returns nil when
|
|
111
|
+
# Phlex/Phlex::SGML aren't defined; this is the guard for that.
|
|
112
|
+
# :reek:ManualDispatch -- respond_to? here is the actual idempotency
|
|
113
|
+
# marker check (has this specific `sgml` already been patched?), not
|
|
114
|
+
# duck-typing standing in for a missing interface.
|
|
115
|
+
def self.patch_phlex_method_added!(base)
|
|
116
|
+
return unless base.ancestors.any? { |mod| mod.name&.start_with?('Phlex::') }
|
|
117
|
+
|
|
118
|
+
sgml = phlex_sgml_constant
|
|
119
|
+
return if sgml.nil? || sgml.respond_to?(:__sorbet_typed_props_method_added_checked__)
|
|
120
|
+
|
|
121
|
+
sgml.define_singleton_method(:__sorbet_typed_props_method_added_checked__) { true }
|
|
122
|
+
install_phlex_method_added_super!(sgml) unless phlex_method_added_forwards_view_template?(sgml)
|
|
123
|
+
end
|
|
124
|
+
private_class_method :patch_phlex_method_added!
|
|
125
|
+
|
|
126
|
+
sig { returns(T.untyped) }
|
|
127
|
+
def self.phlex_sgml_constant
|
|
128
|
+
# :nocov: -- callers only reach this once `check_phlex_compatibility!`
|
|
129
|
+
# has already confirmed Phlex and Phlex::VERSION are defined; these
|
|
130
|
+
# guards duplicate that invariant defensively and aren't independently
|
|
131
|
+
# reachable
|
|
132
|
+
return unless Object.const_defined?(:Phlex)
|
|
133
|
+
|
|
134
|
+
phlex = T.unsafe(Object).const_get(:Phlex) # rubocop:disable Sorbet/ConstantsFromStrings -- see check_phlex_compatibility!
|
|
135
|
+
return unless phlex.const_defined?(:SGML)
|
|
136
|
+
# :nocov:
|
|
137
|
+
|
|
138
|
+
phlex.const_get(:SGML) # rubocop:disable Sorbet/ConstantsFromStrings -- see check_phlex_compatibility!
|
|
139
|
+
end
|
|
140
|
+
private_class_method :phlex_sgml_constant
|
|
141
|
+
|
|
142
|
+
sig { params(sgml: T.untyped).returns(T::Boolean) }
|
|
143
|
+
# Detects whether `sgml`'s own `method_added` forwards via `super` for
|
|
144
|
+
# `view_template` specifically (the one method name Phlex special-cases -
|
|
145
|
+
# see the NOTE above `patch_phlex_method_added!` on why testing a generic
|
|
146
|
+
# method name would wrongly pass against Phlex 2.3.1).
|
|
147
|
+
#
|
|
148
|
+
# Deliberately does NOT test this via a throwaway subclass that does its
|
|
149
|
+
# own `extend T::Sig` - a fresh class's `extend T::Sig` would normally
|
|
150
|
+
# win the ancestor-chain race regardless of Phlex's own behavior (that's
|
|
151
|
+
# the "harmless on its own" case described above), so a probe built that
|
|
152
|
+
# way would never actually exercise the failure condition and would
|
|
153
|
+
# always report `true`. Instead, a canary module is included directly
|
|
154
|
+
# into `sgml.singleton_class` - `include` inserts right after the
|
|
155
|
+
# includer, exactly where a `super` call from within `sgml`'s own
|
|
156
|
+
# `method_added` would look - and the hook is invoked directly on `sgml`
|
|
157
|
+
# itself. This tests Phlex's forwarding behavior in isolation, decoupled
|
|
158
|
+
# from any ancestor-chain positioning question.
|
|
159
|
+
#
|
|
160
|
+
# NOTE: the canary itself forwards via `super` (`if defined?(super)`,
|
|
161
|
+
# since it may sit directly below `Module`/`Kernel`, which don't
|
|
162
|
+
# implement `method_added` themselves). Ruby has no way to remove an
|
|
163
|
+
# included module afterward, so this canary is permanent once installed
|
|
164
|
+
# - it must stay transparent. That's safe either way: if `sgml` doesn't
|
|
165
|
+
# forward on its own, this canary is simply never reached (`sgml`'s own
|
|
166
|
+
# body returns before calling `super`), so it's inert dead code sitting
|
|
167
|
+
# below whatever `install_phlex_method_added_super!` separately patches
|
|
168
|
+
# onto `sgml` itself; if `sgml` does forward, the canary passes the call
|
|
169
|
+
# straight through, same as if it weren't there.
|
|
170
|
+
#
|
|
171
|
+
# :reek:TooManyStatements -- building the canary, installing it, and
|
|
172
|
+
# invoking the probed hook are each one line; splitting this into
|
|
173
|
+
# smaller methods would just spread a single cohesive check across
|
|
174
|
+
# several call sites for no readability gain.
|
|
175
|
+
def self.phlex_method_added_forwards_view_template?(sgml)
|
|
176
|
+
forwarded = T.let(false, T::Boolean)
|
|
177
|
+
canary = Module.new
|
|
178
|
+
canary.define_method(:method_added) do |name|
|
|
179
|
+
forwarded = true if name == :view_template
|
|
180
|
+
# :nocov: -- `defined?(super)` is always true here: Module itself
|
|
181
|
+
# provides a default `method_added`, so the canary (included into
|
|
182
|
+
# sgml's singleton class) always has one further up the chain to
|
|
183
|
+
# forward to
|
|
184
|
+
super(name) if defined?(super)
|
|
185
|
+
# :nocov:
|
|
186
|
+
end
|
|
187
|
+
sgml.singleton_class.include(canary)
|
|
188
|
+
|
|
189
|
+
sgml.send(:method_added, :view_template)
|
|
190
|
+
forwarded
|
|
191
|
+
end
|
|
192
|
+
private_class_method :phlex_method_added_forwards_view_template?
|
|
193
|
+
|
|
194
|
+
sig { params(sgml: T.untyped).void }
|
|
195
|
+
# NOTE: `alias_method`, not a captured `sgml.method(:method_added)` - a
|
|
196
|
+
# `Method` object is permanently bound to the receiver it was captured
|
|
197
|
+
# from (`sgml` itself). Calling it later from inside our wrapper (which
|
|
198
|
+
# runs with `self` set to whatever class is actually being defined, e.g.
|
|
199
|
+
# a Phlex::HTML subclass) would still execute with `self` == `sgml`,
|
|
200
|
+
# breaking Phlex 2.x's own method_added body (which already calls
|
|
201
|
+
# `super` and inspects `self`) - confirmed by this exact regression
|
|
202
|
+
# against Phlex 2.4.1 while adding the second Phlex fixture spec. An
|
|
203
|
+
# aliased method, by contrast, dispatches with the correct dynamic
|
|
204
|
+
# `self` when invoked via `send`, same as a normal method call.
|
|
205
|
+
def self.install_phlex_method_added_super!(sgml)
|
|
206
|
+
original_method_added_name = :__sorbet_typed_props_original_method_added__
|
|
207
|
+
sgml.singleton_class.send(:alias_method, original_method_added_name, :method_added)
|
|
208
|
+
|
|
209
|
+
sgml.define_singleton_method(:method_added) do |name|
|
|
210
|
+
send(original_method_added_name, name)
|
|
211
|
+
super(name)
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
private_class_method :install_phlex_method_added_super!
|
|
215
|
+
|
|
26
216
|
plugin(WithoutInitParamAndNoDefaultPlugin)
|
|
27
217
|
|
|
28
218
|
# Methods injected into the including class singleton
|
|
@@ -36,6 +226,53 @@ module SorbetTyped
|
|
|
36
226
|
prop_parameters.dig(:extra, :initializer) == false
|
|
37
227
|
end
|
|
38
228
|
end
|
|
229
|
+
|
|
230
|
+
sig { params(name: Symbol, cls: T.untyped, rules: T.untyped).void }
|
|
231
|
+
def prop(name, cls, **rules)
|
|
232
|
+
check_element_method_collision!(name)
|
|
233
|
+
super
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
private
|
|
237
|
+
|
|
238
|
+
# Phlex::HTML::StandardElements registers an instance method per HTML
|
|
239
|
+
# tag (title, label, header, ...). A same-named prop silently shadows
|
|
240
|
+
# it - the prop reader wins method resolution, no error today. Duck
|
|
241
|
+
# -typed against Phlex's own element registry accessor rather than a
|
|
242
|
+
# blanket "any ancestor method collision" check, which would
|
|
243
|
+
# false-positive constantly on ordinary Ruby method names (type, name,
|
|
244
|
+
# format, hash, ...) for any non-Phlex consumer. The accessor's name
|
|
245
|
+
# changed across majors - registered_elements (1.x, on
|
|
246
|
+
# Phlex::Elements) vs __registered_elements__ (2.x, on
|
|
247
|
+
# Phlex::SGML::Elements) - check both defensively. If a future Phlex
|
|
248
|
+
# major renames it again, this degrades silently (stops detecting new
|
|
249
|
+
# collisions) rather than raising false positives.
|
|
250
|
+
PHLEX_REGISTERED_ELEMENTS_READERS = T.let(
|
|
251
|
+
%i[__registered_elements__ registered_elements].freeze,
|
|
252
|
+
T::Array[Symbol]
|
|
253
|
+
)
|
|
254
|
+
private_constant :PHLEX_REGISTERED_ELEMENTS_READERS
|
|
255
|
+
|
|
256
|
+
sig { params(name: Symbol).void }
|
|
257
|
+
def check_element_method_collision!(name)
|
|
258
|
+
return unless T.unsafe(self).method_defined?(name)
|
|
259
|
+
|
|
260
|
+
owner = T.unsafe(self).instance_method(name).owner
|
|
261
|
+
return unless phlex_element_owner?(owner, name)
|
|
262
|
+
|
|
263
|
+
Kernel.raise ArgumentError,
|
|
264
|
+
"#{self}: prop `#{name}` collides with a Phlex element method of the same " \
|
|
265
|
+
"name defined by `#{owner}` - rendering `#{name} { ... }` in " \
|
|
266
|
+
"'view_template' would no longer work."
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
sig { params(owner: T.untyped, name: Symbol).returns(T::Boolean) }
|
|
270
|
+
# :reek:ManualDispatch -- intentional duck-typing against Phlex modules via reflect
|
|
271
|
+
def phlex_element_owner?(owner, name)
|
|
272
|
+
PHLEX_REGISTERED_ELEMENTS_READERS.any? do |reader|
|
|
273
|
+
T.unsafe(owner).respond_to?(reader) && T.unsafe(owner).public_send(reader).key?(name)
|
|
274
|
+
end
|
|
275
|
+
end
|
|
39
276
|
end
|
|
40
277
|
|
|
41
278
|
mixes_in_class_methods(ClassMethods)
|
|
@@ -80,7 +80,7 @@ module Tapioca
|
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
sig { void }
|
|
83
|
-
def define_initializer_sig
|
|
83
|
+
def define_initializer_sig # rubocop:disable Metrics/MethodLength
|
|
84
84
|
superclass = constant.superclass
|
|
85
85
|
superclass_name = superclass == Object ? nil : superclass.to_s
|
|
86
86
|
|
|
@@ -91,9 +91,29 @@ module Tapioca
|
|
|
91
91
|
return_type: 'void',
|
|
92
92
|
comments: [RBI::Comment.new('Generated from sorbet SorbetTyped::Props')]
|
|
93
93
|
)
|
|
94
|
+
|
|
95
|
+
next unless self_new_overridden?
|
|
96
|
+
|
|
97
|
+
klass.create_method(
|
|
98
|
+
'new',
|
|
99
|
+
parameters: params_with_optional_last,
|
|
100
|
+
return_type: 'T.attached_class',
|
|
101
|
+
class_method: true,
|
|
102
|
+
comments: [RBI::Comment.new('Generated from sorbet SorbetTyped::Props')]
|
|
103
|
+
)
|
|
94
104
|
end
|
|
95
105
|
end
|
|
96
106
|
|
|
107
|
+
sig { returns(T::Boolean) }
|
|
108
|
+
# NOTE: Phlex::SGML overrides `self.new` (untyped), so Sorbet resolves
|
|
109
|
+
# `.new` calls against that instead of synthesizing a signature from
|
|
110
|
+
# `#initialize`. Defining our own `self.new` directly on the leaf class
|
|
111
|
+
# wins ancestor-chain resolution over any such override. Gated so plain
|
|
112
|
+
# classes (the common case) get no extra RBI output.
|
|
113
|
+
def self_new_overridden?
|
|
114
|
+
constant.method(:new).owner != Class
|
|
115
|
+
end
|
|
116
|
+
|
|
97
117
|
sig { returns(T::Array[RBI::TypedParam]) }
|
|
98
118
|
def params_with_optional_last
|
|
99
119
|
required_params, optional_params = params.partition do |param|
|
data/tested_gem_versions.yml
CHANGED
|
@@ -24,6 +24,12 @@ sorbet-runtime:
|
|
|
24
24
|
patch_sample_step: 500
|
|
25
25
|
tapioca: '0.19.2'
|
|
26
26
|
|
|
27
|
+
test_dependencies:
|
|
28
|
+
phlex:
|
|
29
|
+
max: '2.4.1'
|
|
30
|
+
min_constant: 'SorbetTyped::Props::MINIMUM_SUPPORTED_PHLEX_VERSION'
|
|
31
|
+
granularity: major
|
|
32
|
+
|
|
27
33
|
incompatible_combinations:
|
|
28
34
|
- tapioca: '< 0.19.0'
|
|
29
35
|
sorbet-runtime: '>= 0.6.13149'
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sorbet_typed-props
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.5.
|
|
4
|
+
version: 1.5.5
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Richard Kramer
|
|
@@ -54,6 +54,7 @@ files:
|
|
|
54
54
|
- ".shellcheckrc"
|
|
55
55
|
- ".specstory/.gitignore"
|
|
56
56
|
- CHANGELOG.md
|
|
57
|
+
- CLAUDE.md
|
|
57
58
|
- COPYING
|
|
58
59
|
- README.md
|
|
59
60
|
- docs/defaults.md
|