poetry-core 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +3 -0
  3. data/DESIGN.md +145 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +30 -0
  6. data/THIRD_PARTY_NOTICES.md +507 -0
  7. data/app/components/poetry/core/box/component.rb +82 -0
  8. data/app/components/poetry/core/component.rb +390 -0
  9. data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
  10. data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
  11. data/app/components/poetry/core/concerns/introspection.rb +533 -0
  12. data/app/components/poetry/core/concerns/options.rb +262 -0
  13. data/app/components/poetry/core/concerns/parts.rb +148 -0
  14. data/app/components/poetry/core/concerns/stimulus.rb +363 -0
  15. data/app/components/poetry/core/concerns/styles.rb +323 -0
  16. data/app/components/poetry/core/style.rb +150 -0
  17. data/app/components/poetry/core/wrapper/component.rb +90 -0
  18. data/app/javascript/poetry/core/accordion_controller.js +165 -0
  19. data/app/javascript/poetry/core/action_bar_controller.js +109 -0
  20. data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
  21. data/app/javascript/poetry/core/calendar_controller.js +406 -0
  22. data/app/javascript/poetry/core/carousel_controller.js +167 -0
  23. data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
  24. data/app/javascript/poetry/core/checked_controller.js +208 -0
  25. data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
  26. data/app/javascript/poetry/core/combobox_controller.js +1095 -0
  27. data/app/javascript/poetry/core/command_controller.js +510 -0
  28. data/app/javascript/poetry/core/context_menu_controller.js +204 -0
  29. data/app/javascript/poetry/core/date_field_controller.js +584 -0
  30. data/app/javascript/poetry/core/date_picker_controller.js +111 -0
  31. data/app/javascript/poetry/core/deferred_controller.js +111 -0
  32. data/app/javascript/poetry/core/dialog_controller.js +206 -0
  33. data/app/javascript/poetry/core/dismissable_controller.js +182 -0
  34. data/app/javascript/poetry/core/drawer_controller.js +365 -0
  35. data/app/javascript/poetry/core/file_input_controller.js +191 -0
  36. data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
  37. data/app/javascript/poetry/core/helpers/announce.js +228 -0
  38. data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
  39. data/app/javascript/poetry/core/helpers/collection.js +18 -0
  40. data/app/javascript/poetry/core/helpers/direction.js +15 -0
  41. data/app/javascript/poetry/core/helpers/escape.js +38 -0
  42. data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
  43. data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
  44. data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
  45. data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
  46. data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
  47. data/app/javascript/poetry/core/helpers/mask.js +283 -0
  48. data/app/javascript/poetry/core/helpers/portal.js +217 -0
  49. data/app/javascript/poetry/core/helpers/presence.js +210 -0
  50. data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
  51. data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
  52. data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
  53. data/app/javascript/poetry/core/helpers/state.js +80 -0
  54. data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
  55. data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
  56. data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
  57. data/app/javascript/poetry/core/hotkey_controller.js +55 -0
  58. data/app/javascript/poetry/core/hover_card_controller.js +431 -0
  59. data/app/javascript/poetry/core/index.js +218 -0
  60. data/app/javascript/poetry/core/mask_controller.js +500 -0
  61. data/app/javascript/poetry/core/menu_controller.js +1050 -0
  62. data/app/javascript/poetry/core/menubar_controller.js +329 -0
  63. data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
  64. data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
  65. data/app/javascript/poetry/core/number_field_controller.js +404 -0
  66. data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
  67. data/app/javascript/poetry/core/otp_controller.js +219 -0
  68. data/app/javascript/poetry/core/popover_controller.js +300 -0
  69. data/app/javascript/poetry/core/popper_controller.js +439 -0
  70. data/app/javascript/poetry/core/pressed_controller.js +74 -0
  71. data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
  72. data/app/javascript/poetry/core/radio_group_controller.js +197 -0
  73. data/app/javascript/poetry/core/resizable_controller.js +202 -0
  74. data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
  75. data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
  76. data/app/javascript/poetry/core/search_field_controller.js +95 -0
  77. data/app/javascript/poetry/core/select_controller.js +924 -0
  78. data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
  79. data/app/javascript/poetry/core/sheet_controller.js +24 -0
  80. data/app/javascript/poetry/core/sidebar_controller.js +272 -0
  81. data/app/javascript/poetry/core/slider_controller.js +419 -0
  82. data/app/javascript/poetry/core/state_controller.js +82 -0
  83. data/app/javascript/poetry/core/table_selection_controller.js +153 -0
  84. data/app/javascript/poetry/core/tabs_controller.js +151 -0
  85. data/app/javascript/poetry/core/tag_group_controller.js +134 -0
  86. data/app/javascript/poetry/core/toast_controller.js +228 -0
  87. data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
  88. data/app/javascript/poetry/core/toaster_controller.js +197 -0
  89. data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
  90. data/app/javascript/poetry/core/tooltip_controller.js +541 -0
  91. data/app/javascript/poetry/core/tree_controller.js +265 -0
  92. data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
  93. data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
  94. data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
  95. data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
  96. data/app/views/poetry/core/preview.html.erb +13 -0
  97. data/config/component_registry.yml +20 -0
  98. data/config/controllers_manifest.json +1528 -0
  99. data/config/importmap.rb +11 -0
  100. data/config/state_vocabulary.json +31 -0
  101. data/lib/active_model/type/list.rb +33 -0
  102. data/lib/active_model/type/symbol.rb +36 -0
  103. data/lib/poetry/core/check/stable_identity.rb +104 -0
  104. data/lib/poetry/core/check.rb +1445 -0
  105. data/lib/poetry/core/config.rb +286 -0
  106. data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
  107. data/lib/poetry/core/css/bem_merger.rb +45 -0
  108. data/lib/poetry/core/css/bem_reference.rb +87 -0
  109. data/lib/poetry/core/css/override_scan.rb +110 -0
  110. data/lib/poetry/core/css/resolver.rb +161 -0
  111. data/lib/poetry/core/css/safelist.rb +41 -0
  112. data/lib/poetry/core/css/tailwind_merger.rb +102 -0
  113. data/lib/poetry/core/css/template_classes.rb +102 -0
  114. data/lib/poetry/core/css/theme_coverage.rb +72 -0
  115. data/lib/poetry/core/css/var_coverage.rb +75 -0
  116. data/lib/poetry/core/css/verifier.rb +106 -0
  117. data/lib/poetry/core/design_lint.rb +720 -0
  118. data/lib/poetry/core/design_md/import.rb +250 -0
  119. data/lib/poetry/core/design_md.rb +461 -0
  120. data/lib/poetry/core/engine.rb +71 -0
  121. data/lib/poetry/core/errors.rb +53 -0
  122. data/lib/poetry/core/html/attributes.rb +667 -0
  123. data/lib/poetry/core/icons.rb +157 -0
  124. data/lib/poetry/core/llms_text.rb +354 -0
  125. data/lib/poetry/core/page_architectures.rb +304 -0
  126. data/lib/poetry/core/part_contract.rb +285 -0
  127. data/lib/poetry/core/preview/abstract.rb +121 -0
  128. data/lib/poetry/core/preview/base.rb +227 -0
  129. data/lib/poetry/core/preview/sidecarable.rb +136 -0
  130. data/lib/poetry/core/preview/template.rb +186 -0
  131. data/lib/poetry/core/recipe_items.rb +82 -0
  132. data/lib/poetry/core/registry.rb +363 -0
  133. data/lib/poetry/core/registry_address.rb +115 -0
  134. data/lib/poetry/core/registry_client.rb +217 -0
  135. data/lib/poetry/core/registry_installer.rb +234 -0
  136. data/lib/poetry/core/registry_items.rb +184 -0
  137. data/lib/poetry/core/skill_text.rb +288 -0
  138. data/lib/poetry/core/stable_id.rb +110 -0
  139. data/lib/poetry/core/stimulus/builder.rb +395 -0
  140. data/lib/poetry/core/stimulus/declarations.rb +444 -0
  141. data/lib/poetry/core/stimulus/manifest.rb +68 -0
  142. data/lib/poetry/core/stimulus/merger.rb +131 -0
  143. data/lib/poetry/core/stimulus_contract.rb +263 -0
  144. data/lib/poetry/core/tag_helper.rb +10 -0
  145. data/lib/poetry/core/template_compile.rb +74 -0
  146. data/lib/poetry/core/token_import.rb +317 -0
  147. data/lib/poetry/core/tokens/color.rb +215 -0
  148. data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
  149. data/lib/poetry/core/tokens/generator.rb +211 -0
  150. data/lib/poetry/core/tokens.rb +117 -0
  151. data/lib/poetry/core/version.rb +9 -0
  152. data/lib/poetry/core.rb +52 -0
  153. data/lib/poetry-core.rb +5 -0
  154. data/tokens/aliases.css +145 -0
  155. data/tokens/tailwind-theme.css +47 -0
  156. data/tokens/tokens.css +78 -0
  157. data/tokens/tokens.dtcg.json +779 -0
  158. data/vendor/floating-ui/LICENSE +20 -0
  159. data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
  160. data/vendor/shadcn-tailwind/LICENSE +21 -0
  161. data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
  162. data/vendor/shadcn-tailwind/tailwind.css +629 -0
  163. data/vendor/tw-animate-css/LICENSE +21 -0
  164. data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
  165. data/vendor/tw-animate-css/tw-animate.css +1 -0
  166. metadata +267 -0
