Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,119 @@ 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.

`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.

### 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
Expand Down
4 changes: 4 additions & 0 deletions lib/view_component_css_dsl.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: []
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading