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,1445 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Poetry
6
+ module Core
7
+ # poetry check: a static linter for consumer/agent-written ERB.
8
+ # It herb-parses the template and validates every poetry surface against
9
+ # the committed registry + controllers manifest WITHOUT rendering -
10
+ # unknown components/options/variants (did-you-mean), and unknown
11
+ # Stimulus controllers/actions/targets/values (the Ruby<->JS seam, now at
12
+ # consumer-markup level - hand-written attributes and `data:` keywords
13
+ # alike, values checked against their declared type), plus the style
14
+ # exits that bypass the tokens - raw and arbitrary color classes,
15
+ # inline style colors, and the important modifier - in class=""/style=""
16
+ # attributes and class:/style: keywords alike, and the fake button (an
17
+ # inert element wearing onclick or role=button). The mechanical gate an
18
+ # agent self-corrects against before any deeper review runs.
19
+ #
20
+ # @example Lint one template string
21
+ # findings = Poetry::Core::Check.lint(erb_source, catalog: catalog)
22
+ # findings.reject { |finding| finding.severity == :warning }
23
+ #
24
+ # @api private
25
+ module Check
26
+ SEVERITIES = %i[error warning].freeze
27
+
28
+ # A single lint result. file is attached by the Runner; line is
29
+ # 1-based into that file. suggestion is a did-you-mean fix or nil.
30
+ #
31
+ # @api private
32
+ Finding = Struct.new(:rule, :severity, :message, :line, :suggestion, :file, keyword_init: true) do
33
+ def to_h
34
+ { rule: rule, severity: severity, message: message,
35
+ line: line, suggestion: suggestion, file: file }.compact
36
+ end
37
+
38
+ def to_s
39
+ location = [file, line].compact.join(":")
40
+ hint = suggestion ? " (did you mean #{suggestion}?)" : ""
41
+ "#{location}: [#{severity}] #{rule}: #{message}#{hint}"
42
+ end
43
+ end
44
+
45
+ # Query wrapper over the committed registry hash + the controllers
46
+ # manifest. Language-agnostic data in, poetry semantics out.
47
+ #
48
+ # @api private
49
+ class Catalog
50
+ PASSTHROUGH = %w[class id key webmcp data aria role style if unless].freeze
51
+ COLOR_FAMILIES = %w[
52
+ slate gray zinc neutral stone red orange amber yellow lime green emerald
53
+ teal cyan sky blue indigo violet purple fuchsia pink rose
54
+ ].freeze
55
+ COLOR_UTILITIES = %w[
56
+ bg text border ring fill stroke from to via outline decoration divide accent caret shadow
57
+ ].freeze
58
+ # The palette exit: a color-scale utility (bg-red-500) where a
59
+ # semantic token belongs.
60
+ RAW_COLOR = /\b(?:#{COLOR_UTILITIES.join("|")})-(?:#{COLOR_FAMILIES.join("|")})-\d{2,3}\b/
61
+ COLOR_FUNCTIONS = %w[rgb rgba hsl hsla oklch oklab lab lch hwb color].freeze
62
+ # The arbitrary exit: a color literal typed straight into a utility
63
+ # (bg-[#6366f1], text-[oklch(0.7_0.1_250)]). Unanchored, so it reads
64
+ # one class token and a whole rendered document alike - the eval's
65
+ # universal gate shares it, and one pattern defines the exit.
66
+ ARBITRARY_COLOR = /\b(?:#{COLOR_UTILITIES.join("|")})-\[(?:#|(?:#{COLOR_FUNCTIONS.join("|")})\()/
67
+ # The cascade exit: the important modifier on one class token,
68
+ # trailing (pl-2!) or the legacy leading form (!pl-2).
69
+ IMPORTANT_MODIFIER = /\A!.|.!\z/
70
+ # A color literal in an inline style (color: #333; background:
71
+ # rgb(...)) - a url(#fragment) is not a color.
72
+ STYLE_COLOR = /(?<!url\()#\h{3,8}\b|\b(?:#{COLOR_FUNCTIONS.join("|")})\([^)]*\)/i
73
+
74
+ def self.from_registry(root, helpers: nil, icon_names: nil)
75
+ from_registries([root], helpers: helpers, icon_names: icon_names)
76
+ end
77
+
78
+ # A host catalog spans every installed poetry gem (ui + charts): a
79
+ # gem whose components stay out of the merge leaves its helpers
80
+ # name-valid but pathless, and every one of their blocks reads as a
81
+ # yieldless wrapper block (the chart false-positive class).
82
+ def self.from_registries(roots, helpers: nil, icon_names: nil)
83
+ payloads = roots.map do |root|
84
+ YAML.load_file(Pathname.new(root).join(Registry::RELATIVE_PATH), aliases: true)
85
+ end
86
+ new(payloads.map { |payload| payload.fetch("components") }.reduce({}, :merge),
87
+ helpers: helpers,
88
+ helper_entries: payloads.map { |payload| payload["helpers"] || {} }.reduce({}, :merge),
89
+ icon_names: icon_names,
90
+ helper_args: payloads.map { |payload| payload["helper_args"] || {} }.reduce({}, :merge))
91
+ end
92
+
93
+ # helpers: the FULL set of valid poetry_* helper method names (from
94
+ # the host's ComponentsHelper) - the truth for "is this a real
95
+ # helper". Group / provider / item helpers (poetry_bubble_group,
96
+ # poetry_tooltip_provider) are valid but map to no component, so they
97
+ # pass the existence check and skip option/variant validation. When
98
+ # omitted, the valid set falls back to the component-mapped helpers
99
+ # plus the registry's own "helpers" section (helper_entries), which
100
+ # also carries the value contracts runtime-enforced inside wrapper
101
+ # helpers (poetry_input_group_addon align: et al). icon_names: the
102
+ # active icon set's valid names - when given, icon-formatted option
103
+ # values are checked for membership, not just shape.
104
+ def initialize(components, helpers: nil, helper_entries: nil, icon_names: nil, helper_args: nil)
105
+ @components = components
106
+ @helper_entries = helper_entries || {}
107
+ @helper_args = helper_args || {}
108
+ @icon_names = icon_names&.to_set(&:to_s)
109
+ # helper name -> registry path: poetry_ + the path under the gem
110
+ # namespace (poetry/ui/command/dialog -> poetry_command_dialog,
111
+ # avoiding the last-segment collision with poetry/ui/dialog). The
112
+ # prefix strip covers every poetry gem, not just poetry/ui/ - a
113
+ # merged catalog carries poetry/charts/* too, and a chart helper
114
+ # that fails to map here reads as a yielding wrapper (the chart
115
+ # yieldless-block false positives).
116
+ @path_by_helper = components.keys.to_h do |path|
117
+ ["poetry_#{path.sub(%r{\Apoetry/[^/]+/}, "").tr("/", "_")}", path]
118
+ end
119
+ @helper_names = ((helpers&.map(&:to_s) || @path_by_helper.keys) + @helper_entries.keys).to_set
120
+ end
121
+
122
+ attr_reader :icon_names
123
+
124
+ def helper_names = @helper_names.to_a
125
+
126
+ def helper?(name) = @helper_names.include?(name)
127
+ def path_for(helper) = @path_by_helper[helper]
128
+
129
+ # Max positional arity for a helper, or nil when the registry does
130
+ # not state one (legacy registries stay lint-identical).
131
+ def helper_args(helper) = @helper_args[helper]
132
+
133
+ # A pathless helper that DECLARES it yields (registry helpers
134
+ # section, "yields" key): the dispatcher exception to the
135
+ # no-wrapper-yields invariant - poetry_chart routes to a component
136
+ # that yields its slot builder.
137
+ def helper_yields?(helper) = !@helper_entries.dig(helper, "yields").nil?
138
+
139
+ # A helper whose component entry declares "identity" => false:
140
+ # its rendered output carries no poetry-minted id, so key:/id:
141
+ # have nothing to stabilize and the stable-identity rules skip
142
+ # it. Pathless helpers and legacy entries (no key) read as
143
+ # minting - their warnings stand.
144
+ def identity_free?(helper) = @components.dig(@path_by_helper[helper], "identity") == false
145
+
146
+ # The element-level wiring projection: which elements of
147
+ # a component carry which controllers, values, actions, and
148
+ # targets - use_stimulus declarations, serialized. Empty for
149
+ # registries that predate the projection; future check rules and
150
+ # suggestions read the component's actual wiring here instead of
151
+ # the controllers section's whole-API view.
152
+ def stimulus_wiring(path)
153
+ @components.dig(path, "stimulus") || []
154
+ end
155
+
156
+ # The component's declared agent tools (the operate surface a
157
+ # webmcp: instance registers).
158
+ def tools_of(path)
159
+ @components.dig(path, "tools") || []
160
+ end
161
+
162
+ def option_names(path)
163
+ entry = @components.fetch(path, {})
164
+ (entry["options"] || []).map { |option| option["name"] } +
165
+ (entry["styles"] || []).map { |style| style["name"] }
166
+ end
167
+
168
+ # The full declaration (variants/required/default/format) for one
169
+ # option or style attribute, or nil for pass-through keys.
170
+ def option_entry(path, key)
171
+ entry = @components.fetch(path, {})
172
+ ((entry["options"] || []) + (entry["styles"] || [])).find { |option| option["name"] == key }
173
+ end
174
+
175
+ # Options a call cannot omit: required with no default to fall back
176
+ # on (poetry_icon name:). Required styles WITH defaults (alert
177
+ # variant:) are satisfiable by omission and stay out.
178
+ def required_options(path)
179
+ entry = @components.fetch(path, {})
180
+ ((entry["options"] || []) + (entry["styles"] || []))
181
+ .select { |option| option["required"] && !option.key?("default") }
182
+ .map { |option| option["name"] }
183
+ end
184
+
185
+ # Slot queries take an OWNER: a component path (String) or a nested
186
+ # builder surface (Hash - the "builders" payload of a slot entry),
187
+ # so `menubar.with_menu do |menu|` and `menu.with_item` walk
188
+ # the same rules at every depth.
189
+ def slots_of(owner)
190
+ owner.is_a?(Hash) ? (owner["slots"] || []) : (@components.dig(owner, "slots") || [])
191
+ end
192
+
193
+ def slot_extras_of(owner)
194
+ owner.is_a?(Hash) ? (owner["slot_extras"] || []) : (@components.dig(owner, "slot_extras") || [])
195
+ end
196
+
197
+ # The slot behind a with_<name> call: exact match, the singular form
198
+ # of a many-slot (renders_many :items => with_item), or one of a
199
+ # polymorphic slot's types (with_separator => items' separator type).
200
+ def slot_entry(owner, name)
201
+ slots_of(owner).find do |slot|
202
+ slot["name"] == name || (slot["many"] && slot["name"] == "#{name}s") ||
203
+ (slot["types"] || []).include?(name)
204
+ end
205
+ end
206
+
207
+ # Hand-rolled with_* conveniences the registry knows are real
208
+ # (NavigationMenu#with_link) - valid calls with no slot entry to
209
+ # validate against.
210
+ def slot_extra?(owner, name)
211
+ slot_extras_of(owner).include?(name)
212
+ end
213
+
214
+ # Every name valid after with_ on this owner (many-slots accept both
215
+ # the plural and the singular setter; polymorphic slots accept one
216
+ # setter per type).
217
+ def slot_call_names(owner)
218
+ slots_of(owner).flat_map do |slot|
219
+ names = slot["many"] ? [slot["name"], slot["name"].delete_suffix("s")] : [slot["name"]]
220
+ names + (slot["types"] || [])
221
+ end + slot_extras_of(owner)
222
+ end
223
+
224
+ # The declared value contract of a wrapper helper's option (from the
225
+ # registry "helpers" section), or nil.
226
+ def helper_option_entry(helper, key)
227
+ (@helper_entries.dig(helper, "options") || []).find { |option| option["name"] == key }
228
+ end
229
+
230
+ # The component's requires_content hint (Avatar: "the initials
231
+ # fallback"), or nil when content is optional.
232
+ def requires_content(path)
233
+ @components.dig(path, "requires_content")
234
+ end
235
+
236
+ # Setters an owner's call cannot omit (Menubar's menu without
237
+ # with_trigger raises at render) - setter
238
+ # name => hint, at every depth the slot queries work at.
239
+ def required_slots(owner)
240
+ (owner.is_a?(Hash) ? owner["required_slots"] : @components.dig(owner, "required_slots")) || {}
241
+ end
242
+
243
+ # The conditional any-of contracts: groups of alternatives
244
+ # (content / slots / options) of which a call must satisfy at least
245
+ # one - Button's visible-content rule, Command's accessible name.
246
+ def requires_any(path)
247
+ @components.dig(path, "requires_any") || []
248
+ end
249
+
250
+ # The setter names that satisfy one required-slot key: the slot's
251
+ # own name, a collection's singular, and every polymorphic type
252
+ # ("at least one item" is satisfied by any member of the union -
253
+ # exactly the runtime items? predicate).
254
+ def satisfying_setters(owner, key)
255
+ slot = slot_entry(owner, key)
256
+ return [key] unless slot
257
+
258
+ names = [slot["name"]]
259
+ names << slot["name"].delete_suffix("s") if slot["many"]
260
+ names + (slot["types"] || [])
261
+ end
262
+
263
+ # Class tokens painting a color outside the tokens: palette
264
+ # utilities and arbitrary literals, variant prefixes included.
265
+ def raw_colors(class_string)
266
+ class_string.split.select { |token| token.match?(RAW_COLOR) || token.match?(ARBITRARY_COLOR) }
267
+ end
268
+
269
+ # Class tokens carrying the important modifier.
270
+ def important_tokens(class_string)
271
+ class_string.split.grep(IMPORTANT_MODIFIER)
272
+ end
273
+
274
+ # Color literals in an inline style string.
275
+ def style_colors(style_string)
276
+ style_string.scan(STYLE_COLOR)
277
+ end
278
+ end
279
+
280
+ # Lints one ERB source string. Rules split cleanly: the Ruby-call rules
281
+ # (component/option/variant) walk Prism ASTs of the ERB chunks; the
282
+ # markup rules (wiring/color) walk herb's HTML attribute nodes.
283
+ #
284
+ # @api private
285
+ class Linter
286
+ ACTION_TOKEN = /(?:[\w.:@-]+->)?(?<identifier>poetry--[\w-]+)#(?<method>\w+)/
287
+ # Chrome's guidance for a registered tool name is 30 characters;
288
+ # the composed poetry.{instance}.{tool} name is what an agent reads.
289
+ WEBMCP_NAME_BUDGET = 30
290
+ # A webmcp: instance name (snake_case, at most 64 characters).
291
+ WEBMCP_NAME = /\A[a-z][a-z0-9_-]{0,63}\z/
292
+ POETRY_PREFIX = Stimulus::Manifest::POETRY_PREFIX
293
+ # Elements with no interactive semantics of their own: a click
294
+ # handler or role=button on one of these is a button in costume.
295
+ INERT_TAGS = %w[div span p li tr td th section article header footer nav main aside figure img svg].freeze
296
+ # A literal `nil` argument, distinct from "no literal to check"
297
+ # (dynamic values return plain nil and are left alone).
298
+ NIL_LITERAL = Object.new.tap { |sentinel| sentinel.define_singleton_method(:inspect) { "nil" } }.freeze
299
+
300
+ def initialize(catalog)
301
+ @catalog = catalog
302
+ end
303
+
304
+ def lint(source)
305
+ require "herb"
306
+ require "prism"
307
+ # Every block-param binding this lint opens, in document order -
308
+ # each accumulates the setters actually called on it so the
309
+ # required-slot accounting can run once the whole template has
310
+ # been walked (a with_trigger in the last chunk satisfies a
311
+ # requirement opened in the first). Reset per lint: the Runner
312
+ # reuses one Linter across files.
313
+ @instances = []
314
+ @webmcp_names = {}
315
+ document = Herb.parse(source)
316
+ findings = document.errors.map do |error|
317
+ Finding.new(rule: "parse-error", severity: :error, message: error.message,
318
+ line: error.location&.start&.line)
319
+ end
320
+ # Block-param name -> instance (owner + accounting), accumulated in
321
+ # document order (the `do |alert|` chunk precedes the
322
+ # `alert.with_icon` chunk), so slot calls resolve across ERB tag
323
+ # boundaries.
324
+ bindings = {}
325
+ walk(document.value) do |node|
326
+ findings.concat(ruby_findings(node, bindings)) if erb?(node)
327
+ findings.concat(attribute_findings(node)) if node.is_a?(Herb::AST::HTMLAttributeNode)
328
+ findings.concat(form_autosubmit_findings(node)) if open_tag?(node, "form")
329
+ findings.concat(fake_button_findings(node)) if INERT_TAGS.include?(open_tag_name(node))
330
+ end
331
+ findings.concat(missing_slot_findings)
332
+ findings.sort_by { |finding| [finding.line || 0, finding.rule] }
333
+ end
334
+
335
+ private
336
+
337
+ def erb?(node)
338
+ node.respond_to?(:content) && node.content.respond_to?(:value) &&
339
+ node.class.name.include?("ERB") && !erb_comment?(node)
340
+ end
341
+
342
+ # <%# ... %> parses as an ERBContentNode like any output tag - only
343
+ # the tag opening tells prose from code. Comment text mentioning a
344
+ # helper ("a plain poetry_input chromes it") must never reach Prism
345
+ # as Ruby (the helper-arity false-positive class).
346
+ def erb_comment?(node)
347
+ node.respond_to?(:tag_opening) && node.tag_opening&.value == "<%#"
348
+ end
349
+
350
+ # --- Ruby-call rules (component / option / variant / value / slot) ---
351
+
352
+ def ruby_findings(node, bindings)
353
+ base_line = node.location.start.line
354
+ parsed = Prism.parse(node.content.value)
355
+ calls = []
356
+ collect_calls(parsed.value, calls)
357
+ content_fed = content_fed_calls(parsed.value)
358
+ findings = calls.flat_map { |call| call_findings(call, base_line, bindings, content_fed) }
359
+ findings += slot_findings(parsed.value, bindings, base_line)
360
+ mark_escaped_bindings(parsed.value, bindings)
361
+ findings
362
+ end
363
+
364
+ # Receivers of a chained .with_content("...") - content arrives
365
+ # without a block, so the missing-content-block rule stands down.
366
+ def content_fed_calls(node, into = Set.new)
367
+ return into unless node
368
+
369
+ into << node.receiver if node.is_a?(Prism::CallNode) && node.name == :with_content && node.receiver
370
+ if node.respond_to?(:compact_child_nodes)
371
+ node.compact_child_nodes.each do |child|
372
+ content_fed_calls(child, into)
373
+ end
374
+ end
375
+ into
376
+ end
377
+
378
+ def collect_calls(node, into)
379
+ return unless node
380
+
381
+ into << node if node.is_a?(Prism::CallNode) && node.name.to_s.start_with?("poetry_")
382
+ node.compact_child_nodes.each { |child| collect_calls(child, into) } if node.respond_to?(:compact_child_nodes)
383
+ end
384
+
385
+ def call_findings(call, base_line, bindings, content_fed = Set.new)
386
+ helper = call.name.to_s
387
+ line = base_line + (call.location.start_line - 1)
388
+
389
+ unless @catalog.helper?(helper)
390
+ suggestion = suggest(helper, @catalog.helper_names)
391
+ return [Finding.new(rule: "unknown-component", severity: :error,
392
+ message: "no poetry component #{helper}", line: line, suggestion: suggestion)]
393
+ end
394
+
395
+ # A valid helper with no component mapping (group / provider / item
396
+ # wrapper): its registry-declared value contracts still check, and
397
+ # NO wrapper yields anything to its block (a roster invariant) - a
398
+ # declared block param will be nil at render
399
+ # (e.g. `poetry_sidebar_group do |group|`).
400
+ path = @catalog.path_for(helper)
401
+ unless path
402
+ return helper_findings(helper, call, base_line) + yieldless_findings(helper, call, line) +
403
+ helper_arity_findings(helper, call, line) + webmcp_form_findings(helper, call, line) +
404
+ passthrough_findings(keyword_pairs(call), base_line) + data_findings(call, base_line)
405
+ end
406
+
407
+ record_binding(call, path, bindings, line)
408
+ pairs = keyword_pairs(call)
409
+ findings = pairs.flat_map do |key, value, kw_line|
410
+ option_findings(path, key, value, base_line + kw_line - 1)
411
+ end
412
+ findings + webmcp_findings(path, pairs, line) +
413
+ missing_option_findings(path, helper_of(path), call, pairs, line) +
414
+ helper_arity_findings(helper, call, line) +
415
+ content_findings(path, helper, call, line, content_fed) +
416
+ blockless_slot_findings(path, helper, call, pairs, line) +
417
+ requires_any_findings(path, call, pairs, line, content_fed) +
418
+ passthrough_findings(pairs, base_line) + data_findings(call, base_line)
419
+ end
420
+
421
+ # The requires_content tier: a component that raises without a
422
+ # content block, called with none.
423
+ # Positional arguments or a chained .with_content may carry content
424
+ # invisibly - such calls are left alone.
425
+ def content_findings(path, helper, call, line, content_fed)
426
+ hint = @catalog.requires_content(path)
427
+ return [] unless hint
428
+ return [] if call.block || content_fed.include?(call)
429
+
430
+ positionals = positional_arguments(call)
431
+ return [] if positionals.nil? || positionals.any?
432
+
433
+ [Finding.new(rule: "missing-content-block", severity: :error,
434
+ message: "#{helper} requires a content block (#{hint})", line: line)]
435
+ end
436
+
437
+ # The helper-arity rule (e.g. `poetry_link "text", href:` on a
438
+ # kwargs-only helper - an ArgumentError at render). Enforced
439
+ # only where the registry states an arity - introspected from the
440
+ # helper module's real signatures, never assumed - and only on
441
+ # receiverless calls (a form builder's poetry_select owns its own
442
+ # signature).
443
+ def helper_arity_findings(helper, call, line)
444
+ return [] if call.receiver
445
+
446
+ allowed = @catalog.helper_args(helper)
447
+ return [] unless allowed
448
+
449
+ positionals = positional_arguments(call)
450
+ return [] if positionals.nil? || positionals.size <= allowed
451
+
452
+ limit = if allowed.zero?
453
+ "no positional arguments - options are keywords, content is the block"
454
+ else
455
+ "at most #{allowed} positional argument#{"s" if allowed > 1}"
456
+ end
457
+ [Finding.new(rule: "helper-arity", severity: :error,
458
+ message: "#{helper} takes #{limit}", line: line)]
459
+ end
460
+
461
+ def yieldless_findings(helper, call, line)
462
+ return [] if @catalog.helper_yields?(helper)
463
+
464
+ block = call.block
465
+ return [] unless block.respond_to?(:parameters) && block.parameters
466
+
467
+ [Finding.new(rule: "yieldless-block", severity: :error,
468
+ message: "#{helper} yields nothing to its block - the param will be nil; " \
469
+ "remove it and write the content directly", line: line)]
470
+ end
471
+
472
+ def option_findings(path, key, value, line)
473
+ findings = []
474
+ known = @catalog.option_names(path)
475
+ entry = @catalog.option_entry(path, key)
476
+
477
+ unless known.include?(key) || Catalog::PASSTHROUGH.include?(key)
478
+ suggestion = suggest(key, known)
479
+ # No suggestion => an intentional pass-through html attribute, not a typo.
480
+ if suggestion
481
+ findings << Finding.new(rule: "unknown-option", severity: :warning,
482
+ message: "#{helper_of(path)} has no option #{key}", line: line,
483
+ suggestion: suggestion)
484
+ end
485
+ end
486
+
487
+ findings + (entry ? value_findings(entry, helper_of(path), key, value, line) : [])
488
+ end
489
+
490
+ # The value-contract tier: the call shapes that raise or misrender
491
+ # at render time - enumerated values on any declared attribute, literal nil
492
+ # against required options, and icon-name format/membership.
493
+ def value_findings(entry, owner, key, value, line)
494
+ if value.equal?(NIL_LITERAL)
495
+ return [] unless entry["required"]
496
+
497
+ return [Finding.new(rule: "missing-option", severity: :error,
498
+ message: "#{key}: is required on #{owner} and cannot be nil", line: line)]
499
+ end
500
+ return [] unless value
501
+
502
+ variants = entry["variants"]
503
+ if variants && !variants.include?(value)
504
+ return [Finding.new(rule: "unknown-variant", severity: :error,
505
+ message: "#{key}: #{value.inspect} is not a #{owner} #{key} " \
506
+ "(#{variants.join(", ")})",
507
+ line: line, suggestion: suggest(value, variants))]
508
+ end
509
+
510
+ entry["format"] == "icon-name" ? icon_findings(key, value, line) : []
511
+ end
512
+
513
+ # Icon names are kebab-case names from the active icon set. Shape is
514
+ # always checkable (a snake_case `:folder_plus` never renders); membership only when
515
+ # the catalog carries the set's names.
516
+ def icon_findings(key, value, line)
517
+ unless value.match?(Icons::FileSet::NAME_FORMAT)
518
+ kebab = value.tr("_", "-")
519
+ suggestion = kebab if kebab.match?(Icons::FileSet::NAME_FORMAT) &&
520
+ (@catalog.icon_names.nil? || @catalog.icon_names.include?(kebab))
521
+ return [Finding.new(rule: "unknown-icon", severity: :error,
522
+ message: "#{key}: #{value.inspect} is not an icon name " \
523
+ "(icon names are kebab-case, like :\"circle-alert\")",
524
+ line: line, suggestion: suggestion)]
525
+ end
526
+ return [] if @catalog.icon_names.nil? || @catalog.icon_names.include?(value)
527
+
528
+ # Icons.suggest, not the generic checker: it catches the Lucide v1
529
+ # reversed-compound renames (alert-circle -> circle-alert) that
530
+ # edit distance misses every time.
531
+ [Finding.new(rule: "unknown-icon", severity: :error,
532
+ message: "#{key}: #{value.inspect} is not in the icon set", line: line,
533
+ suggestion: Icons.suggest(value, @catalog.icon_names))]
534
+ end
535
+
536
+ # Required-with-no-default options that a literal call omits (a
537
+ # **splat may carry anything - such calls are left alone).
538
+ def missing_option_findings(path, owner, call, pairs, line)
539
+ # A receiver'd call owns its contract (a form builder's
540
+ # poetry_date_picker supplies name: itself) - same stand-down as
541
+ # helper arity.
542
+ return [] if splatted?(call) || call.receiver
543
+
544
+ (@catalog.required_options(path) - pairs.map(&:first)).map do |key|
545
+ Finding.new(rule: "missing-option", severity: :error,
546
+ message: "#{key}: is required on #{owner}", line: line)
547
+ end
548
+ end
549
+
550
+ # A wrapper helper (no component mapping) with registry-declared
551
+ # value contracts: poetry_input_group_addon(align: :leading) fails
552
+ # here the way it fails at render - minus the render.
553
+ def helper_findings(helper, call, base_line)
554
+ keyword_pairs(call).flat_map do |key, value, kw_line|
555
+ entry = @catalog.helper_option_entry(helper, key)
556
+ entry ? value_findings(entry, helper, key, value, base_line + kw_line - 1) : []
557
+ end
558
+ end
559
+
560
+ # --- slot rules (typed slots are prop calls, not blocks) ---
561
+
562
+ # A poetry_* call opening a block binds its param name to the
563
+ # component (poetry_alert do |alert| -> alert), so later chunks can
564
+ # validate alert.with_icon(...) against the alert slot surface.
565
+ # Bindings map param name -> instance: owner is a component path or
566
+ # a nested builder surface, label names the owner in
567
+ # messages, and called/escaped carry the required-slot accounting.
568
+ def record_binding(call, path, bindings, line)
569
+ name = block_param_name(call)
570
+ bindings[name] = track_instance(owner: path, label: helper_of(path), line: line) if name
571
+ end
572
+
573
+ def track_instance(owner:, label:, line:)
574
+ instance = { owner: owner, label: label, line: line, called: Set.new, escaped: false }
575
+ @instances << instance
576
+ instance
577
+ end
578
+
579
+ def block_param_name(call)
580
+ block_parameters = call.block&.parameters&.parameters
581
+ name = block_parameters && block_parameters.requireds.first&.name
582
+ name&.to_s
583
+ rescue StandardError
584
+ nil # a block shape Prism could not recover fully never blocks linting
585
+ end
586
+
587
+ def slot_findings(root, bindings, base_line)
588
+ slot_calls = []
589
+ collect_slot_calls(root, slot_calls)
590
+ slot_calls.flat_map do |call|
591
+ instance = bindings[receiver_name(call)]
592
+ instance ? slot_call_findings(call, instance, base_line, bindings) : []
593
+ end
594
+ end
595
+
596
+ def collect_slot_calls(node, into)
597
+ return unless node
598
+
599
+ into << node if node.is_a?(Prism::CallNode) && node.name.to_s.start_with?("with_") && node.receiver
600
+ return unless node.respond_to?(:compact_child_nodes)
601
+
602
+ node.compact_child_nodes.each do |child|
603
+ collect_slot_calls(child, into)
604
+ end
605
+ end
606
+
607
+ # Inside one ERB chunk the receiver parses as a local variable; in a
608
+ # later chunk the same name parses as a bare method call - both are
609
+ # the block param the bindings map knows.
610
+ def receiver_name(call)
611
+ receiver = call.receiver
612
+ case receiver
613
+ when Prism::LocalVariableReadNode then receiver.name.to_s
614
+ when Prism::CallNode then receiver.name.to_s if receiver.receiver.nil? && receiver.arguments.nil?
615
+ end
616
+ end
617
+
618
+ def slot_call_findings(call, instance, base_line, bindings)
619
+ owner = instance[:owner]
620
+ label = instance[:label]
621
+ slot_name = call.name.to_s.delete_prefix("with_")
622
+ instance[:called] << slot_name
623
+ line = base_line + (call.location.start_line - 1)
624
+ entry = @catalog.slot_entry(owner, slot_name)
625
+ unless entry
626
+ return [] if @catalog.slot_extra?(owner, slot_name)
627
+
628
+ return [Finding.new(rule: "unknown-slot", severity: :error,
629
+ message: "#{label} has no slot #{slot_name}", line: line,
630
+ suggestion: suggest(slot_name, @catalog.slot_call_names(owner)))]
631
+ end
632
+
633
+ # A setter opening a block over a declared builder binds the
634
+ # nested surface (menubar.with_menu do |menu| - menu's own items
635
+ # then lint at this same depth).
636
+ if (surface = entry.dig("builders", slot_name)) && (param = block_param_name(call))
637
+ bindings[param] = track_instance(owner: surface, label: "with_#{slot_name}", line: line)
638
+ end
639
+
640
+ findings = arity_findings(entry, slot_name, call, line) +
641
+ setter_block_findings(entry, slot_name, call, line) +
642
+ setter_keyword_findings(entry, slot_name, call, base_line) +
643
+ passthrough_findings(keyword_pairs(call), base_line) + data_findings(call, base_line)
644
+ component = entry["component"]
645
+ return findings unless component
646
+
647
+ # A typed slot IS a component call: same option/value rules, plus
648
+ # the block-form trap (with_icon do ... end builds the component
649
+ # with no props at all).
650
+ pairs = keyword_pairs(call)
651
+ findings += pairs.flat_map do |key, value, kw_line|
652
+ option_findings(component, key, value, base_line + kw_line - 1)
653
+ end
654
+ unless splatted?(call)
655
+ findings += (@catalog.required_options(component) - pairs.map(&:first)).map do |key|
656
+ Finding.new(rule: "missing-option", severity: :error,
657
+ message: "#{slot_name} is a typed slot rendering #{helper_of(component)} - pass " \
658
+ "#{key}: (with_#{slot_name}(#{key}: ...)), not a block", line: line)
659
+ end
660
+ end
661
+ if (hint = @catalog.requires_content(component)) && call.block.nil?
662
+ findings << Finding.new(rule: "missing-content-block", severity: :error,
663
+ message: "with_#{slot_name} renders #{helper_of(component)}, which " \
664
+ "requires a content block (#{hint})", line: line)
665
+ end
666
+ # The any-of contracts ride the component fact too
667
+ # (e.g. with_action(label:) - a Button through a
668
+ # forwarding lambda, nothing visible). At a slot site the block
669
+ # IS the content; sub-slot alternatives are unreachable and drop
670
+ # out of the satisfiable set.
671
+ unless splatted?(call) || positional_arguments(call).nil? || positional_arguments(call).any?
672
+ keys = pairs.map(&:first)
673
+ @catalog.requires_any(component).each do |group|
674
+ next if call.block && group["content"]
675
+ next if (group["options"] || []).intersect?(keys)
676
+
677
+ # Sub-slot alternatives are unreachable through a slot call -
678
+ # the phrase names only what THIS site can still do.
679
+ reachable = group.slice("hint", "content", "options")
680
+ reachable = group unless reachable["content"] || reachable["options"]
681
+ findings << Finding.new(rule: "requires-any", severity: :error,
682
+ message: "with_#{slot_name} renders #{helper_of(component)}, which " \
683
+ "requires #{any_of_phrase(reachable)}", line: line)
684
+ end
685
+ end
686
+ findings
687
+ end
688
+
689
+ # --- the required-slot tier (setters a call cannot omit) ---
690
+
691
+ # A required-slot component called with NO block cannot set the
692
+ # slot at all (the setters live on the block param) - the blockless
693
+ # sibling of the bound-block accounting below. Positional arguments
694
+ # or a same-named keyword may carry the requirement invisibly, and
695
+ # a receiver'd call (a form builder's poetry_select) owns its own
696
+ # contract - all of those stand down.
697
+ def blockless_slot_findings(path, helper, call, pairs, line)
698
+ return [] if call.block || call.receiver
699
+
700
+ required = @catalog.required_slots(path)
701
+ return [] if required.empty?
702
+
703
+ positionals = positional_arguments(call)
704
+ return [] if positionals.nil? || positionals.any? || splatted?(call)
705
+
706
+ keys = pairs.map(&:first)
707
+ required.filter_map do |key, hint|
708
+ next if keys.include?(key)
709
+
710
+ Finding.new(rule: "missing-slot", severity: :error,
711
+ message: "#{helper} requires with_#{key} (#{hint}) - open a block: " \
712
+ "#{helper}(...) do |c| ... c.with_#{key} ... end", line: line)
713
+ end
714
+ end
715
+
716
+ # The conditional any-of tier: the contracts no single-fact rule
717
+ # can state. A group passes when ANY listed
718
+ # alternative is satisfied or possibly satisfied: a block counts
719
+ # for content AND for slot alternatives (the setters may be called
720
+ # inside), a listed option key counts by presence (a literal-false
721
+ # loading: is the runtime's to catch), and receiver'd calls,
722
+ # splats, and positionals stand the whole rule down.
723
+ def requires_any_findings(path, call, pairs, line, content_fed)
724
+ groups = @catalog.requires_any(path)
725
+ return [] if groups.empty? || call.receiver || splatted?(call)
726
+
727
+ positionals = positional_arguments(call)
728
+ return [] if positionals.nil? || positionals.any?
729
+
730
+ keys = pairs.map(&:first)
731
+ groups.filter_map do |group|
732
+ next if call.block && (group["content"] || (group["slots"] || []).any?)
733
+ next if content_fed.include?(call) && group["content"]
734
+ next if (group["options"] || []).intersect?(keys)
735
+
736
+ Finding.new(rule: "requires-any", severity: :error,
737
+ message: "#{helper_of(path)} requires #{any_of_phrase(group)}", line: line)
738
+ end
739
+ end
740
+
741
+ def any_of_phrase(group)
742
+ parts = []
743
+ parts << "a content block" if group["content"]
744
+ parts.concat((group["slots"] || []).map { |name| "with_#{name}" })
745
+ parts.concat((group["options"] || []).map { |key| "#{key}:" })
746
+ "one of #{parts.join(" / ")} (#{group["hint"]})"
747
+ end
748
+
749
+ # A bound block param that travels anywhere except a with_* receiver
750
+ # position (a partial's locals, a helper argument, a non-setter
751
+ # method) may set slots where this lint cannot see - required-slot
752
+ # accounting stands down for that instance rather than guess.
753
+ def mark_escaped_bindings(root, bindings)
754
+ return if bindings.empty?
755
+
756
+ receivers = Set.new
757
+ walk_prism(root) do |node|
758
+ next unless node.is_a?(Prism::CallNode) && node.name.to_s.start_with?("with_")
759
+
760
+ receivers << node.receiver.object_id if node.receiver
761
+ end
762
+ walk_prism(root) do |node|
763
+ name = variable_read_name(node)
764
+ next unless name && (instance = bindings[name])
765
+
766
+ instance[:escaped] = true unless receivers.include?(node.object_id)
767
+ end
768
+ end
769
+
770
+ # A read of a bound name: a local variable in the binding chunk, a
771
+ # bare argless call in later chunks (the receiver_name duality).
772
+ def variable_read_name(node)
773
+ case node
774
+ when Prism::LocalVariableReadNode then node.name.to_s
775
+ when Prism::CallNode
776
+ node.name.to_s if node.receiver.nil? && node.arguments.nil? && node.block.nil?
777
+ end
778
+ end
779
+
780
+ # The end-of-template accounting: every non-escaped binding must
781
+ # have called each required setter of its owner (satisfied by the
782
+ # slot's own setter, a collection's singular, or any polymorphic
783
+ # type - the runtime predicate, textually). A call can pass every
784
+ # other rule truthfully and still fail on exactly this omission.
785
+ def missing_slot_findings
786
+ @instances.flat_map do |instance|
787
+ next [] if instance[:escaped]
788
+
789
+ @catalog.required_slots(instance[:owner]).filter_map do |key, hint|
790
+ next if @catalog.satisfying_setters(instance[:owner], key).intersect?(instance[:called].to_a)
791
+
792
+ Finding.new(rule: "missing-slot", severity: :error,
793
+ message: "#{instance[:label]} requires with_#{key} (#{hint})",
794
+ line: instance[:line])
795
+ end
796
+ end
797
+ end
798
+
799
+ def walk_prism(node, &block)
800
+ return unless node
801
+
802
+ yield node
803
+ return unless node.respond_to?(:compact_child_nodes)
804
+
805
+ node.compact_child_nodes.each { |child| walk_prism(child, &block) }
806
+ end
807
+
808
+ # The block seam of a setter, both directions. A yieldless
809
+ # setter (its lambda consumes the block as content) yields nothing -
810
+ # a declared block param is nil at render (e.g.
811
+ # `menu.with_item do |item|`). A required_content setter crashes
812
+ # WITHOUT a block (Carousel with_item).
813
+ def setter_block_findings(entry, slot_name, call, line)
814
+ findings = []
815
+ if (entry["yieldless"] || []).include?(slot_name) && block_param_name(call)
816
+ findings << Finding.new(rule: "yieldless-block", severity: :error,
817
+ message: "with_#{slot_name} yields nothing to its block - the param " \
818
+ "will be nil; remove it and write the content directly",
819
+ line: line)
820
+ end
821
+ if (hint = entry.dig("required_content", slot_name)) && call.block.nil?
822
+ findings << Finding.new(rule: "missing-content-block", severity: :error,
823
+ message: "with_#{slot_name} requires a content block (#{hint})",
824
+ line: line)
825
+ end
826
+ findings
827
+ end
828
+
829
+ # Keywords a closed-signature setter does not accept
830
+ # (e.g. `with_item(class:)` against
831
+ # `|classes: nil, &block|` - an ArgumentError at render, and NOT a
832
+ # pass-through surface, unlike component options).
833
+ def setter_keyword_findings(entry, slot_name, call, base_line)
834
+ allowed = entry.dig("setter_kwargs", slot_name)
835
+ return [] unless allowed
836
+
837
+ takes = "takes #{allowed.map { |name| "#{name}:" }.join(", ")}"
838
+ keyword_pairs(call).filter_map do |key, _value, kw_line|
839
+ next if allowed.include?(key)
840
+
841
+ Finding.new(rule: "slot-keyword", severity: :error,
842
+ message: "with_#{slot_name} does not take #{key}: (#{takes})",
843
+ line: base_line + kw_line - 1, suggestion: suggest(key, allowed))
844
+ end
845
+ end
846
+
847
+ # Positional args a setter does not take (e.g.
848
+ # `with_item(:item, ...)` - a type-as-argument dispatch no
849
+ # setter has; the type IS the setter). Arity comes from the
850
+ # registry's introspected lambda signatures; anything unknowable
851
+ # (splats, untracked setters) is left alone.
852
+ def arity_findings(entry, slot_name, call, line)
853
+ max = entry.dig("setter_args", slot_name)
854
+ return [] unless max
855
+
856
+ positionals = positional_arguments(call)
857
+ return [] if positionals.nil? || positionals.size <= max
858
+
859
+ first = positionals.first
860
+ first_symbol = first.unescaped if first.is_a?(Prism::SymbolNode)
861
+ sibling = first_symbol && first_symbol != slot_name &&
862
+ (entry["setter_args"].key?(first_symbol) || (entry["types"] || []).include?(first_symbol))
863
+ detail = if first_symbol && (entry["types"] || []).include?(first_symbol)
864
+ " - the type is the setter (#{(entry["types"] || []).map { |type| "with_#{type}" }.join(" / ")})"
865
+ else
866
+ ""
867
+ end
868
+ limit = max.zero? ? "keyword options only" : "at most #{max} positional argument#{"s" if max > 1}"
869
+ [Finding.new(rule: "slot-arity", severity: :error,
870
+ message: "with_#{slot_name} takes #{limit}#{detail}", line: line,
871
+ suggestion: (sibling ? "with_#{first_symbol}" : nil))]
872
+ end
873
+
874
+ # nil when a splat makes the count unknowable.
875
+ def positional_arguments(call)
876
+ arguments = call.arguments&.arguments
877
+ return [] unless arguments
878
+ return nil if arguments.any?(Prism::SplatNode)
879
+
880
+ arguments.reject do |argument|
881
+ argument.is_a?(Prism::KeywordHashNode) || argument.is_a?(Prism::BlockArgumentNode)
882
+ end
883
+ end
884
+
885
+ # [key_string, literal_value_or_nil, line] per keyword argument.
886
+ def keyword_pairs(call)
887
+ hash = call.arguments&.arguments&.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
888
+ return [] unless hash
889
+
890
+ hash.elements.filter_map do |element|
891
+ next unless element.is_a?(Prism::AssocNode) && element.key.respond_to?(:unescaped)
892
+
893
+ [element.key.unescaped, literal_value(element.value), element.key.location.start_line]
894
+ end
895
+ end
896
+
897
+ # A call carrying **splat (or a bare hash variable) may set anything
898
+ # statically invisible - required-option checks stand down.
899
+ def splatted?(call)
900
+ arguments = call.arguments&.arguments
901
+ return false unless arguments
902
+
903
+ arguments.any? do |argument|
904
+ argument.is_a?(Prism::KeywordHashNode) &&
905
+ argument.elements.any? { |element| !element.is_a?(Prism::AssocNode) }
906
+ end
907
+ end
908
+
909
+ # Simple literal values only (symbols/strings, plus literal nil as
910
+ # its own sentinel) - dynamic values are unknowable statically and
911
+ # are left alone.
912
+ def literal_value(node)
913
+ return NIL_LITERAL if node.is_a?(Prism::NilNode)
914
+
915
+ node.unescaped if node.is_a?(Prism::SymbolNode) || node.is_a?(Prism::StringNode)
916
+ end
917
+
918
+ # --- Markup rules (Stimulus wiring / the style exits) ---
919
+
920
+ def attribute_findings(node)
921
+ name = attribute_name(node)
922
+ value = attribute_value(node)
923
+ return [] unless name && value
924
+
925
+ case name
926
+ when "class" then class_findings(value, line_of(node))
927
+ when "style" then style_findings(value, line_of(node))
928
+ else stimulus_findings(name, value, line_of(node))
929
+ end
930
+ end
931
+
932
+ # The Stimulus wiring rules on one rendered attribute, whether it
933
+ # was hand-written in the markup or arrives through a `data:` hash.
934
+ def stimulus_findings(name, value, line)
935
+ case name
936
+ when "data-controller" then controller_findings(value, line)
937
+ when "data-action" then action_findings(value, line)
938
+ when /\Adata-(poetry--[\w-]+)-target\z/ then target_findings(Regexp.last_match(1), value, line)
939
+ else value_attribute_findings(name, value, line)
940
+ end
941
+ end
942
+
943
+ # `poetry_button(data: { action: "click->poetry--core--sheet#close" })`
944
+ # and `card.with_footer(data: { ... })` render the same data-*
945
+ # attributes a template could hand-write, and were the one place a
946
+ # typo reached the browser unchecked (an editor cannot see inside a
947
+ # helper's keywords either). Rails dasherizes every underscore in a
948
+ # data key (`poetry__core__x_target:` and `"poetry--core--x-target":`
949
+ # both render data-poetry--core--x-target), so the rendered name is
950
+ # what gets checked. Dynamic values are left alone.
951
+ def data_findings(call, base_line)
952
+ hash = hash_argument(call, "data")
953
+ return [] unless hash
954
+
955
+ hash_pairs(hash).flat_map do |key, node|
956
+ value = literal_value(node)
957
+ next [] unless value.is_a?(String)
958
+
959
+ stimulus_findings("data-#{key.tr("_", "-")}", value, base_line + node.location.start_line - 1)
960
+ end
961
+ end
962
+
963
+ def controller_findings(value, line)
964
+ value.split.filter_map do |identifier|
965
+ next unless identifier.start_with?(POETRY_PREFIX)
966
+ next if definition(identifier)
967
+
968
+ Finding.new(rule: "unknown-controller", severity: :error,
969
+ message: "no poetry controller #{identifier}", line: line,
970
+ suggestion: suggest(identifier, Stimulus::Manifest.catalog.keys))
971
+ end
972
+ end
973
+
974
+ def action_findings(value, line)
975
+ value.scan(ACTION_TOKEN).filter_map do
976
+ identifier = Regexp.last_match(:identifier)
977
+ method = Regexp.last_match(:method)
978
+ definition = definition(identifier)
979
+ next unless definition
980
+ next if definition["methods"].include?(method)
981
+
982
+ Finding.new(rule: "unknown-action", severity: :error,
983
+ message: "#{identifier} has no action ##{method}", line: line,
984
+ suggestion: suggest(method, definition["methods"] - %w[connect disconnect]))
985
+ end
986
+ end
987
+
988
+ def target_findings(identifier, value, line)
989
+ definition = definition(identifier)
990
+ return [] unless definition
991
+ return [] if definition["targets"].include?(value)
992
+
993
+ [Finding.new(rule: "unknown-target", severity: :error,
994
+ message: "#{identifier} has no target #{value.inspect}", line: line,
995
+ suggestion: suggest(value, definition["targets"]))]
996
+ end
997
+
998
+ # --- Stimulus values (the manifest's typed value contract) ---
999
+
1000
+ # `data-<identifier>-<name>-value`: the name must be one the
1001
+ # controller declares (Stimulus dasherizes the JS name:
1002
+ # weekStart -> week-start), and a literal must parse as the
1003
+ # declared type - Stimulus reads a Number through Number() (NaN
1004
+ # for anything else), Array/Object through JSON.parse, and a
1005
+ # Boolean as anything but "false"/"0" (so a misspelt "flase" is
1006
+ # silently TRUE). The identifier is the longest manifest identifier
1007
+ # the attribute starts with; an unknown one is data-controller's
1008
+ # finding, not this rule's. Classes and outlets: no poetry
1009
+ # controller declares any, so there is nothing to check yet.
1010
+ def value_attribute_findings(name, value, line)
1011
+ return [] unless name.start_with?("data-#{POETRY_PREFIX}") && name.end_with?("-value")
1012
+
1013
+ identifier = Stimulus::Manifest.catalog.keys.select { |id| name.start_with?("data-#{id}-") }.max_by(&:length)
1014
+ definition = identifier && definition(identifier)
1015
+ return [] unless definition
1016
+
1017
+ value_name = name.delete_prefix("data-#{identifier}-").delete_suffix("-value")
1018
+ declared = (definition["values"] || {}).to_h { |key, spec| [dasherize_value(key), spec] }
1019
+ spec = declared[value_name]
1020
+ unless spec
1021
+ return [Finding.new(rule: "unknown-value", severity: :error,
1022
+ message: "#{identifier} has no value #{value_name.inspect}", line: line,
1023
+ suggestion: suggest(value_name, declared.keys))]
1024
+ end
1025
+
1026
+ value_type_findings(identifier, value_name, spec["type"], value, line)
1027
+ end
1028
+
1029
+ def value_type_findings(identifier, value_name, type, value, line)
1030
+ problem = case type
1031
+ when "Boolean"
1032
+ %w[true false 1 0].include?(value) ? nil : "write \"true\" or \"false\""
1033
+ when "Number"
1034
+ Float(value, exception: false) ? nil : "#{value.inspect} is not a number"
1035
+ when "Array"
1036
+ json_literal(value).is_a?(Array) ? nil : "#{value.inspect} is not a JSON array"
1037
+ when "Object"
1038
+ json_literal(value).is_a?(Hash) ? nil : "#{value.inspect} is not a JSON object"
1039
+ end
1040
+ return [] unless problem
1041
+
1042
+ [Finding.new(rule: "value-type", severity: :error,
1043
+ message: "#{identifier} value #{value_name} is #{type} - #{problem}", line: line)]
1044
+ end
1045
+
1046
+ # Stimulus's own attribute spelling of a JS value name.
1047
+ def dasherize_value(key)
1048
+ key.to_s.gsub(/([A-Z])/) { "-#{Regexp.last_match(1).downcase}" }
1049
+ end
1050
+
1051
+ def json_literal(value)
1052
+ JSON.parse(value)
1053
+ rescue JSON::ParserError
1054
+ nil
1055
+ end
1056
+
1057
+ # A literal class string wherever it appears (a class="" attribute,
1058
+ # a class: keyword on a component, wrapper, or slot call): color
1059
+ # utilities off the tokens, and the important modifier forcing a
1060
+ # cascade the class merge already resolves.
1061
+ def class_findings(value, line)
1062
+ colors = @catalog.raw_colors(value).uniq.map do |token|
1063
+ Finding.new(rule: "raw-color", severity: :warning,
1064
+ message: "raw color class #{token.inspect} - use a semantic token " \
1065
+ "(bg-primary, text-destructive, ...) or add one to the theme", line: line)
1066
+ end
1067
+ important = @catalog.important_tokens(value).uniq.map do |token|
1068
+ Finding.new(rule: "important-modifier", severity: :warning,
1069
+ message: "#{token.inspect} forces the cascade with ! - on a component, class: wins " \
1070
+ "over the dictionary without it; against a rule from another element, " \
1071
+ "theme the surface", line: line)
1072
+ end
1073
+ colors + important
1074
+ end
1075
+
1076
+ # A literal style string: a color literal here is the one exit no
1077
+ # dictionary or theme rule reaches.
1078
+ def style_findings(value, line)
1079
+ @catalog.style_colors(value).uniq.map do |literal|
1080
+ Finding.new(rule: "raw-color", severity: :warning,
1081
+ message: "inline style paints #{literal.inspect} - use a token (var(--primary)) " \
1082
+ "or a theme rule", line: line)
1083
+ end
1084
+ end
1085
+
1086
+ # The passthrough keywords that are markup in disguise: a literal
1087
+ # class:/style: string lints like the attribute it becomes.
1088
+ def passthrough_findings(pairs, base_line)
1089
+ pairs.flat_map do |key, value, kw_line|
1090
+ next [] unless value.is_a?(String)
1091
+
1092
+ line = base_line + kw_line - 1
1093
+ case key
1094
+ when "class" then class_findings(value, line)
1095
+ when "style" then style_findings(value, line)
1096
+ else []
1097
+ end
1098
+ end
1099
+ end
1100
+
1101
+ # --- WebMCP rules (the operate surface) ---
1102
+
1103
+ # webmcp: on a component that declares no tools registers nothing;
1104
+ # a name outside the tool-name grammar cannot register; a name the
1105
+ # template already used collides (the browser rejects duplicates).
1106
+ def webmcp_findings(path, pairs, line)
1107
+ pair = pairs.find { |key, _value, _line| key == "webmcp" }
1108
+ return [] unless pair
1109
+
1110
+ _key, value, kw_line = pair
1111
+ at = line + kw_line - 1
1112
+ findings = []
1113
+ if @catalog.tools_of(path).empty?
1114
+ findings << Finding.new(rule: "webmcp-without-tools", severity: :error,
1115
+ message: "#{helper_of(path)} declares no agent tools - webmcp: has nothing " \
1116
+ "to register", line: at)
1117
+ end
1118
+ return findings unless value.is_a?(String)
1119
+
1120
+ unless WEBMCP_NAME.match?(value)
1121
+ findings << Finding.new(rule: "webmcp-name", severity: :error,
1122
+ message: "webmcp: #{value.inspect} is not a valid instance name " \
1123
+ "(snake_case, at most 64 characters)", line: at)
1124
+ end
1125
+ if (first = @webmcp_names[value])
1126
+ findings << Finding.new(rule: "webmcp-duplicate-name", severity: :warning,
1127
+ message: "webmcp: #{value.inspect} already names the instance on line " \
1128
+ "#{first} - the browser rejects duplicate tool names", line: at)
1129
+ else
1130
+ @webmcp_names[value] = at
1131
+ end
1132
+ findings + webmcp_name_budget_findings(path, value, at)
1133
+ end
1134
+
1135
+ # The composed poetry.{instance}.{tool} names an agent reads
1136
+ # should stay inside Chrome's 30-character guidance.
1137
+ def webmcp_name_budget_findings(path, instance, at)
1138
+ longest = @catalog.tools_of(path).map { |tool| "poetry.#{instance}.#{tool["name"]}" }.max_by(&:length)
1139
+ return [] if longest.nil? || longest.length <= WEBMCP_NAME_BUDGET
1140
+
1141
+ [Finding.new(rule: "webmcp-name-budget", severity: :warning,
1142
+ message: "webmcp: #{instance.inspect} composes #{longest} (#{longest.length} chars) - " \
1143
+ "agents read names best under #{WEBMCP_NAME_BUDGET}; a shorter instance name " \
1144
+ "keeps the tool name legible", line: at)]
1145
+ end
1146
+
1147
+ # poetry_webmcp_form(tool: { autosubmit: true }) must be a GET form:
1148
+ # an agent may submit a read-only lookup by itself, never a
1149
+ # mutation (the helper raises at render; this catches it first).
1150
+ def webmcp_form_findings(helper, call, line)
1151
+ return [] unless helper == "poetry_webmcp_form"
1152
+
1153
+ pairs = keyword_pairs(call)
1154
+ tool = hash_argument(call, "tool")
1155
+ unless tool && hash_pairs(tool).any? { |key, value| key == "autosubmit" && value.is_a?(Prism::TrueNode) }
1156
+ return []
1157
+ end
1158
+
1159
+ method = pairs.find { |key, _value, _line| key == "method" }&.then { |(_key, value, _line)| value }
1160
+ return [] if method.is_a?(String) && method.casecmp?("get")
1161
+
1162
+ [Finding.new(rule: "webmcp-autosubmit", severity: :error,
1163
+ message: "autosubmit: true needs method: :get - an agent may submit a read-only " \
1164
+ "lookup by itself, never a mutating form", line: line)]
1165
+ end
1166
+
1167
+ # <form toolautosubmit> whose method is not GET (absent = GET).
1168
+ def form_autosubmit_findings(node)
1169
+ attributes = node.child_nodes.compact.grep(Herb::AST::HTMLAttributeNode)
1170
+ names = attributes.to_h { |attribute| [attribute_name(attribute)&.downcase, attribute_value(attribute)] }
1171
+ return [] unless names.key?("toolautosubmit")
1172
+
1173
+ method = names["method"]
1174
+ return [] if method.nil? || method.casecmp?("get")
1175
+
1176
+ [Finding.new(rule: "webmcp-autosubmit", severity: :error,
1177
+ message: "toolautosubmit on a #{method.upcase} form - an agent may submit a read-only " \
1178
+ "lookup by itself, never a mutating form", line: line_of(node))]
1179
+ end
1180
+
1181
+ # A click target that is not a button: an inert element carrying
1182
+ # onclick or wearing role=button. A real <button> brings focus,
1183
+ # keyboard activation, and the role for free, which is the first
1184
+ # rule of ARIA. Anchors stay out (a role=button link is an accepted
1185
+ # pattern), and so does a Stimulus click on a bare div - a backdrop
1186
+ # or a row is a legitimate click target without a role.
1187
+ def fake_button_findings(node)
1188
+ tag = open_tag_name(node)
1189
+ attributes = node.child_nodes.compact.grep(Herb::AST::HTMLAttributeNode)
1190
+ names = attributes.to_h { |attribute| [attribute_name(attribute)&.downcase, attribute_value(attribute)] }
1191
+ tell = if names.key?("onclick") then "onclick"
1192
+ elsif names["role"]&.casecmp?("button") then %(role="button")
1193
+ end
1194
+ return [] unless tell
1195
+
1196
+ [Finding.new(rule: "fake-button", severity: :warning,
1197
+ message: "<#{tag}> with #{tell} acts as a button without a button's focus, keyboard " \
1198
+ "activation, and accessible role - render a <button type=\"button\"> " \
1199
+ "(poetry_button) instead", line: line_of(node))]
1200
+ end
1201
+
1202
+ # The lowercase tag name of an open tag, nil for any other node.
1203
+ def open_tag_name(node)
1204
+ return unless node.class.name.to_s.end_with?("HTMLOpenTagNode") && node.respond_to?(:tag_name) &&
1205
+ node.tag_name.respond_to?(:value)
1206
+
1207
+ node.tag_name.value.to_s.downcase
1208
+ end
1209
+
1210
+ def open_tag?(node, name) = open_tag_name(node) == name.downcase
1211
+
1212
+ # The Prism::HashNode passed as a keyword argument, or nil.
1213
+ def hash_argument(call, key)
1214
+ hash = call.arguments&.arguments&.find { |argument| argument.is_a?(Prism::KeywordHashNode) }
1215
+ pair = hash&.elements&.find do |element|
1216
+ element.is_a?(Prism::AssocNode) && element.key.respond_to?(:unescaped) && element.key.unescaped == key
1217
+ end
1218
+ pair&.value if pair&.value.is_a?(Prism::HashNode)
1219
+ end
1220
+
1221
+ # [key, value node] pairs of a literal hash.
1222
+ def hash_pairs(hash)
1223
+ hash.elements.filter_map do |element|
1224
+ next unless element.is_a?(Prism::AssocNode) && element.key.respond_to?(:unescaped)
1225
+
1226
+ [element.key.unescaped, element.value]
1227
+ end
1228
+ end
1229
+
1230
+ # --- shared ---
1231
+
1232
+ def definition(identifier)
1233
+ Stimulus::Manifest.definition(identifier)
1234
+ rescue StandardError
1235
+ nil # unknown poetry controller is reported by controller_findings
1236
+ end
1237
+
1238
+ def suggest(input, dictionary)
1239
+ require "did_you_mean"
1240
+ DidYouMean::SpellChecker.new(dictionary: dictionary.map(&:to_s)).correct(input.to_s).first
1241
+ end
1242
+
1243
+ def helper_of(path) = "poetry_#{path.delete_prefix("poetry/ui/").tr("/", "_")}"
1244
+ def line_of(node) = node.location.start.line
1245
+
1246
+ def attribute_name(node)
1247
+ name_node = node.name
1248
+ return unless name_node
1249
+
1250
+ first = Array(name_node.child_nodes).compact.first
1251
+ first.content if first.respond_to?(:content)
1252
+ end
1253
+
1254
+ def attribute_value(node)
1255
+ value = node.value
1256
+ return unless value
1257
+
1258
+ literals = value.child_nodes.compact.filter_map do |chunk|
1259
+ chunk.content if chunk.is_a?(Herb::AST::LiteralNode)
1260
+ end
1261
+ joined = literals.join(" ").strip
1262
+ joined.empty? ? nil : joined
1263
+ end
1264
+
1265
+ def walk(node, &block)
1266
+ return unless node
1267
+
1268
+ yield node
1269
+ node.child_nodes.compact.each { |child| walk(child, &block) } if node.respond_to?(:child_nodes)
1270
+ end
1271
+ end
1272
+
1273
+ # The declaration tier (the FLASH_ICONS pattern): icon names
1274
+ # that live in app RUBY - `icon:`-keyed hash pairs and ICON-named
1275
+ # constants - and reach the renderer through a lookup the ERB tier can
1276
+ # never see (`poetry_icon name: ICONS[status]`). Prism-walks .rb files
1277
+ # for icon-shaped literals in those declaration positions and validates
1278
+ # them against the active set. Warning severity: a key named `icon` is
1279
+ # strong-but-not-certain evidence. Skipped entirely when the catalog
1280
+ # carries no set names (membership IS the question), and on files that
1281
+ # don't parse (broken Ruby is not this tier's finding). Prism ships
1282
+ # with Ruby - no new dependency, same optional-parse posture as herb.
1283
+ #
1284
+ # @api private
1285
+ class IconDeclarations
1286
+ # `icon:`, `menu_icon:`, `icons:`, `status_icons:` - but NOT
1287
+ # `icon_position:` (an enum, not a name; the FP that loose /icon/
1288
+ # matching would create).
1289
+ ICON_KEY = /\A[a-z0-9_]*icons?\z/
1290
+ # ICON underscore-bounded on both sides: FLASH_ICONS, ICON_NAMES,
1291
+ # ICON - but not LEXICON or ICONOGRAPHY.
1292
+ ICON_CONSTANT = /(?:\A|_)ICONS?(?:_|\z)/
1293
+ # A literal that could be an icon name. Excludes CSS class strings
1294
+ # (spaces, slashes), paths, and interpolation.
1295
+ NAME_SHAPED = /\A[a-z][a-z0-9_-]*\z/
1296
+
1297
+ def initialize(catalog)
1298
+ @catalog = catalog
1299
+ end
1300
+
1301
+ # Lint one Ruby source string. Returns [Finding].
1302
+ def lint(source)
1303
+ names = @catalog.icon_names
1304
+ return [] if names.nil?
1305
+
1306
+ require "prism"
1307
+ result = Prism.parse(source)
1308
+ return [] unless result.success?
1309
+
1310
+ findings = []
1311
+ walk(result.value, findings, names, Set.new)
1312
+ findings
1313
+ end
1314
+
1315
+ private
1316
+
1317
+ def walk(node, findings, names, seen)
1318
+ case node
1319
+ when Prism::AssocNode
1320
+ key = node.key
1321
+ if key.is_a?(Prism::SymbolNode) && key.unescaped.match?(ICON_KEY)
1322
+ harvest(node.value, key.unescaped, findings, names, seen)
1323
+ end
1324
+ when Prism::ConstantWriteNode
1325
+ harvest(node.value, node.name.to_s, findings, names, seen) if node.name.to_s.match?(ICON_CONSTANT)
1326
+ end
1327
+ node.child_nodes.compact.each { |child| walk(child, findings, names, seen) }
1328
+ end
1329
+
1330
+ # Collects icon-name candidates from a declaration value: a bare
1331
+ # literal, an array of literals, or a hash's VALUES ({ success:
1332
+ # :circle-check } - the keys are the app's domain, not icon names).
1333
+ # `.freeze` and parentheses are peeled so frozen constant maps still
1334
+ # harvest.
1335
+ def harvest(value_node, owner, findings, names, seen)
1336
+ value_node = unwrap(value_node)
1337
+ case value_node
1338
+ when Prism::SymbolNode, Prism::StringNode
1339
+ check(value_node, owner, findings, names, seen)
1340
+ when Prism::ArrayNode
1341
+ value_node.elements.each { |element| harvest(element, owner, findings, names, seen) }
1342
+ when Prism::HashNode
1343
+ value_node.elements.grep(Prism::AssocNode).each { |assoc| harvest(assoc.value, owner, findings, names, seen) }
1344
+ end
1345
+ end
1346
+
1347
+ def unwrap(value_node)
1348
+ while value_node.is_a?(Prism::CallNode) && value_node.name == :freeze && value_node.receiver &&
1349
+ (value_node.arguments.nil? || value_node.arguments.arguments.empty?)
1350
+ value_node = value_node.receiver
1351
+ end
1352
+ value_node = value_node.body.first if value_node.is_a?(Prism::ParenthesesNode) &&
1353
+ value_node.body.is_a?(Prism::StatementsNode) &&
1354
+ value_node.body.body.size == 1
1355
+ value_node
1356
+ end
1357
+
1358
+ def check(literal, owner, findings, names, seen)
1359
+ raw = literal.unescaped
1360
+ return unless raw.is_a?(String) && raw.match?(NAME_SHAPED)
1361
+
1362
+ line = literal.location.start_line
1363
+ return unless seen.add?([line, raw])
1364
+
1365
+ kebab = raw.tr("_", "-")
1366
+ if names.include?(kebab)
1367
+ return if raw == kebab
1368
+
1369
+ findings << Finding.new(rule: "icon-declaration", severity: :warning,
1370
+ message: "#{owner}: #{raw.inspect} is written snake_case - " \
1371
+ "icon names are kebab-case (#{kebab.to_sym.inspect} " \
1372
+ "is what renders)",
1373
+ line: line, suggestion: kebab)
1374
+ else
1375
+ findings << Finding.new(rule: "icon-declaration", severity: :warning,
1376
+ message: "#{owner}: #{raw.inspect} looks like an icon name " \
1377
+ "but is not in the icon set",
1378
+ line: line, suggestion: Icons.suggest(kebab, names))
1379
+ end
1380
+ end
1381
+ end
1382
+
1383
+ # Reads files, lints each, attaches the file to every finding. Ruby
1384
+ # files go through the declaration tier; everything else is ERB.
1385
+ #
1386
+ # @api private
1387
+ class Runner
1388
+ def initialize(catalog)
1389
+ @linter = Linter.new(catalog)
1390
+ @declarations = IconDeclarations.new(catalog)
1391
+ @stable_identity = StableIdentity.new(catalog)
1392
+ end
1393
+
1394
+ def run(paths)
1395
+ paths.flat_map do |path|
1396
+ source = File.read(path)
1397
+ findings = linter_for(path).lint(source)
1398
+ # The StableId heuristics ride every ERB pass (warnings only -
1399
+ # they never flip the exit code).
1400
+ findings += @stable_identity.lint(source) unless path.end_with?(".rb")
1401
+ findings.each { |finding| finding.file = path }
1402
+ end
1403
+ end
1404
+
1405
+ private
1406
+
1407
+ def linter_for(path)
1408
+ path.end_with?(".rb") ? @declarations : @linter
1409
+ end
1410
+ end
1411
+
1412
+ module_function
1413
+
1414
+ # Lint file paths against a registry root.
1415
+ #
1416
+ # @param paths [Array<String>, String] template/Ruby file paths to lint
1417
+ # @param registry_root [String] the gem root holding the committed registry
1418
+ # @return [Array<Finding>]
1419
+ def run(paths:, registry_root:)
1420
+ Runner.new(Catalog.from_registry(registry_root)).run(Array(paths))
1421
+ end
1422
+
1423
+ # Lint a single source string (no file).
1424
+ #
1425
+ # @param source [String] the ERB template source
1426
+ # @param catalog [Catalog] the component catalog to validate against
1427
+ # @return [Array<Finding>]
1428
+ def lint(source, catalog:)
1429
+ Linter.new(catalog).lint(source)
1430
+ end
1431
+
1432
+ def to_json(findings)
1433
+ JSON.pretty_generate(findings.map(&:to_h))
1434
+ end
1435
+
1436
+ def to_text(findings)
1437
+ return "poetry check: no issues found" if findings.empty?
1438
+
1439
+ errors = findings.count { |finding| finding.severity == :error }
1440
+ warnings = findings.length - errors
1441
+ (findings.map(&:to_s) + ["", "#{errors} error(s), #{warnings} warning(s)"]).join("\n")
1442
+ end
1443
+ end
1444
+ end
1445
+ end