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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +3 -0
- data/DESIGN.md +145 -0
- data/LICENSE.txt +21 -0
- data/README.md +30 -0
- data/THIRD_PARTY_NOTICES.md +507 -0
- data/app/components/poetry/core/box/component.rb +82 -0
- data/app/components/poetry/core/component.rb +390 -0
- data/app/components/poetry/core/concerns/agent_tools.rb +418 -0
- data/app/components/poetry/core/concerns/declared_attributes.rb +267 -0
- data/app/components/poetry/core/concerns/introspection.rb +533 -0
- data/app/components/poetry/core/concerns/options.rb +262 -0
- data/app/components/poetry/core/concerns/parts.rb +148 -0
- data/app/components/poetry/core/concerns/stimulus.rb +363 -0
- data/app/components/poetry/core/concerns/styles.rb +323 -0
- data/app/components/poetry/core/style.rb +150 -0
- data/app/components/poetry/core/wrapper/component.rb +90 -0
- data/app/javascript/poetry/core/accordion_controller.js +165 -0
- data/app/javascript/poetry/core/action_bar_controller.js +109 -0
- data/app/javascript/poetry/core/autocomplete_controller.js +220 -0
- data/app/javascript/poetry/core/calendar_controller.js +406 -0
- data/app/javascript/poetry/core/carousel_controller.js +167 -0
- data/app/javascript/poetry/core/checkbox_group_controller.js +97 -0
- data/app/javascript/poetry/core/checked_controller.js +208 -0
- data/app/javascript/poetry/core/clipboard_text_controller.js +111 -0
- data/app/javascript/poetry/core/combobox_controller.js +1095 -0
- data/app/javascript/poetry/core/command_controller.js +510 -0
- data/app/javascript/poetry/core/context_menu_controller.js +204 -0
- data/app/javascript/poetry/core/date_field_controller.js +584 -0
- data/app/javascript/poetry/core/date_picker_controller.js +111 -0
- data/app/javascript/poetry/core/deferred_controller.js +111 -0
- data/app/javascript/poetry/core/dialog_controller.js +206 -0
- data/app/javascript/poetry/core/dismissable_controller.js +182 -0
- data/app/javascript/poetry/core/drawer_controller.js +365 -0
- data/app/javascript/poetry/core/file_input_controller.js +191 -0
- data/app/javascript/poetry/core/focus_scope_controller.js +162 -0
- data/app/javascript/poetry/core/helpers/announce.js +228 -0
- data/app/javascript/poetry/core/helpers/breakpoint.js +28 -0
- data/app/javascript/poetry/core/helpers/collection.js +18 -0
- data/app/javascript/poetry/core/helpers/direction.js +15 -0
- data/app/javascript/poetry/core/helpers/escape.js +38 -0
- data/app/javascript/poetry/core/helpers/filter_rank.js +104 -0
- data/app/javascript/poetry/core/helpers/focus_guards.js +46 -0
- data/app/javascript/poetry/core/helpers/hotkey.js +43 -0
- data/app/javascript/poetry/core/helpers/id_integrity.js +65 -0
- data/app/javascript/poetry/core/helpers/incomplete_date.js +328 -0
- data/app/javascript/poetry/core/helpers/mask.js +283 -0
- data/app/javascript/poetry/core/helpers/portal.js +217 -0
- data/app/javascript/poetry/core/helpers/presence.js +210 -0
- data/app/javascript/poetry/core/helpers/registration_guard.js +76 -0
- data/app/javascript/poetry/core/helpers/scroll_lock.js +67 -0
- data/app/javascript/poetry/core/helpers/scroller_geometry.js +422 -0
- data/app/javascript/poetry/core/helpers/state.js +80 -0
- data/app/javascript/poetry/core/helpers/tabbable.js +56 -0
- data/app/javascript/poetry/core/helpers/turbo_cache.js +21 -0
- data/app/javascript/poetry/core/helpers/typeahead.js +85 -0
- data/app/javascript/poetry/core/hotkey_controller.js +55 -0
- data/app/javascript/poetry/core/hover_card_controller.js +431 -0
- data/app/javascript/poetry/core/index.js +218 -0
- data/app/javascript/poetry/core/mask_controller.js +500 -0
- data/app/javascript/poetry/core/menu_controller.js +1050 -0
- data/app/javascript/poetry/core/menubar_controller.js +329 -0
- data/app/javascript/poetry/core/message_scroller_controller.js +930 -0
- data/app/javascript/poetry/core/navigation_menu_controller.js +458 -0
- data/app/javascript/poetry/core/number_field_controller.js +404 -0
- data/app/javascript/poetry/core/optimistic_form_controller.js +56 -0
- data/app/javascript/poetry/core/otp_controller.js +219 -0
- data/app/javascript/poetry/core/popover_controller.js +300 -0
- data/app/javascript/poetry/core/popper_controller.js +439 -0
- data/app/javascript/poetry/core/pressed_controller.js +74 -0
- data/app/javascript/poetry/core/questionnaire_controller.js +468 -0
- data/app/javascript/poetry/core/radio_group_controller.js +197 -0
- data/app/javascript/poetry/core/resizable_controller.js +202 -0
- data/app/javascript/poetry/core/roving_focus_controller.js +215 -0
- data/app/javascript/poetry/core/scroll_spy_controller.js +84 -0
- data/app/javascript/poetry/core/search_field_controller.js +95 -0
- data/app/javascript/poetry/core/select_controller.js +924 -0
- data/app/javascript/poetry/core/sensitive_input_controller.js +195 -0
- data/app/javascript/poetry/core/sheet_controller.js +24 -0
- data/app/javascript/poetry/core/sidebar_controller.js +272 -0
- data/app/javascript/poetry/core/slider_controller.js +419 -0
- data/app/javascript/poetry/core/state_controller.js +82 -0
- data/app/javascript/poetry/core/table_selection_controller.js +153 -0
- data/app/javascript/poetry/core/tabs_controller.js +151 -0
- data/app/javascript/poetry/core/tag_group_controller.js +134 -0
- data/app/javascript/poetry/core/toast_controller.js +228 -0
- data/app/javascript/poetry/core/toast_trigger_controller.js +26 -0
- data/app/javascript/poetry/core/toaster_controller.js +197 -0
- data/app/javascript/poetry/core/toggle_group_controller.js +206 -0
- data/app/javascript/poetry/core/tooltip_controller.js +541 -0
- data/app/javascript/poetry/core/tree_controller.js +265 -0
- data/app/javascript/poetry/core/vendor/floating_ui_core.js +1043 -0
- data/app/javascript/poetry/core/vendor/floating_ui_dom.js +794 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils.js +141 -0
- data/app/javascript/poetry/core/vendor/floating_ui_utils_dom.js +170 -0
- data/app/views/poetry/core/preview.html.erb +13 -0
- data/config/component_registry.yml +20 -0
- data/config/controllers_manifest.json +1528 -0
- data/config/importmap.rb +11 -0
- data/config/state_vocabulary.json +31 -0
- data/lib/active_model/type/list.rb +33 -0
- data/lib/active_model/type/symbol.rb +36 -0
- data/lib/poetry/core/check/stable_identity.rb +104 -0
- data/lib/poetry/core/check.rb +1445 -0
- data/lib/poetry/core/config.rb +286 -0
- data/lib/poetry/core/contrib/wrapped_helper.rb +68 -0
- data/lib/poetry/core/css/bem_merger.rb +45 -0
- data/lib/poetry/core/css/bem_reference.rb +87 -0
- data/lib/poetry/core/css/override_scan.rb +110 -0
- data/lib/poetry/core/css/resolver.rb +161 -0
- data/lib/poetry/core/css/safelist.rb +41 -0
- data/lib/poetry/core/css/tailwind_merger.rb +102 -0
- data/lib/poetry/core/css/template_classes.rb +102 -0
- data/lib/poetry/core/css/theme_coverage.rb +72 -0
- data/lib/poetry/core/css/var_coverage.rb +75 -0
- data/lib/poetry/core/css/verifier.rb +106 -0
- data/lib/poetry/core/design_lint.rb +720 -0
- data/lib/poetry/core/design_md/import.rb +250 -0
- data/lib/poetry/core/design_md.rb +461 -0
- data/lib/poetry/core/engine.rb +71 -0
- data/lib/poetry/core/errors.rb +53 -0
- data/lib/poetry/core/html/attributes.rb +667 -0
- data/lib/poetry/core/icons.rb +157 -0
- data/lib/poetry/core/llms_text.rb +354 -0
- data/lib/poetry/core/page_architectures.rb +304 -0
- data/lib/poetry/core/part_contract.rb +285 -0
- data/lib/poetry/core/preview/abstract.rb +121 -0
- data/lib/poetry/core/preview/base.rb +227 -0
- data/lib/poetry/core/preview/sidecarable.rb +136 -0
- data/lib/poetry/core/preview/template.rb +186 -0
- data/lib/poetry/core/recipe_items.rb +82 -0
- data/lib/poetry/core/registry.rb +363 -0
- data/lib/poetry/core/registry_address.rb +115 -0
- data/lib/poetry/core/registry_client.rb +217 -0
- data/lib/poetry/core/registry_installer.rb +234 -0
- data/lib/poetry/core/registry_items.rb +184 -0
- data/lib/poetry/core/skill_text.rb +288 -0
- data/lib/poetry/core/stable_id.rb +110 -0
- data/lib/poetry/core/stimulus/builder.rb +395 -0
- data/lib/poetry/core/stimulus/declarations.rb +444 -0
- data/lib/poetry/core/stimulus/manifest.rb +68 -0
- data/lib/poetry/core/stimulus/merger.rb +131 -0
- data/lib/poetry/core/stimulus_contract.rb +263 -0
- data/lib/poetry/core/tag_helper.rb +10 -0
- data/lib/poetry/core/template_compile.rb +74 -0
- data/lib/poetry/core/token_import.rb +317 -0
- data/lib/poetry/core/tokens/color.rb +215 -0
- data/lib/poetry/core/tokens/contrast_gate.rb +120 -0
- data/lib/poetry/core/tokens/generator.rb +211 -0
- data/lib/poetry/core/tokens.rb +117 -0
- data/lib/poetry/core/version.rb +9 -0
- data/lib/poetry/core.rb +52 -0
- data/lib/poetry-core.rb +5 -0
- data/tokens/aliases.css +145 -0
- data/tokens/tailwind-theme.css +47 -0
- data/tokens/tokens.css +78 -0
- data/tokens/tokens.dtcg.json +779 -0
- data/vendor/floating-ui/LICENSE +20 -0
- data/vendor/floating-ui/VENDORED_VERSIONS +15 -0
- data/vendor/shadcn-tailwind/LICENSE +21 -0
- data/vendor/shadcn-tailwind/VENDORED_COMMIT +1 -0
- data/vendor/shadcn-tailwind/tailwind.css +629 -0
- data/vendor/tw-animate-css/LICENSE +21 -0
- data/vendor/tw-animate-css/VENDORED_VERSION +1 -0
- data/vendor/tw-animate-css/tw-animate.css +1 -0
- 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
|