Guardrails

A Rails toolset that prevents UI drift in AI-assisted applications. Static audits over your views, components, stylesheets, JS controllers, and tokens — surfacing the kinds of inconsistencies that compound silently as an AI assistant ships code faster than design-system discipline can keep up.

Built and maintained by Meticulous.

Current release: 1.0.0 — V0 + V1 + V2 complete, published on RubyGems.org as ui_guardrails. The Ruby module stays Guardrails (so require "guardrails" is unchanged) — only the gem package name on rubygems carries the ui_ prefix, to clear RubyGems' similarity rule against the unrelated guard-rails gem. See doc/ROADMAP.md for status, CHANGELOG.md for the full naming rationale.


The problem

AI-assisted Rails development is fast. Too fast. Without design guardrails in place, codebases accumulate:

  • Inline style= attributes nobody asked for
  • Hex literals scattered across views that should reference a token
  • bg-[#fa3] arbitrary Tailwind values when a theme color already exists
  • Six near-duplicate "card" partials when one parameterized component would do
  • <button> elements with no accessible name
  • Orphan ViewComponent slots and Stimulus controllers
  • The same 7-class utility soup pasted onto 30 buttons

Guardrails catches these patterns without rendering anything — every detector is a static AST walk over your source. Findings stream into a unified report (text or JSON) with the same exit-code contract every other lint tool uses.


Installation

Guardrails works two ways: installed in your project (auto-loaded via a Railtie, runs the audit on every CI pass) or sidecar (cloned alongside your app, audits any tree on demand without modifying its Gemfile).

# Gemfile
group :development, :test do
  gem "ui_guardrails", "~> 1.0"
end
bundle install
bundle exec rake guardrails:init       # writes guardrails.yml, scaffolds MQs
bundle exec rake guardrails:audit      # run the full audit

The gem's Railtie auto-loads rake tasks and (when Lookbook is also in your Gemfile) registers the Guardrails panel inside every preview's inspector.

Sidecar (no Gemfile change)

Useful when you want to audit a Rails app without committing to the gem yet, or in CI for repos you don't own.

git clone https://github.com/meticulous/guardrails ~/code/guardrails
cd ~/code/guardrails && bundle install

# From your Rails app's root:
cd ~/code/my-rails-app
bundle exec --gemfile=~/code/guardrails/Gemfile \
  rake -f ~/code/guardrails/lib/tasks/guardrails.rake \
  guardrails:audit

Every rake task accepts the same env vars in sidecar mode as in-project. The tasks default root to Rails.root when Rails is loaded, else Dir.pwd — so just run from the target app's root.


Quick start

# One-time setup — detects your token stack, writes guardrails.yml,
# scaffolds prefers-color-scheme media queries if your stylesheet
# doesn't already have them. Interactive when on a TTY; CI-safe
# default fallback otherwise.
bundle exec rake guardrails:init

# Full audit — exits 1 on any violation.
bundle exec rake guardrails:audit

# Get a markdown checklist of fixable findings:
SUGGEST=1 bundle exec rake guardrails:audit
# → writes doc/guardrails-suggestions-{TIMESTAMP}.md

# Auto-fix raw_color and tailwind_arbitrary where a token matches:
APPLY=1 bundle exec rake guardrails:audit

Tasks at a glance

Task What it does
guardrails:init Stack detection, writes guardrails.yml, scaffolds prefers-color-scheme / prefers-contrast media queries. Refuses to overwrite an existing config — FORCE=1 overrides.
guardrails:audit Runs every detector — view drift, stimulus, partial similarity, view-components, a11y, cross-codebase patterns, class-itis. Exits 1 on violations.
guardrails:icons Generates an SVG sprite from app/assets/images/icons/, flags inline <svg> in views, reports unused icons.
guardrails:tokens Parses your color and type-scale tokens (CSS vars / SCSS vars / Tailwind v3 config / Tailwind v4 @theme), reports hex literals in stylesheets that should reference a token.
guardrails:a11y:deep Reads axe-core JSON output and folds it into the unified report. Doesn't run axe itself (no Capybara / headless Chrome runtime deps) — point it at axe output your existing tooling produces.
guardrails:visual:deep Consumes screenshot-diff tool output (snap_diff-capybara today; BackstopJS in flight, #15) and reports visual regressions. Same parse-only design — your existing test toolchain runs screenshots; Guardrails reports.

Detectors

View-level drift (guardrails:audit)

Static AST walk over every .html.erb under app/views/ and app/components/ (via Herb, the ERB-aware parser):

Detector Catches
inline_style <div style="…"> — inline styles bypass tokens entirely.
raw_color Hex / rgb literals in color-bearing attributes (fill, stroke, color, bgcolor, background, data-*color*, etc.).
tailwind_arbitrary bg-[#fa3], text-[14px], p-[7px] — arbitrary values that bypass the theme.
helper_recommended <button> / <a> wrapping <%= … %> ERB output. Suggests tag.button / link_to / button_to for clean accessible names. Skips elements that already have aria-label.
image_alt / button_name / link_name / input_label The four most common static a11y misses (see doc/A11Y.md).

Suggest mode and auto-fix

  • SUGGEST=1 rake guardrails:audit writes doc/guardrails-suggestions-{TIMESTAMP}.md — a markdown checklist with one [ ] per fixable finding, the rule that fired, and the proposed replacement (closest token by exact match, else near-match within a configurable channel-distance).
  • APPLY=1 rake guardrails:audit auto-fixes raw_color (→ var(--token)) and tailwind_arbitrary (→ Tailwind theme color shorthand) when a matching token exists. Near-match auto-fix is gated by near_match_policy in guardrails.yml (fix / leave / notify).

Tokens

Guardrails::Tokens parses every token source it can find:

  • CSS custom properties in your configured colors_file
  • SCSS variables in the same
  • Tailwind v3 tailwind.config.js — flat colors and nested scales (gray.50gray-50)
  • Tailwind v4 @theme {} blocks — picked up by the CSS-custom-property scanner

Then scans every other stylesheet for hex literals and reports drift, matching each to the closest defined token. Block + line comments are stripped before matching, preserving line/column positions so reports are accurate.

Stimulus

Guardrails::StimulusAudit cross-references data-controller="…" attributes against app/javascript/**/controllers/*_controller.{js,ts} (works across importmap, Webpacker, Vite, and Avo's app/javascript/js/controllers/ layout):

  • Orphaned — controller referenced in a view but no JS file defines it.
  • Dead — JS file exists but no view references it.
  • Picks up Ruby helper syntax: tag.div(data: { controller: "foo" }).

ViewComponent

Guardrails::ViewComponentAudit:

  • Reports components without a preview file (Lookbook discoverability).
  • Reports renders_one / renders_many slots declared in the component class but never referenced in the template (orphan slots).

Partial similarity

Guardrails::PartialSimilarity runs n-gram Jaccard over the tag-stream of every partial and component template, groups near-duplicates by connected components, and reports clusters above the configured threshold (default 0.7). Pair sample lines surface the most similar match in each cluster.

Cross-codebase patterns (0.3.0)

Guardrails::CrossCodebasePatterns walks every element subtree, fingerprints the tag-only shape (article(header(h2),section(p,p),footer(a))), and reports shapes appearing 3+ times with ≥ 5 elements. Distinct from PartialSimilarity (which compares existing partials) — this finds shapes that should be partials but aren't yet. Dedupes redundant nested patterns so a repeating table doesn't generate three findings (table(…), thead(…), tr(…)) for the same locations.

Class-itis (0.4.0)

Guardrails::ClassItis groups elements by (tag, sorted-uniq-class-list). Reports tuples with ≥ 5 distinct classes appearing on the same tag in ≥ 3 places — the classic AI-assisted-Rails failure mode of 8-utility soup pasted across many buttons when the codebase should have a shared component or @apply rule. ERB-driven class fragments are dropped; only the static portion is fingerprinted.

Visual diff via snap_diff-capybara (0.8.0)

Guardrails::VisualDiff consumes screenshot-diff tool output and folds findings into the unified report. The shipped adapter is snap_diff-capybara — the Rails-native baselines-in-git visual-regression gem:

# Your existing system tests produce diffs under doc/screenshots/
bundle exec rspec spec/system/

# Fold visual-diff findings into the audit:
VISUAL_DIFF=1 bundle exec rake guardrails:audit

Or standalone via rake guardrails:visual:deep. Parse-only — same trade as deep a11y, no Capybara/Chromium runtime deps in the gem. BackstopJS adapter tracked in #15. See doc/VISUAL-DIFF.md.

Deep a11y via axe-core JSON (0.6.0)

Guardrails::A11yDeep consumes axe-core JSON output and folds findings into the unified report:

npx @axe-core/cli http://localhost:3000/ --save axe.json
AXE_JSON=axe.json bundle exec rake guardrails:audit

Or standalone via rake guardrails:a11y:deep. Stays parse-only — your existing test toolchain runs axe (axe-core-rspec, the CLI, Puppeteer, a CDP script — anything that emits axe v4 JSON), Guardrails provides the merge + report. See doc/A11Y.md.

Lookbook auto-panel (0.5.0)

When Lookbook is in the Gemfile, Guardrails auto-registers a :guardrails panel that appears next to every preview's Source / Notes panels. The panel renders Guardrails::Lookbook::ComponentReport#for(component_class_name) — drift in the template, orphan slots, similar templates — inline. Host apps override by dropping their own partial at app/views/lookbook_panels/_guardrails.html.erb. See doc/LOOKBOOK.md.


Output

Every task prints a human-readable text report by default and exits 1 when violations or failing findings exist. For machine consumption:

FORMAT=json bundle exec rake guardrails:audit > findings.json

The JSON payload has a summary: block with finding counts per category plus per-detector arrays — see the rake task source for the exact shape.

Common env vars

Var Effect
SUGGEST=1 Write the markdown checklist alongside the text report.
APPLY=1 Auto-fix raw_color + tailwind_arbitrary where tokens match.
FORMAT=json Emit one JSON document to stdout (all other audit output is suppressed).
FORCE=1 Bypass init's refuse-to-overwrite default.
AXE_JSON=path Fold axe-core findings into the unified report.
VISUAL_DIFF=1 Fold visual-diff findings into guardrails:audit. Embedded installs can flip this on permanently via `Guardrails.configure { \
VISUAL_DIFF_DIR=path / VISUAL_DIFF_THRESHOLD=0.02 Tune the visual-diff snap_diff adapter and mismatch threshold.
SIMILARITY_THRESHOLD=0.85 Override the partial-similarity Jaccard threshold.
PATTERN_MIN_SIZE=8 / PATTERN_MIN_OCCURRENCES=4 Tune the cross-codebase pattern detector.
CLASSITIS_MIN_CLASSES=6 / CLASSITIS_MIN_OCCURRENCES=4 Tune the class-itis detector.

CI

The audit task is a single shell command:

# .github/workflows/ci.yml
- name: Guardrails audit
  run: bundle exec rake guardrails:audit

For richer integration:

- name: Guardrails audit (JSON)
  run: bundle exec rake guardrails:audit FORMAT=json > findings.json
  continue-on-error: true

- name: Upload findings
  uses: actions/upload-artifact@v4
  with:
    name: guardrails-findings
    path: findings.json

Configuration

guardrails.yml at the repo root. guardrails:init writes a sensible default after detecting your stack — what's below is annotated to show every available key:

guardrails:
  scan_paths:
    - app/views
    - app/components
  ignore:
    - app/views/layouts
  tokens:
    # Where your color tokens live. The init task picks this up from
    # stack detection (CSS vars, SCSS, Tailwind v3/v4); edit if it
    # guessed wrong.
    colors_file: app/assets/stylesheets/tokens/_colors.css
    type_scale_file: app/assets/stylesheets/tokens/_type.css
    # Per-channel R/G/B distance at which a near-match becomes a
    # "close enough to suggest" finding. 0 = exact only; 4 = default
    # (catches #0066ff ↔ #0067fe); 20+ = aggressive.
    near_match_threshold: 4
    # What to do with near-matches: notify (default; suggest in the
    # report), fix (auto-fix with APPLY=1), or leave (silence).
    near_match_policy: notify

Demo app

examples/demo is a bootable Rails 7.2 app with intentionally seeded findings — every detector trips at least once. Clone, bin/setup, bin/rails server, then visit /, /broken, and /rails/lookbook to see the auto-registered Guardrails panel. bundle exec rake guardrails:audit runs the full audit against the seeded tree. See examples/demo/README.md.


Status & roadmap

  • V0 (foundation) — ✅ shipped
  • V1 (polish + ecosystem) — ✅ shipped
  • V2 (advanced) — ✅ shipped (cross-codebase patterns, class-itis, visual diff via snap_diff-capybara)

Full status table and decision log: doc/ROADMAP.md.


Development

bundle install
bundle exec rspec        # 453 examples

Real-world signal lives in examples/demo (integration spec) and in the dogfood patches against four real codebases (Patchvault, Talos, Forem, Avo — see CHANGELOG.md for what each round caught).

Issues and PRs: https://github.com/meticulous/guardrails


License

MIT