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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38708cf1f39a8cef314d4843c4fed453f4faf8f2fc35db4e47e1c659dcd581d7
4
- data.tar.gz: 94b1bf4f088610d3e3e3bb1eb1ea7f09da8428f9849176d214f4f0a9d2760ff7
3
+ metadata.gz: e7206c19cf06589c26a6d00a673273645270a37d4957304902ee0a3eb5e5cfe9
4
+ data.tar.gz: 44091faa0e3dbf390cb383dbed54044230cb1416eaee7a4501003d1209622183
5
5
  SHA512:
6
- metadata.gz: d37eeeaea430f1950b0a9bc80e50e5c98945458082ee037989c213151a593e67ecf9e52816b05c371636b8110b01d161ba53418f601ec514d654c5ea4abfa374
7
- data.tar.gz: e54af5f419e3cea6b5aaacc4379db254d17c3cc6da84be35c8ab715149194466a1c5cec687896dc2cf93027541f1a5af88246ac4fd83d99fa92d3d54801e973e
6
+ metadata.gz: d196157b6961f084f2ef8b60e77348d17b2a7d72f6a75a990bdaff20747ff80807a3ca5d091dab0a1f7e072dc36a9f44fdd510e9e88205d968cee1d9359907b6
7
+ data.tar.gz: ce46d5dc413eb1da669a9c98a24051c56c2cbf741952a4445e55ff237a70900ea0372f7456e59caab3f4c10746babdf57e1c0b93ff5c504ffa10b76c3fa10192
data/.cz.yaml CHANGED
@@ -15,7 +15,7 @@ commitizen:
15
15
  - mise run format
16
16
  tag_format: v$version
17
17
  update_changelog_on_bump: true
18
- version: 1.5.2
18
+ version: 1.5.5
19
19
  version_files:
20
20
  - lib/sorbet_typed/props/version.rb
21
21
  version_scheme: semver2
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
- ### Known static typing limitation
64
+ ## Behavior notes
67
65
 
68
- > [!warning] Known limitation
69
- >
70
- > Phlex overrides `::new` rather than `#initialize` in `Phlex::SGML`, so Sorbet cannot statically validate props on
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
- ```ruby
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
- A possible workaround is to add a manual RBI shim for `::new` on the component:
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
- # sorbet/rbi/shims/components/user_card.rbi
84
- class Components::UserCard
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
- This requires a shim per component. See issue [#23](https://gitlab.com/sorbet_typed/props/-/work_items/23) for the
91
- upstream discussion.
81
+ ## Phlex version compatibility
92
82
 
93
- ## Behavior notes
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
- - Props are validated at runtime even though Sorbet cannot check `::new` call sites statically on Phlex components.
96
- - The generated `initialize` RBI is correct — Sorbet validates calls to `#initialize` where it can reach them.
97
- - The limitation applies only to `Phlex::SGML` subclasses (i.e. `Phlex::HTML` and `Phlex::SVG`). Non-Phlex classes work
98
- fully with static analysis.
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
+ ```
@@ -5,7 +5,7 @@
5
5
 
6
6
  module SorbetTyped
7
7
  module Props
8
- VERSION = '1.5.2'
8
+ VERSION = '1.5.5'
9
9
  end
10
10
  end
11
11
 
@@ -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
- !!(rules.dig(:extra, :initializer) == false &&
36
- !rules[:type_object].valid?(value) &&
37
- !rspec_double_valid_for_type?(value, rules[:type_object]))
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 if target.nil?
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
- target.ancestors.include?(type_object.raw_type)
73
+ target_ancestors.include?(type_object.raw_type)
67
74
  when T::Types::Union
68
- type_object.types.any? { |t| t.is_a?(T::Types::Simple) && target.ancestors.include?(t.raw_type) }
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
@@ -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|
@@ -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.2
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