From 4be202414a4be8498679a831aec9875b84a4de40 Mon Sep 17 00:00:00 2001 From: Jeff Lange Date: Thu, 4 Jun 2026 17:20:38 -0400 Subject: [PATCH 1/3] Add Verifier: static checks for component css declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The DSL's worst failures are silent — a hallucinated Tailwind class produces no CSS at all under JIT. The verifier catches those at edit time, before render. Added: ViewComponentCssDsl::Verifier — six static checks for component declarations: Tailwind class validity (via a compiled-CSS oracle), self-conflicting declarations, method-rule resolution, axis settability, variant-matrix smoke, and template html_attrs splat coverage --- README.md | 35 ++ lib/view_component_css_dsl.rb | 4 + lib/view_component_css_dsl/verifier.rb | 329 ++++++++++++++++++ .../verifier/compiled_css_oracle.rb | 46 +++ .../manual_call_with_attrs_component.rb | 10 + .../manual_call_without_attrs_component.rb | 11 + spec/verifier_spec.rb | 323 +++++++++++++++++ 7 files changed, 758 insertions(+) create mode 100644 lib/view_component_css_dsl/verifier.rb create mode 100644 lib/view_component_css_dsl/verifier/compiled_css_oracle.rb create mode 100644 spec/fixtures/manual_call_with_attrs_component.rb create mode 100644 spec/fixtures/manual_call_without_attrs_component.rb create mode 100644 spec/verifier_spec.rb diff --git a/README.md b/README.md index abf4669..0c1556d 100644 --- a/README.md +++ b/README.md @@ -458,6 +458,41 @@ end Axis, method, and proc rules are appended, not overridden. +## Verifier + +The DSL's worst failure modes are silent: a typo'd or hallucinated Tailwind class produces no CSS at all under JIT, a self-conflicting declaration quietly drops a class, and a rule referencing a missing method only raises at render time on the code path that hits it. `ViewComponentCssDsl::Verifier` catches all of these statically — fast enough to run on every edit. + +```ruby +require "view_component_css_dsl/verifier" + +oracle = ViewComponentCssDsl::Verifier::CompiledCssOracle.new( + "app/assets/builds/tailwind.css" +) +verifier = ViewComponentCssDsl::Verifier.new(known_classes: oracle) + +findings = components.flat_map { |component| verifier.verify(component) } +puts findings +abort if findings.any?(&:error?) +``` + +`verify(component)` returns `Finding` structs (`component`, `check`, `severity`, `message`). Six checks run: + +| Check | Asserts | Catches | +| --- | --- | --- | +| `class_validity` | Every declared class exists in the compiled Tailwind output | Typos, hallucinated classes, theme values that don't exist | +| `self_conflicts` | No declaration conflicts with itself | `css "block flex"` silently dropping `block` | +| `method_rules` | Every Symbol in `css`/`data`/`aria`/`attribute` rules resolves to a method | Render-time `NoMethodError`s | +| `axes_settable` | Every axis has an initialize param or `@ivar` assignment | Variant rules that can never fire | +| `variant_matrix` | `#css` builds cleanly for every axis-value combination | Anything the static checks miss, without rendering | +| `template_splat` | Every template references `html_attrs` | Components whose DSL output never reaches the DOM | + +Notes: + +- **Verify every class in the hierarchy**, abstract bases included. Declaration checks only inspect what each class itself declared, so a parent's mistakes are reported once — on the parent. +- `known_classes:` is anything responding to `include?(String)`. `CompiledCssOracle` parses class selectors out of a compiled Tailwind build; since Tailwind's JIT generates a rule for every valid class found in your content globs, a declared class missing from the output is invalid. Rebuild before verifying — the oracle is only as fresh as the build. Omit `known_classes:` to skip the check. +- `template_splat` covers sidecar files, inline templates (`erb_template "..."`), and hand-written `#call` methods. +- The variant matrix smoke-tests on bare (`allocate`d) instances. Method and proc rules that need `initialize` state report as warnings rather than errors. + ## Development ```sh diff --git a/lib/view_component_css_dsl.rb b/lib/view_component_css_dsl.rb index dbd33b3..8c0539c 100644 --- a/lib/view_component_css_dsl.rb +++ b/lib/view_component_css_dsl.rb @@ -36,6 +36,9 @@ module ViewComponentCssDsl included do class_attribute :_css_base, instance_writer: false, default: "" + # Base css strings as written, before inheritance merging. The merged _css_base + # is conflict-free by construction; the Verifier needs the raw declarations. + class_attribute :_css_base_declarations, instance_writer: false, default: [] class_attribute :_css_axis_rules, instance_writer: false, default: [] class_attribute :_css_method_rules, instance_writer: false, default: [] class_attribute :_css_proc_rules, instance_writer: false, default: [] @@ -79,6 +82,7 @@ def css(*args, **options) when String if options.empty? # Base CSS: css "rounded p-4" + self._css_base_declarations = _css_base_declarations + [args.first] parent_base_css = superclass.respond_to?(:_css_base) ? superclass._css_base : "" self._css_base = if parent_base_css.present? smart_merge(parent_base_css, args.first) diff --git a/lib/view_component_css_dsl/verifier.rb b/lib/view_component_css_dsl/verifier.rb new file mode 100644 index 0000000..e969cdf --- /dev/null +++ b/lib/view_component_css_dsl/verifier.rb @@ -0,0 +1,329 @@ +# frozen_string_literal: true + +require "view_component_css_dsl" + +# Static checks over a component's DSL declarations. Catches the mistakes the DSL +# itself can't surface until render time (or surfaces silently): hallucinated +# Tailwind classes, self-conflicting declarations, rules referencing undefined +# methods, axes no initializer sets, and templates that forget the html_attrs +# splat. Designed to be fast enough to run on every edit. +# +# verifier = ViewComponentCssDsl::Verifier.new(known_classes: oracle) +# findings = verifier.verify(ButtonComponent) +# +# Verify every class in the component hierarchy: declaration-shape checks +# (class validity, self-conflicts) only inspect declarations the class itself +# added, so a parent's declarations are checked on the parent, not re-reported +# on every child. +class ViewComponentCssDsl::Verifier + Finding = Struct.new( + :component, :check, :severity, :message, keyword_init: true + ) do + def to_s + "#{component.name || component.inspect} [#{check}] #{severity}: #{message}" + end + + def error? = severity == :error + end + + TEMPLATE_EXTENSIONS = %w[erb haml slim].freeze + + # Safety cap for pathological axis cartesian products. + VARIANT_MATRIX_CAP = 256 + + # Source file of the DSL itself, for classifying smoke-test backtraces. + DSL_SOURCE_FILE = File.expand_path("../view_component_css_dsl.rb", __dir__) + + # known_classes: anything responding to include?(String) — a Set, or a + # CompiledCssOracle built from your app's compiled Tailwind output. When nil, + # the class-validity check is skipped. + def initialize(known_classes: nil) + @known_classes = known_classes + end + + def verify(component) + check_class_validity(component) + + check_self_conflicts(component) + + check_method_rules(component) + + check_axes_settable(component) + + check_variant_matrix(component) + + check_template_splat(component) + end + + private + + # Every statically-declared class must exist in the known-classes oracle. + # Hallucinated or typo'd classes produce no CSS at all under Tailwind's JIT — + # no error, no warning — so this is the check that catches them. + def check_class_validity(component) + return [] unless @known_classes + + own_declared_styles(component).flat_map do |label, styles| + styles.split.reject { |cls| @known_classes.include?(cls) }.map do |cls| + finding(component, :class_validity, :error, + "#{label}: unknown class \"#{cls}\" (not in the compiled Tailwind output)") + end + end + end + + # A single declaration whose classes conflict with each other (e.g. "block flex") + # is almost always a mistake — smart_merge silently keeps only the last. + def check_self_conflicts(component) + own_declared_styles(component).flat_map do |label, styles| + tokens = styles.split + survivors = component.smart_merge(styles).split + results = [] + + duplicates = tokens.tally.select { |_cls, count| count > 1 }.keys + if duplicates.any? + results << finding(component, :self_conflicts, :error, + "#{label}: duplicate class(es) #{quote_list(duplicates)}") + end + + dropped = tokens.uniq - survivors + if dropped.any? + results << finding(component, :self_conflicts, :error, + "#{label}: #{quote_list(dropped)} conflict with later classes in the " \ + "same declaration and would be silently dropped") + end + + results + end + end + + # Symbols in css/data/aria/attribute rules are method references; each must + # resolve on the component. The DSL only raises for these at render time. + def check_method_rules(component) + method_references(component).filter_map do |label, method_name| + next if resolves?(component, method_name) + + finding(component, :method_rules, :error, + "#{label} references undefined method `#{method_name}`") + end + end + + # Each axis with css rules must be settable: an initialize param of the same + # name, or an @ivar assignment somewhere in the component's source. An axis + # nothing sets means its rules can never fire. + def check_axes_settable(component) + component._css_defined_axes.keys.filter_map do |axis| + next if initialize_param?(component, axis) + next if ivar_assigned_in_source?(component, axis) + + finding(component, :axes_settable, :error, + "axis :#{axis} has css rules, but initialize has no #{axis} param and " \ + "no @#{axis} assignment was found") + end + end + + # Smoke test: build the css string for every combination of axis values + # (including unset) on a bare instance. Exercises validate_axes!, the merge + # caches, and method/proc rules end-to-end without rendering. + def check_variant_matrix(component) + combos = axis_combinations(component) + findings = [] + + if combos.size > VARIANT_MATRIX_CAP + findings << finding(component, :variant_matrix, :warning, + "#{combos.size} axis combinations; only the first #{VARIANT_MATRIX_CAP} " \ + "were smoke-tested") + combos = combos.first(VARIANT_MATRIX_CAP) + end + + combos.each do |combo| + error = smoke_css(component, combo) + next unless error + + findings << finding(component, :variant_matrix, smoke_severity(error), + "#{combo_label(combo)}: #{error.class}: #{error.message}") + end + + findings + end + + # Every template must reference html_attrs — that splat is what carries the + # DSL's classes and attributes to the DOM. Covers sidecar files, inline + # templates (erb_template "..."), and hand-written #call methods. + def check_template_splat(component) + sources = template_sources(component) + if sources.empty? + return [finding(component, :template_splat, :warning, + "no template found (no sidecar file, inline template, or #call method)")] + end + + sources.filter_map do |label, source| + next if source.match?(/\bhtml_attrs\b/) + + finding(component, :template_splat, :error, + "#{label} does not reference html_attrs; DSL classes and attributes " \ + "will not reach the DOM") + end + end + + ################################################################################## + # Declaration collection + ################################################################################## + + # [label, styles] pairs for declarations this class itself added. Inherited + # declarations are checked on the ancestor that declared them. + def own_declared_styles(component) + parent = component.superclass + base = own_rules(component, parent, :_css_base_declarations) + .map { |styles| ["css \"#{styles}\"", styles] } + axis = own_rules(component, parent, :_css_axis_rules) + .map { |rule| [axis_label(rule[:axes]), rule[:styles]] } + methods = own_rules(component, parent, :_css_method_rules) + .map { |rule| ["css :#{rule[:method]}", rule[:styles]] } + base + axis + methods + end + + def own_rules(component, parent, attr) + inherited = parent.respond_to?(attr) ? parent.public_send(attr) : [] + component.public_send(attr) - inherited + end + + def axis_label(axes) = "css #{axes.map { |k, v| "#{k}: :#{v}" }.join(", ")}" + + # [label, method_name] for every Symbol the rules will send to the instance. + # Inherited rules are included: they run on this class, so they must resolve + # on this class (a child may legitimately define the method a parent's rule + # references). + def method_references(component) + refs = component._css_method_rules + .map { |rule| ["css :#{rule[:method]}", rule[:method]] } + + {data: :_data_rules, aria: :_aria_rules, attribute: :_attribute_rules} + .each do |namespace, attr| + component.public_send(attr).each do |rule| + if rule[:predicate].is_a?(Symbol) + refs << ["#{namespace} :#{rule[:predicate]} predicate", rule[:predicate]] + end + rule[:attrs].each do |key, value| + refs << ["#{namespace} #{key}: :#{value}", value] if value.is_a?(Symbol) + end + end + end + + refs + end + + def resolves?(component, method_name) + component.method_defined?(method_name) || + component.private_method_defined?(method_name) + end + + ################################################################################## + # Axis settability + ################################################################################## + + def initialize_param?(component, axis) + params = component.instance_method(:initialize).parameters + params.any? { |_type, name| name == axis } + end + + def ivar_assigned_in_source?(component, axis) + source_paths(component).any? do |path| + File.read(path).match?(/@#{axis}\b\s*(\|\|)?=[^=~]/) + end + end + + # Source files of the component and its DSL-including ancestors. Stops before + # ViewComponent::Base — framework internals aren't where axes get assigned. + def source_paths(component) + component.ancestors + .take_while { |mod| !view_component_base?(mod) } + .filter_map { |mod| mod.identifier if mod.respond_to?(:identifier) } + .uniq + .select { |path| File.exist?(path) } + end + + def view_component_base?(mod) + defined?(ViewComponent::Base) && mod == ViewComponent::Base + end + + ################################################################################## + # Variant matrix + ################################################################################## + + # Cartesian product over defined axes, each axis taking every declared value + # plus nil (axis unset). [{}] when no axes — base/method/proc paths still get + # one smoke run. + def axis_combinations(component) + component._css_defined_axes.reduce([{}]) do |combos, (axis, values)| + combos.flat_map do |combo| + (values.to_a + [nil]).map { |value| combo.merge(axis => value) } + end + end + end + + def smoke_css(component, combo) + instance = component.allocate + instance.instance_variable_set(:@html_attrs, {}) + combo.each { |axis, value| instance.instance_variable_set(:"@#{axis}", value) } + instance.css + nil + rescue => error + error + end + + # Errors raised inside the DSL are real failures. Errors raised in component + # code usually mean a method/proc rule needs initialize state a bare instance + # doesn't have — report those as warnings, not failures. + def smoke_severity(error) + first_frame = error.backtrace&.first.to_s + first_frame.start_with?("#{DSL_SOURCE_FILE}:") ? :error : :warning + end + + def combo_label(combo) + return "with no axes set" if combo.compact.empty? + + "with #{combo.compact.map { |axis, value| "#{axis}: :#{value}" }.join(", ")}" + end + + ################################################################################## + # Templates + ################################################################################## + + def template_sources(component) + sources = [] + + if component.respond_to?(:__vc_inline_template) + inline = component.__vc_inline_template + sources << ["inline template", inline.source] if inline + end + + if component.respond_to?(:sidecar_files) + component.sidecar_files(TEMPLATE_EXTENSIONS).each do |path| + sources << [File.basename(path), File.read(path)] + end + end + + if sources.empty? && (call_path = manual_call_path(component)) + sources << ["#call (#{File.basename(call_path)})", File.read(call_path)] + end + + sources + end + + # Path of a hand-written #call, if the component (or a DSL ancestor) defines + # one. ViewComponent compiles templates into #call too, but only at render + # time — run the verifier in a fresh process and only manual ones exist. + def manual_call_path(component) + return nil unless component.method_defined?(:call) + + owner = component.instance_method(:call).owner + return nil if !owner.is_a?(Class) || view_component_base?(owner) + return nil unless owner.respond_to?(:_css_base) + + path = component.instance_method(:call).source_location&.first + path if path && File.exist?(path) + end + + def finding(component, check, severity, message) + Finding.new(component:, check:, severity:, message:) + end + + def quote_list(classes) = classes.map { |cls| "\"#{cls}\"" }.join(", ") +end + +require_relative "verifier/compiled_css_oracle" diff --git a/lib/view_component_css_dsl/verifier/compiled_css_oracle.rb b/lib/view_component_css_dsl/verifier/compiled_css_oracle.rb new file mode 100644 index 0000000..707dea5 --- /dev/null +++ b/lib/view_component_css_dsl/verifier/compiled_css_oracle.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require_relative "../verifier" + +# The class-validity oracle for Verifier, built from a compiled Tailwind CSS file. +# Tailwind's JIT generates a rule for every valid class it finds in your content +# globs — so as long as your component .rb files are in those globs, the compiled +# output contains exactly the valid classes among those you declared. A declared +# class missing from the output is a typo, a hallucination, or a value your theme +# doesn't define. +# +# oracle = ViewComponentCssDsl::Verifier::CompiledCssOracle.new( +# "app/assets/builds/tailwind.css" +# ) +# oracle.include?("bg-blue-500") # => true +# oracle.include?("bg-blurple") # => false +# +# Caveat: the oracle is only as fresh as the build. Rebuild Tailwind before +# verifying, or a just-added valid class will be flagged as unknown. +class ViewComponentCssDsl::Verifier::CompiledCssOracle + # Class selector: a dot, then word chars / hyphens / CSS escapes. Tailwind + # escapes special chars with a backslash (`.hover\:bg-blue-500`) and leading + # digits as hex (`.\32 xl\:grid`). + CLASS_SELECTOR = /\.((?:\\[0-9a-fA-F]{1,6}\s?|\\.|[\w-])+)/ + + def initialize(css_path) + @classes = parse(File.read(css_path)) + end + + def include?(class_name) = @classes.include?(class_name) + + def size = @classes.size + + private + + def parse(css) + css.scan(CLASS_SELECTOR).map { |(selector)| unescape(selector) }.to_set + end + + # Hex escapes first (`\32 ` -> "2"), then simple escapes (`\:` -> ":"). + def unescape(selector) + selector + .gsub(/\\([0-9a-fA-F]{1,6})\s?/) { $1.hex.chr(Encoding::UTF_8) } + .gsub(/\\(.)/, '\1') + end +end diff --git a/spec/fixtures/manual_call_with_attrs_component.rb b/spec/fixtures/manual_call_with_attrs_component.rb new file mode 100644 index 0000000..74808ad --- /dev/null +++ b/spec/fixtures/manual_call_with_attrs_component.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Fixture: a component with a hand-written #call that references html_attrs. +class ManualCallWithAttrsComponent < TestComponent + css "rounded p-4" + + def call + tag.div(**html_attrs) { "content" } + end +end diff --git a/spec/fixtures/manual_call_without_attrs_component.rb b/spec/fixtures/manual_call_without_attrs_component.rb new file mode 100644 index 0000000..b87e20f --- /dev/null +++ b/spec/fixtures/manual_call_without_attrs_component.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Fixture: a component with a hand-written #call that forgets the DSL's +# attributes entirely. +class ManualCallWithoutAttrsComponent < TestComponent + css "rounded p-4" + + def call + tag.div { "content" } + end +end diff --git a/spec/verifier_spec.rb b/spec/verifier_spec.rb new file mode 100644 index 0000000..1e31198 --- /dev/null +++ b/spec/verifier_spec.rb @@ -0,0 +1,323 @@ +# frozen_string_literal: true + +require "view_component_css_dsl/verifier" +require "tmpdir" + +RSpec.describe ViewComponentCssDsl::Verifier do + let(:verifier) { described_class.new } + + def findings_for(component, check) + verifier.verify(component).select { |f| f.check == check } + end + + describe "class validity" do + let(:oracle) { Set["rounded", "p-4", "bg-blue-500", "opacity-50"] } + let(:verifier) { described_class.new(known_classes: oracle) } + + it "flags classes missing from the oracle" do + component = Class.new(TestComponent) { css "rounded p-4 bg-blurple" } + + findings = findings_for(component, :class_validity) + expect(findings.size).to eq(1) + expect(findings.first.severity).to eq(:error) + expect(findings.first.message).to include("bg-blurple") + end + + it "checks axis and method rule styles too" do + component = Class.new(TestComponent) do + css variant: :primary, style: "bg-blu-500" + css :disabled?, style: "opcity-50" + + def disabled? = false + end + + messages = findings_for(component, :class_validity).map(&:message) + expect(messages).to include(a_string_including("bg-blu-500")) + expect(messages).to include(a_string_including("opcity-50")) + end + + it "passes when every class is known" do + component = Class.new(TestComponent) { css "rounded bg-blue-500" } + + expect(findings_for(component, :class_validity)).to be_empty + end + + it "is skipped when no oracle is provided" do + component = Class.new(TestComponent) { css "totally-made-up" } + + no_oracle = described_class.new + expect(no_oracle.verify(component).select { |f| f.check == :class_validity }) + .to be_empty + end + + it "does not re-flag inherited declarations on the child" do + parent = Class.new(TestComponent) { css "bg-blurple" } + child = Class.new(parent) { css "rounded" } + + expect(findings_for(child, :class_validity)).to be_empty + end + end + + describe "self conflicts" do + it "flags classes silently dropped within one declaration" do + component = Class.new(TestComponent) { css "block flex rounded" } + + findings = findings_for(component, :self_conflicts) + expect(findings.size).to eq(1) + expect(findings.first.message).to include("\"block\"") + expect(findings.first.message).to include("silently dropped") + end + + it "flags duplicate classes" do + component = Class.new(TestComponent) { css "p-4 rounded p-4" } + + findings = findings_for(component, :self_conflicts) + expect(findings.size).to eq(1) + expect(findings.first.message).to include("duplicate") + expect(findings.first.message).to include("\"p-4\"") + end + + it "flags conflicts inside an axis rule's styles" do + component = Class.new(TestComponent) do + css variant: :loud, style: "bg-red-500 bg-blue-500" + end + + findings = findings_for(component, :self_conflicts) + expect(findings.size).to eq(1) + expect(findings.first.message).to include("variant: :loud") + end + + it "passes clean declarations" do + component = Class.new(TestComponent) { css "rounded border p-4 bg-white" } + + expect(findings_for(component, :self_conflicts)).to be_empty + end + + it "does not flag a child overriding its parent's base" do + parent = Class.new(TestComponent) { css "bg-white" } + child = Class.new(parent) { css "bg-blue-500" } + + expect(findings_for(child, :self_conflicts)).to be_empty + end + end + + describe "method rules resolve" do + it "flags a css rule referencing an undefined method" do + component = Class.new(TestComponent) { css :missing?, style: "opacity-50" } + + findings = findings_for(component, :method_rules) + expect(findings.size).to eq(1) + expect(findings.first.message).to include("`missing?`") + end + + it "accepts private methods" do + component = Class.new(TestComponent) do + css :quiet?, style: "opacity-50" + + private + + def quiet? = true + end + + expect(findings_for(component, :method_rules)).to be_empty + end + + it "flags Symbol predicates and values in data/aria/attribute rules" do + component = Class.new(TestComponent) do + data :missing_predicate?, foo: "bar" + aria label: :missing_label_method + attribute role: :missing_role_method + end + + messages = findings_for(component, :method_rules).map(&:message) + expect(messages).to include(a_string_including("missing_predicate?")) + expect(messages).to include(a_string_including("missing_label_method")) + expect(messages).to include(a_string_including("missing_role_method")) + end + + it "checks inherited rules against the child" do + parent = Class.new(TestComponent) { css :fancy?, style: "shadow-lg" } + child = Class.new(parent) do + def fancy? = true + end + + expect(findings_for(parent, :method_rules).size).to eq(1) + expect(findings_for(child, :method_rules)).to be_empty + end + end + + describe "axes settable" do + it "passes when initialize accepts a matching kwarg" do + component = Class.new(TestComponent) do + css variant: :primary, style: "bg-blue-500" + + def initialize(variant: :primary) + @variant = variant + end + end + + expect(findings_for(component, :axes_settable)).to be_empty + end + + it "passes when the ivar is assigned in the component source" do + component = Class.new(TestComponent) do + css tone: :warm, style: "bg-orange-100" + + def initialize(mood) + @tone = mood + end + end + + expect(findings_for(component, :axes_settable)).to be_empty + end + + it "flags an axis nothing sets" do + component = Class.new(TestComponent) do + css zorp: :high, style: "bg-purple-500" + end + + findings = findings_for(component, :axes_settable) + expect(findings.size).to eq(1) + expect(findings.first.message).to include(":zorp") + end + end + + describe "variant matrix smoke" do + it "passes a healthy component across all axis combinations" do + component = Class.new(TestComponent) do + css "rounded p-4" + css variant: :info, style: "bg-gray-100" + css variant: :danger, style: "bg-red-100" + css size: :sm, style: "text-sm" + + def initialize(variant: :info, size: :sm) + @variant = variant + @size = size + end + end + + expect(findings_for(component, :variant_matrix)).to be_empty + end + + it "reports DSL-raised errors as errors" do + component = Class.new(TestComponent) { css :vanished?, style: "opacity-50" } + + findings = findings_for(component, :variant_matrix) + expect(findings).not_to be_empty + expect(findings.first.severity).to eq(:error) + expect(findings.first.message).to include("NoMethodError") + end + + it "reports component-code errors on a bare instance as warnings" do + component = Class.new(TestComponent) do + css "rounded" + css -> { "pl-#{@indent * 4}" } + end + + findings = findings_for(component, :variant_matrix) + expect(findings.size).to eq(1) + expect(findings.first.severity).to eq(:warning) + end + end + + describe "template splat" do + it "passes an inline template referencing html_attrs" do + component = Class.new(TestComponent) do + css "rounded" + erb_template <<~ERB + <%= tag.div **html_attrs do %>hi<% end %> + ERB + end + + expect(findings_for(component, :template_splat)).to be_empty + end + + it "flags an inline template missing html_attrs" do + component = Class.new(TestComponent) do + css "rounded" + erb_template "
hi
" + end + + findings = findings_for(component, :template_splat) + expect(findings.size).to eq(1) + expect(findings.first.severity).to eq(:error) + expect(findings.first.message).to include("inline template") + end + + it "warns when no template can be found" do + component = Class.new(TestComponent) { css "rounded" } + + findings = findings_for(component, :template_splat) + expect(findings.size).to eq(1) + expect(findings.first.severity).to eq(:warning) + end + + it "checks sidecar template files" do + Dir.mktmpdir do |dir| + File.write("#{dir}/sidecar_spec_component.html.erb", "
no attrs
") + stub_const("SidecarSpecComponent", Class.new(TestComponent)) + SidecarSpecComponent.identifier = "#{dir}/sidecar_spec_component.rb" + + findings = findings_for(SidecarSpecComponent, :template_splat) + expect(findings.size).to eq(1) + expect(findings.first.message).to include("sidecar_spec_component.html.erb") + end + end + + it "passes a sidecar template referencing html_attrs" do + Dir.mktmpdir do |dir| + File.write( + "#{dir}/sidecar_ok_component.html.erb", + "<%= tag.div **html_attrs do %>hi<% end %>" + ) + stub_const("SidecarOkComponent", Class.new(TestComponent)) + SidecarOkComponent.identifier = "#{dir}/sidecar_ok_component.rb" + + expect(findings_for(SidecarOkComponent, :template_splat)).to be_empty + end + end + + context "with hand-written #call methods" do + before do + require_relative "fixtures/manual_call_with_attrs_component" + require_relative "fixtures/manual_call_without_attrs_component" + end + + it "passes a #call that references html_attrs" do + findings = findings_for(ManualCallWithAttrsComponent, :template_splat) + expect(findings).to be_empty + end + + it "flags a #call that does not reference html_attrs" do + findings = findings_for(ManualCallWithoutAttrsComponent, :template_splat) + expect(findings.size).to eq(1) + expect(findings.first.message).to include("#call") + end + end + end + + describe ViewComponentCssDsl::Verifier::CompiledCssOracle do + it "parses plain, modifier-prefixed, escaped, and hex-escaped class selectors" do + css = <<~CSS + .bg-blue-500{background-color:#3b82f6} + .hover\\:bg-blue-500:hover{background-color:#3b82f6} + .w-1\\/2{width:50%} + .\\32 xl\\:grid{display:grid} + .data-\\[open\\]\\:bg-gray-100[data-open]{background-color:#f3f4f6} + CSS + + Dir.mktmpdir do |dir| + path = "#{dir}/build.css" + File.write(path, css) + oracle = described_class.new(path) + + expect(oracle.include?("bg-blue-500")).to be(true) + expect(oracle.include?("hover:bg-blue-500")).to be(true) + expect(oracle.include?("w-1/2")).to be(true) + expect(oracle.include?("2xl:grid")).to be(true) + expect(oracle.include?("data-[open]:bg-gray-100")).to be(true) + expect(oracle.include?("bg-blurple")).to be(false) + end + end + end +end From abd287ca8d8110a90f00dda3236f7d082dac45a9 Mon Sep 17 00:00:00 2001 From: Jeff Lange Date: Thu, 4 Jun 2026 17:27:49 -0400 Subject: [PATCH 2/3] Summarize the six checks in the Verifier class comment --- lib/view_component_css_dsl/verifier.rb | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/lib/view_component_css_dsl/verifier.rb b/lib/view_component_css_dsl/verifier.rb index e969cdf..bcba64a 100644 --- a/lib/view_component_css_dsl/verifier.rb +++ b/lib/view_component_css_dsl/verifier.rb @@ -3,10 +3,25 @@ require "view_component_css_dsl" # Static checks over a component's DSL declarations. Catches the mistakes the DSL -# itself can't surface until render time (or surfaces silently): hallucinated -# Tailwind classes, self-conflicting declarations, rules referencing undefined -# methods, axes no initializer sets, and templates that forget the html_attrs -# splat. Designed to be fast enough to run on every edit. +# itself can't surface until render time (or surfaces silently). Designed to be +# fast enough to run on every edit. +# +# The six checks: +# +# class_validity - every declared class exists in the compiled Tailwind output; +# catches typos, hallucinated classes, and theme values that +# don't exist (requires known_classes:) +# self_conflicts - no declaration conflicts with itself; catches e.g. +# css "block flex" silently dropping "block" +# method_rules - every Symbol in css/data/aria/attribute rules resolves to a +# method; catches render-time NoMethodErrors +# axes_settable - every axis has an initialize param or @ivar assignment; +# catches variant rules that can never fire +# variant_matrix - #css builds cleanly for every axis-value combination; +# smoke-catches anything the static checks miss, no rendering +# template_splat - every template (sidecar, inline erb_template, or manual +# #call) references html_attrs; catches components whose DSL +# output never reaches the DOM # # verifier = ViewComponentCssDsl::Verifier.new(known_classes: oracle) # findings = verifier.verify(ButtonComponent) From 0209b93d5d8269f4e0b31637506e5de23f75c9bf Mon Sep 17 00:00:00 2001 From: Jeff Lange Date: Thu, 4 Jun 2026 17:35:45 -0400 Subject: [PATCH 3/3] Document wiring the verifier into a consumer app The library alone relies on devs remembering to run it; the README now ships copy-pasteable enforcement: a CI-failing spec, a bin script, and a Claude Code hook. --- README.md | 104 +++++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 91 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0c1556d..9d0c3a1 100644 --- a/README.md +++ b/README.md @@ -462,19 +462,6 @@ Axis, method, and proc rules are appended, not overridden. The DSL's worst failure modes are silent: a typo'd or hallucinated Tailwind class produces no CSS at all under JIT, a self-conflicting declaration quietly drops a class, and a rule referencing a missing method only raises at render time on the code path that hits it. `ViewComponentCssDsl::Verifier` catches all of these statically — fast enough to run on every edit. -```ruby -require "view_component_css_dsl/verifier" - -oracle = ViewComponentCssDsl::Verifier::CompiledCssOracle.new( - "app/assets/builds/tailwind.css" -) -verifier = ViewComponentCssDsl::Verifier.new(known_classes: oracle) - -findings = components.flat_map { |component| verifier.verify(component) } -puts findings -abort if findings.any?(&:error?) -``` - `verify(component)` returns `Finding` structs (`component`, `check`, `severity`, `message`). Six checks run: | Check | Asserts | Catches | @@ -493,6 +480,97 @@ Notes: - `template_splat` covers sidecar files, inline templates (`erb_template "..."`), and hand-written `#call` methods. - The variant matrix smoke-tests on bare (`allocate`d) instances. Method and proc rules that need `initialize` state report as warnings rather than errors. +### Wiring it into your app + +Three layers, from "can't forget" to "instant feedback". The spec is the only one you need — it makes violations fail CI with no one having to remember anything. The other two make the feedback faster. + +#### 1. A spec — the floor + +Add this once and every component, present and future, is verified on every test run. `descendants` discovers components dynamically, so new ones are covered the day they're written: + +```ruby +# spec/components/css_dsl_verification_spec.rb +require "rails_helper" +require "view_component_css_dsl/verifier" + +RSpec.describe "CssDsl verification" do + it "has no verifier errors in any component" do + Rails.application.eager_load! + + oracle = ViewComponentCssDsl::Verifier::CompiledCssOracle.new( + Rails.root.join("app/assets/builds/tailwind.css") + ) + verifier = ViewComponentCssDsl::Verifier.new(known_classes: oracle) + findings = ApplicationComponent.descendants.flat_map { |c| verifier.verify(c) } + + errors = findings.select(&:error?) + expect(errors).to be_empty, errors.join("\n") + end +end +``` + +Point the oracle at your compiled Tailwind output and make sure your CI builds it before running specs. + +#### 2. `bin/verify-css-dsl` — the edit loop + +Verifies just the components you're working on (or everything, with no args) without waiting for the suite: + +```ruby +#!/usr/bin/env ruby +# Usage: bin/verify-css-dsl [path/to/component.rb ...] +# With no args, verifies every component. + +require_relative "../config/environment" +require "view_component_css_dsl/verifier" + +Rails.application.eager_load! + +components = if ARGV.any? + components_root = Rails.root.join("app/components") + ARGV.map do |path| + rel = Pathname.new(path).expand_path.relative_path_from(components_root) + rel.to_s.delete_suffix(".rb").camelize.constantize + end +else + ApplicationComponent.descendants +end + +oracle = ViewComponentCssDsl::Verifier::CompiledCssOracle.new( + Rails.root.join("app/assets/builds/tailwind.css") +) +verifier = ViewComponentCssDsl::Verifier.new(known_classes: oracle) + +findings = components.flat_map { |component| verifier.verify(component) } +puts findings +exit 1 if findings.any?(&:error?) +``` + +Make it executable with `chmod +x bin/verify-css-dsl`. + +#### 3. A Claude Code hook — the agent loop + +Runs the bin script automatically after Claude Code edits a component file and feeds any findings straight back to the model, so a hallucinated class is flagged the moment it's written rather than when the suite runs. In your project's `.claude/settings.json`: + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit|Write", + "hooks": [ + { + "type": "command", + "command": "f=$(jq -r .tool_input.file_path); case \"$f\" in */app/components/*.rb) bin/verify-css-dsl \"$f\" >&2 || exit 2;; esac" + } + ] + } + ] + } +} +``` + +Exit code 2 returns the findings to Claude as feedback to act on; clean edits stay silent. Note the hook boots your Rails app on each component edit — if that's slow, bootsnap helps. + ## Development ```sh