@@ -0,0 +1,418 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Poetry
6
+ module Core
7
+ module Concerns
8
+ # The agent-tool contract: class-level declarations of the
9
+ # component's agent-callable tools - the "operate" projection of the
10
+ # component's Stimulus surface. A tool names one action an in-page
11
+ # agent may invoke on a rendered instance (WebMCP's
12
+ # `document.modelContext`, or any client that reads the registry),
13
+ # described in MCP `Tool` shape: name, description, JSON-Schema
14
+ # input, and safety annotations.
15
+ #
16
+ # tool :set_value,
17
+ # description: "Select the option whose value matches.",
18
+ # params: { value: { type: "string", required: true,
19
+ # description: "The option value to select." } },
20
+ # executes: :set_value,
21
+ # mutating: true
22
+ #
23
+ # Declarations are validated at CLASS LOAD, like use_stimulus:
24
+ # `executes:` resolves through {Concerns::Stimulus#stimulus_action}
25
+ # against the component's declared controllers and the controllers
26
+ # manifest, so a tool can never name an action the JS does not
27
+ # define - declare `use_stimulus` before `tool`. The resolved
28
+ # descriptor ("poetry--core--combobox#setValue") is the tool's wire
29
+ # form: registration runtimes dispatch it verbatim.
30
+ #
31
+ # Safety doctrine (non-negotiable): tools are read-only unless
32
+ # declared `mutating: true` (`annotations.readOnlyHint` inverts it),
33
+ # `untrusted_content: true` marks tools whose output carries
34
+ # user-authored content, and declaring a tool exposes NOTHING by
35
+ # itself - emission is opt-in per rendered instance, owned by the
36
+ # registration runtime, never default-on.
37
+ #
38
+ # Projection: {ClassMethods#tool_definitions} feeds the registry's
39
+ # per-component `tools` section (plain strings, YAML-safe), which
40
+ # the agent surfaces (llms.txt, MCP server, skills, docs) and the
41
+ # WebMCP registration payload all read - one contract, every
42
+ # surface.
43
+ module AgentTools
44
+ extend ActiveSupport::Concern
45
+
46
+ # Raised at class load for an invalid tool declaration.
47
+ class ToolError < Poetry::Core::Error; end
48
+
49
+ # One declared tool. `executes` is the declared action spec
50
+ # (`[method]` or `[controller, method]`) - resolved to a bare
51
+ # Stimulus descriptor per PROJECTING class (see
52
+ # {ClassMethods#tool_definitions}); `input_schema` is the
53
+ # normalized JSON-Schema object (string keys) or nil for a
54
+ # parameterless tool.
55
+ Tool = Struct.new(:name, :title, :description, :input_schema,
56
+ :executes, :mutating, :untrusted_content,
57
+ keyword_init: true)
58
+
59
+ # Tool and param names stay inside the WebMCP tool-name grammar
60
+ # (ASCII alphanumerics, `_`, `-`, `.`) with poetry's stricter
61
+ # snake_case convention, so a composed full name (instance prefix +
62
+ # tool name) can never need escaping.
63
+ NAME_PATTERN = /\A[a-z][a-z0-9_]*\z/
64
+
65
+ # The house character budgets: names stay far under the spec's
66
+ # 128-char full-name cap (instance prefixes join later);
67
+ # descriptions stay inside the agent-legibility budget.
68
+ NAME_LIMIT = 64
69
+ # The registrar controller (poetry-agent's) that a `webmcp:` root
70
+ # registers; its manifest entry joins core's catalog when the gem
71
+ # loads, so the wiring validates like any other controller.
72
+ WEBMCP_CONTROLLER = "poetry--agent--webmcp"
73
+ # The agent-legibility budget for a tool description.
74
+ DESCRIPTION_LIMIT = 500
75
+ # The agent-legibility budget for one parameter's description
76
+ # (Chrome's guidance: 150 characters per parameter description).
77
+ PARAM_DESCRIPTION_LIMIT = 150
78
+ # The registrar's built-in per-document registration budget; the
79
+ # root carries an explicit budget only when the configured value
80
+ # ({Poetry::Core::Config#webmcp_registration_budget}) differs.
81
+ WEBMCP_DEFAULT_BUDGET = 20
82
+
83
+ class_methods do
84
+ # Declares one agent-callable tool of this component.
85
+ #
86
+ # @param name [Symbol] the tool's name (snake_case; composes into
87
+ # the registered full name, so it must satisfy the WebMCP name
88
+ # grammar)
89
+ # @param description [String] what the tool does and when to use
90
+ # it - positive, single-function, non-empty (agents pick tools
91
+ # by this text)
92
+ # @param executes [Symbol, Array(Symbol, Symbol)] the Stimulus
93
+ # action the tool dispatches: a bare method resolves across the
94
+ # component's declared controllers; `[:controller, :method]`
95
+ # pins one. Validated against the controllers manifest at
96
+ # class load.
97
+ # @param params [Hash{Symbol => Hash}, nil] the tool's parameters
98
+ # as `name => spec`; each spec requires `type:` and may carry
99
+ # `required: true` (folded into the schema's `required` list)
100
+ # plus any JSON-Schema keywords (`description:` - at most
101
+ # {PARAM_DESCRIPTION_LIMIT} characters -, `enum:`, ...). The
102
+ # generated schema closes with `additionalProperties: false`,
103
+ # so a runtime rejects parameters the tool never declared.
104
+ # @param input_schema [Hash, nil] a complete JSON-Schema object
105
+ # for advanced shapes - mutually exclusive with `params:`
106
+ # @param title [String, nil] human-readable label for agent UIs
107
+ # (the spec's UA-displayable field); defaults to the tool name
108
+ # humanized (`set_value` -> "Set value")
109
+ # @param mutating [Boolean] declare true when invoking the tool
110
+ # changes state; tools are read-only by default
111
+ # (`annotations.readOnlyHint` is the inverse of this flag)
112
+ # @param untrusted_content [Boolean] declare true when the
113
+ # tool's output can carry user-authored content
114
+ # @return [void]
115
+ # @raise [ToolError] at class load for a duplicate name, invalid
116
+ # name/description/params, or an `executes:` action the
117
+ # declared controllers do not define
118
+ # @example A parameterless UI-state tool
119
+ # tool :open, description: "Open the dialog.",
120
+ # executes: :open, mutating: true
121
+ def tool(name, description:, executes:, # rubocop:disable Metrics/ParameterLists -- one keyword per Tool field
122
+ params: nil, input_schema: nil, title: nil, mutating: false, untrusted_content: false)
123
+ name = validate_tool_name!(name)
124
+ raise ToolError, "#{self}: tool #{name.inspect} declared twice" if own_tools.key?(name)
125
+
126
+ validate_tool_action!(name, executes) # the class-load gate; resolution happens at projection
127
+ own_tools[name] = AgentTools::Tool.new(
128
+ name: name,
129
+ title: title&.to_s,
130
+ description: validate_tool_description!(name, description),
131
+ input_schema: normalize_tool_schema!(name, params, input_schema),
132
+ executes: Array(executes).map(&:to_sym),
133
+ mutating: mutating ? true : false,
134
+ untrusted_content: untrusted_content ? true : false
135
+ )
136
+ end
137
+
138
+ # This class's own tool declarations, name => {Tool}.
139
+ #
140
+ # @return [Hash{Symbol => Tool}]
141
+ def own_tools
142
+ @own_tools ||= {}
143
+ end
144
+
145
+ # The effective tools after inheritance: superclass chain
146
+ # root-first, a subclass redeclaring a name replaces it.
147
+ #
148
+ # @return [Hash{Symbol => Tool}]
149
+ def tools
150
+ chain = []
151
+ klass = self
152
+ while klass.respond_to?(:own_tools)
153
+ chain.unshift(klass)
154
+ klass = klass.superclass
155
+ end
156
+
157
+ chain.each_with_object({}) do |ancestor, resolved|
158
+ resolved.merge!(ancestor.own_tools)
159
+ end
160
+ end
161
+
162
+ # The registry-shaped projection of the resolved tools: MCP
163
+ # `Tool` fields (name / title / description / inputSchema /
164
+ # annotations) plus the `executes` dispatch descriptor - plain
165
+ # string keys, YAML round-trippable.
166
+ #
167
+ # `executes` resolves HERE, against the projecting class: a
168
+ # subclass that re-controllers its root (Sheet over Dialog)
169
+ # projects its own controller's descriptor for an inherited
170
+ # bare `executes: :open`, and a pinned controller the subclass
171
+ # no longer wires raises instead of projecting a descriptor the
172
+ # rendered DOM cannot dispatch.
173
+ #
174
+ # @return [Array<Hash>]
175
+ # @raise [ToolError] when an inherited tool's action does not
176
+ # resolve on this class's declared controllers
177
+ def tool_definitions
178
+ tools.values.map do |tool|
179
+ definition = { "name" => tool.name.to_s }
180
+ definition["title"] = tool.title || default_tool_title(tool.name)
181
+ definition["description"] = tool.description
182
+ definition["inputSchema"] = tool.input_schema if tool.input_schema
183
+ definition["annotations"] = {
184
+ "readOnlyHint" => !tool.mutating,
185
+ "untrustedContentHint" => tool.untrusted_content
186
+ }
187
+ definition["executes"] = resolve_tool_action(tool)
188
+ definition
189
+ end
190
+ end
191
+
192
+ # The bare Stimulus descriptor a tool dispatches on THIS class.
193
+ #
194
+ # @param tool [Tool]
195
+ # @return [String] e.g. "poetry--core--combobox#setValue"
196
+ # @raise [ToolError] when the action does not resolve on the
197
+ # class's declared controllers, or resolves to a controller
198
+ # the class does not wire
199
+ def resolve_tool_action(tool)
200
+ descriptor = validate_tool_action!(tool.name, tool.executes)
201
+ identifier = descriptor.split("#").first
202
+ return descriptor if stimulus_identifiers.include?(identifier)
203
+
204
+ raise ToolError,
205
+ "#{self}: tool #{tool.name.inspect} executes #{descriptor}, but #{self} does not " \
206
+ "wire #{identifier} (declared: #{stimulus_identifiers.join(", ")}) - redeclare the " \
207
+ "tool on #{self} with the controller it wires"
208
+ end
209
+
210
+ private
211
+
212
+ # The UA-displayable default title: the snake_case name as
213
+ # words, first letter up ("set_value" -> "Set value").
214
+ #
215
+ # @api private
216
+ def default_tool_title(name)
217
+ words = name.to_s.tr("_", " ")
218
+ words[0].upcase + words[1..]
219
+ end
220
+
221
+ # @api private
222
+ def validate_tool_name!(name)
223
+ unless name.is_a?(Symbol) || name.is_a?(String)
224
+ raise ToolError, "#{self}: tool name must be a Symbol (got #{name.class})"
225
+ end
226
+
227
+ text = name.to_s
228
+ unless NAME_PATTERN.match?(text) && text.length <= NAME_LIMIT
229
+ raise ToolError,
230
+ "#{self}: invalid tool name #{name.inspect} - snake_case " \
231
+ "([a-z][a-z0-9_]*), at most #{NAME_LIMIT} characters"
232
+ end
233
+ text.to_sym
234
+ end
235
+
236
+ # @api private
237
+ def validate_tool_description!(name, description)
238
+ text = description.to_s.strip
239
+ if text.empty? || text.length > DESCRIPTION_LIMIT
240
+ raise ToolError,
241
+ "#{self}: tool #{name.inspect} description must be present and " \
242
+ "at most #{DESCRIPTION_LIMIT} characters"
243
+ end
244
+ text
245
+ end
246
+
247
+ # The `executes:` gate: build the bare descriptor through the
248
+ # class's own stimulus_action, which resolves the controller
249
+ # across the use_stimulus declarations and validates the method
250
+ # against the controllers manifest. Returns the descriptor;
251
+ # callers keep the spec (`Array(executes)`) for re-resolution.
252
+ #
253
+ # @api private
254
+ def validate_tool_action!(name, executes)
255
+ args = Array(executes)
256
+ unless args.size.between?(1, 2) && args.all? { |arg| arg.is_a?(Symbol) || arg.is_a?(String) }
257
+ raise ToolError,
258
+ "#{self}: tool #{name.inspect} executes: takes a method Symbol " \
259
+ "or [controller, method]"
260
+ end
261
+
262
+ begin
263
+ stimulus_action(*args)
264
+ rescue ArgumentError, Poetry::Core::Stimulus::Declarations::DeclarationError,
265
+ Poetry::Core::Stimulus::Manifest::UnknownName => e
266
+ raise ToolError,
267
+ "#{self}: tool #{name.inspect} executes: #{executes.inspect} - " \
268
+ "#{e.message} (declare use_stimulus before tool)"
269
+ end
270
+ end
271
+
272
+ # @api private
273
+ def normalize_tool_schema!(name, params, input_schema)
274
+ if params && input_schema
275
+ raise ToolError,
276
+ "#{self}: tool #{name.inspect} takes ONE of params: or input_schema:"
277
+ end
278
+ return deep_stringify_tool_keys(validate_input_schema!(name, input_schema)) if input_schema
279
+ return nil if params.nil? || params.empty?
280
+
281
+ raise ToolError, "#{self}: tool #{name.inspect} params: must be a Hash" unless params.is_a?(Hash)
282
+
283
+ properties = {}
284
+ required = []
285
+ params.each do |param_name, spec|
286
+ param = validate_tool_name!(param_name).to_s
287
+ unless spec.is_a?(Hash) && (spec[:type] || spec["type"])
288
+ raise ToolError,
289
+ "#{self}: tool #{name.inspect} param #{param_name.inspect} needs " \
290
+ "a Hash spec with type:"
291
+ end
292
+ spec = deep_stringify_tool_keys(spec)
293
+ required << param if spec.delete("required")
294
+ validate_param_description!(name, param, spec["description"])
295
+ properties[param] = spec
296
+ end
297
+
298
+ schema = { "type" => "object", "properties" => properties }
299
+ schema["required"] = required if required.any?
300
+ schema["additionalProperties"] = false
301
+ schema
302
+ end
303
+
304
+ # @api private
305
+ def validate_param_description!(name, param, description)
306
+ return if description.nil?
307
+
308
+ text = description.to_s
309
+ return unless text.strip.empty? || text.length > PARAM_DESCRIPTION_LIMIT
310
+
311
+ raise ToolError,
312
+ "#{self}: tool #{name.inspect} param #{param.inspect} description must be present " \
313
+ "and at most #{PARAM_DESCRIPTION_LIMIT} characters"
314
+ end
315
+
316
+ # @api private
317
+ def validate_input_schema!(name, input_schema)
318
+ unless input_schema.is_a?(Hash) && (input_schema[:type] || input_schema["type"])
319
+ raise ToolError,
320
+ "#{self}: tool #{name.inspect} input_schema: must be a JSON-Schema " \
321
+ "Hash with type:"
322
+ end
323
+ input_schema
324
+ end
325
+
326
+ # @api private
327
+ def deep_stringify_tool_keys(value)
328
+ case value
329
+ when Hash then value.to_h { |key, inner| [key.to_s, deep_stringify_tool_keys(inner)] }
330
+ when Array then value.map { |inner| deep_stringify_tool_keys(inner) }
331
+ when Symbol then value.to_s
332
+ else value
333
+ end
334
+ end
335
+ end
336
+ # Whether this rendered instance opted into WebMCP registration
337
+ # (`webmcp: true` or `webmcp: "name"` on the call).
338
+ #
339
+ # @return [Boolean]
340
+ def webmcp_enabled?
341
+ !!@webmcp
342
+ end
343
+
344
+ # The instance name the registrar composes tool names from
345
+ # (`poetry.{name}.{tool}`): the explicit `webmcp:` string, else the
346
+ # component title (give instances explicit names when a page
347
+ # renders several of one component - the browser rejects
348
+ # duplicate tool names).
349
+ #
350
+ # @return [String, nil] nil when not enabled
351
+ # @raise [ToolError] when the explicit name is not a valid instance
352
+ # name
353
+ def webmcp_name
354
+ return nil unless webmcp_enabled?
355
+
356
+ name = (@webmcp == true ? self.class.component_title : @webmcp.to_s).tr("-", "_")
357
+ unless NAME_PATTERN.match?(name) && name.length <= NAME_LIMIT
358
+ raise ToolError,
359
+ "#{self.class}: webmcp: #{@webmcp.inspect} is not a valid instance name " \
360
+ "(snake_case, at most #{NAME_LIMIT} characters)"
361
+ end
362
+ name
363
+ end
364
+
365
+ # The instance's registration payload: its resolved tool
366
+ # definitions, each passed through {#webmcp_tool_definition} so a
367
+ # component can enrich a schema with rendered facts (Tabs adds the
368
+ # rendered tab values as an enum).
369
+ #
370
+ # @return [Array<Hash>]
371
+ def webmcp_tools
372
+ self.class.tool_definitions.map { |definition| webmcp_tool_definition(definition) }
373
+ end
374
+
375
+ # The per-instance enrichment hook - override to refine a tool's
376
+ # projected definition with what only the rendered instance knows.
377
+ # Must return a JSON-serializable Hash with the same keys.
378
+ #
379
+ # @param definition [Hash] the class-level projection
380
+ # @return [Hash]
381
+ def webmcp_tool_definition(definition)
382
+ definition
383
+ end
384
+
385
+ # The registrar wiring for the root: applied by
386
+ # {Concerns::Stimulus#stimulus_attributes_for} on the ONE shared
387
+ # Attributes instance, so data-controller merges correctly with
388
+ # the component's own controllers. Raises when the runtime gem is
389
+ # absent - opting in without poetry-agent is a configuration error,
390
+ # not a silent no-op.
391
+ #
392
+ # @param attrs [Poetry::Core::HTML::Attributes]
393
+ # @return [void]
394
+ # @api private
395
+ def apply_webmcp_wiring(attrs)
396
+ return unless webmcp_enabled?
397
+
398
+ unless Poetry::Core::Stimulus::Manifest.catalog.key?(WEBMCP_CONTROLLER)
399
+ raise ToolError,
400
+ "#{self.class}: webmcp: requires the poetry-agent gem (its #{WEBMCP_CONTROLLER} " \
401
+ "controller is not registered) - add `gem \"poetry-agent\"` to the Gemfile"
402
+ end
403
+ if self.class.tools.empty?
404
+ raise ToolError,
405
+ "#{self.class}: webmcp: opted in, but the component declares no tools"
406
+ end
407
+
408
+ builder = Poetry::Core::Stimulus::Builder.new(WEBMCP_CONTROLLER, attrs)
409
+ builder.register_controller
410
+ builder.with_value(:name, webmcp_name)
411
+ builder.with_value(:tools, JSON.generate(webmcp_tools))
412
+ budget = Poetry::Core::Config.current.webmcp_registration_budget
413
+ builder.with_value(:budget, budget) if budget != WEBMCP_DEFAULT_BUDGET
414
+ end
415
+ end
416
+ end
417
+ end
418
+ end
@@ -0,0 +1,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Poetry
4
+ module Core
5
+ module Concerns
6
+ # The declared-attribute engine shared by the Styles and Options DSLs.
7
+ # Each DSL is one "kind" of declared attribute: registration lands in
8
+ # per-kind class-level collections (@_<kind>_attributes,
9
+ # @_<kind>_attributes_with_defaults, @_<kind>_proc_defaults), explicit
10
+ # initialization is tracked per instance through the registered_<kind>s
11
+ # class_attribute the owning concern declares, and hierarchy-wide
12
+ # queries walk the ancestry up to the base component class.
13
+ # Kind-specific surface - variants and CSS emission for styles,
14
+ # ActiveModel types and value formats for options - stays in the
15
+ # owning concern.
16
+ module DeclaredAttributes
17
+ extend ActiveSupport::Concern
18
+
19
+ # Where ancestor walks stop: declarations above the base component
20
+ # class are nobody's.
21
+ BASE_COMPONENT_CLASS = Poetry::Core::Component
22
+
23
+ class_methods do
24
+ private
25
+
26
+ # Registers a declared attribute in the per-kind class-level
27
+ # collections. Runs before any option extraction so the presence
28
+ # of :default (static or proc) is still observable.
29
+ #
30
+ # The :doc string is documentation, not an ActiveModel option - it
31
+ # is lifted out here (before `attribute` ever sees the hash) into
32
+ # the per-kind docs map that the registry and doc build project.
33
+ #
34
+ # @param kind [Symbol] :style or :option
35
+ # @param name [Symbol, String] the attribute name
36
+ # @param options [Hash] the attribute options
37
+ def register_declared_attribute(kind, name, options)
38
+ declared_ivar_set(:"@_#{kind}_attributes") << name.to_sym
39
+
40
+ doc = options.delete(:doc)
41
+ declared_ivar_hash(:"@_#{kind}_docs")[name.to_sym] = doc if doc
42
+
43
+ declared_ivar_set(:"@_#{kind}_attributes_with_defaults") << name.to_sym if options.key?(:default)
44
+
45
+ return unless options[:default].is_a?(Proc)
46
+
47
+ declared_ivar_hash(:"@_#{kind}_proc_defaults")[name.to_sym] = options[:default]
48
+ end
49
+
50
+ # The declaration docs for a kind, hierarchy-wide (nearest
51
+ # declaration wins).
52
+ #
53
+ # @param kind [Symbol] :style or :option
54
+ # @return [Hash{Symbol => String}]
55
+ def declared_docs(kind)
56
+ collect_declared_map(:"@_#{kind}_docs")
57
+ end
58
+
59
+ # Extracts the shared :default/:required options. A proc default
60
+ # is deleted so ActiveModel never installs it (the getter override
61
+ # evaluates it lazily against the instance); a static default
62
+ # stays in the hash for `attribute` to install.
63
+ #
64
+ # @param options [Hash] the original options hash
65
+ # @return [Array<(Boolean, Object)>] required and default_value
66
+ def extract_declared_defaults(options)
67
+ default_value = options[:default]
68
+ options.delete(:default) if default_value.is_a?(Proc)
69
+
70
+ required = options.delete(:required) || false
71
+
72
+ [required, default_value]
73
+ end
74
+
75
+ # Sets up per-instance initialization tracking: every declared
76
+ # attribute records explicit writes; a proc default additionally
77
+ # resolves through the tracked state on read.
78
+ #
79
+ # @param kind [Symbol] :style or :option
80
+ # @param name [Symbol] the attribute name
81
+ # @param default_value [Object] the default value (may be a Proc)
82
+ def setup_declared_tracking(kind, name, default_value)
83
+ override_declared_setter_for_tracking(kind, name)
84
+ override_declared_getter_for_proc_default(kind, name, default_value) if default_value.is_a?(Proc)
85
+ end
86
+
87
+ # Overrides the setter to record the attribute in the kind's
88
+ # registered set.
89
+ #
90
+ # @param kind [Symbol] :style or :option
91
+ # @param name [Symbol] the attribute name
92
+ def override_declared_setter_for_tracking(kind, name)
93
+ original_setter = instance_method("#{name}=")
94
+ registry_reader = :"registered_#{kind}s"
95
+ registry_writer = :"registered_#{kind}s="
96
+
97
+ define_method("#{name}=") do |value|
98
+ public_send(registry_writer, Set.new) if public_send(registry_reader).nil?
99
+ public_send(registry_reader) << name.to_sym
100
+ original_setter.bind_call(self, value)
101
+ end
102
+ end
103
+
104
+ # Overrides the getter so an unset attribute evaluates its proc
105
+ # default against the instance (other attributes are readable),
106
+ # surfacing the value in the attributes hash without marking the
107
+ # attribute initialized - reads keep following the proc until an
108
+ # explicit write.
109
+ #
110
+ # @param kind [Symbol] :style or :option
111
+ # @param name [Symbol] the attribute name
112
+ # @param default_value [Proc] the proc that provides the default value
113
+ def override_declared_getter_for_proc_default(kind, name, default_value)
114
+ original_getter = instance_method(name)
115
+ initialized_predicate = :"#{kind}_attribute_initialized?"
116
+
117
+ define_method(name) do
118
+ if public_send(initialized_predicate, name)
119
+ original_getter.bind_call(self)
120
+ else
121
+ value = instance_exec(&default_value)
122
+ @attributes.write_from_user(name.to_s, value)
123
+ value
124
+ end
125
+ end
126
+ end
127
+
128
+ # All declared attributes of a kind across the hierarchy.
129
+ #
130
+ # @param kind [Symbol] :style or :option
131
+ # @return [Array<Symbol>] sorted array of attribute names
132
+ def declared_attributes(kind)
133
+ collect_declared_set(:"@_#{kind}_attributes")
134
+ end
135
+
136
+ # Declared attributes of a kind with any default (static or proc).
137
+ #
138
+ # @param kind [Symbol] :style or :option
139
+ # @return [Array<Symbol>] sorted array of attribute names
140
+ def declared_attributes_with_defaults(kind)
141
+ collect_declared_set(:"@_#{kind}_attributes_with_defaults")
142
+ end
143
+
144
+ # Declared attributes of a kind with a static (non-proc) default.
145
+ #
146
+ # @param kind [Symbol] :style or :option
147
+ # @return [Array<Symbol>] sorted array of attribute names
148
+ def declared_attributes_with_static_defaults(kind)
149
+ proc_defaults_ivar = :"@_#{kind}_proc_defaults"
150
+
151
+ collect_declared_set(:"@_#{kind}_attributes_with_defaults") do |attributes_set, defaults, klass|
152
+ next unless defaults
153
+
154
+ proc_defaults = klass.instance_variable_get(proc_defaults_ivar)
155
+ defaults.each do |attr|
156
+ attributes_set << attr unless proc_defaults&.key?(attr)
157
+ end
158
+ end
159
+ end
160
+
161
+ # Declared attributes of a kind with a proc default.
162
+ #
163
+ # @param kind [Symbol] :style or :option
164
+ # @return [Array<Symbol>] sorted array of attribute names
165
+ def declared_attributes_with_proc_defaults(kind)
166
+ collect_declared_set(:"@_#{kind}_proc_defaults") do |attributes_set, proc_defaults, _klass|
167
+ attributes_set.merge(proc_defaults.keys) if proc_defaults
168
+ end
169
+ end
170
+
171
+ # Collects a Set-valued class-level collection across the
172
+ # hierarchy.
173
+ #
174
+ # @param ivar_name [Symbol] the instance variable name to collect
175
+ # @yield [attributes_set, values, klass] optional block for custom collection logic
176
+ # @return [Array<Symbol>] sorted array of collected attributes
177
+ def collect_declared_set(ivar_name)
178
+ attributes_set = Set.new
179
+
180
+ declared_hierarchy do |klass|
181
+ values = klass.instance_variable_get(ivar_name)
182
+
183
+ if block_given?
184
+ yield(attributes_set, values, klass)
185
+ elsif values
186
+ attributes_set.merge(values)
187
+ end
188
+ end
189
+
190
+ attributes_set.to_a.sort
191
+ end
192
+
193
+ # Collects a Hash-valued class-level collection across the
194
+ # hierarchy; the nearest declaration wins on key conflicts.
195
+ #
196
+ # @param ivar_name [Symbol] the instance variable name to collect
197
+ # @return [Hash{Symbol => Object}]
198
+ def collect_declared_map(ivar_name)
199
+ map = {}
200
+
201
+ declared_hierarchy do |klass|
202
+ klass_map = klass.instance_variable_get(ivar_name)
203
+ map = klass_map.merge(map) if klass_map
204
+ end
205
+
206
+ map
207
+ end
208
+
209
+ # Walks the ancestry from this class toward the base component
210
+ # class, yielding each class that can carry declarations. The base
211
+ # class itself is never yielded.
212
+ def declared_hierarchy
213
+ klass = self
214
+
215
+ while klass&.respond_to?(:instance_variable_get)
216
+ yield klass
217
+
218
+ klass = klass.superclass
219
+ break if klass == BASE_COMPONENT_CLASS || !klass.ancestors.include?(BASE_COMPONENT_CLASS)
220
+ end
221
+ end
222
+
223
+ # The class-level Set for an ivar, created on first use.
224
+ #
225
+ # @param ivar_name [Symbol]
226
+ # @return [Set]
227
+ def declared_ivar_set(ivar_name)
228
+ instance_variable_get(ivar_name) || instance_variable_set(ivar_name, Set.new)
229
+ end
230
+
231
+ # The class-level Hash for an ivar, created on first use.
232
+ #
233
+ # @param ivar_name [Symbol]
234
+ # @return [Hash]
235
+ def declared_ivar_hash(ivar_name)
236
+ instance_variable_get(ivar_name) || instance_variable_set(ivar_name, {})
237
+ end
238
+ end
239
+
240
+ # The cop cannot see across the class_methods block boundary: this
241
+ # modifier governs the module's instance methods, not the block's.
242
+ private # rubocop:disable Lint/UselessAccessModifier
243
+
244
+ # The initialized-attribute list behind a kind's registered set.
245
+ #
246
+ # @param registry [Set, nil] the per-instance registered set
247
+ # @return [Array<Symbol>] sorted array of initialized attribute names
248
+ def initialized_declared_attributes(registry)
249
+ return [] if registry.nil?
250
+
251
+ registry.to_a.sort
252
+ end
253
+
254
+ # Whether an attribute is recorded in a kind's registered set.
255
+ #
256
+ # @param registry [Set, nil] the per-instance registered set
257
+ # @param name [Symbol, String] the attribute name
258
+ # @return [Boolean] true if the attribute was explicitly set
259
+ def declared_attribute_registered?(registry, name)
260
+ return false if registry.nil?
261
+
262
+ registry.include?(name.to_sym)
263
+ end
264
+ end
265
+ end
266
+ end
267
+ end