Linting & Code Quality Tools

This project uses multiple linters to maintain code quality across Ruby, ERB, JavaScript, and SCSS.

Where checks run

Deterministic checks are first-party gates; semantic review is local and on demand.
As of May 2026 the per-PR workflow files
.github/workflows/pronto.yml and .github/workflows/yard-lint.yml were
retired in favor of bin/ci (local + PR via
.github/workflows/ci.yml). script/reek_diff
runs Reek over changed Ruby sources and reports only smells anchored to
added lines. RuboCop, ESLint, and Stylelint use the same green-on-master model
through script/quality_diff, which also owns
ShellCheck and Hadolint. Brakeman compares high-confidence results with a
committed debt baseline, and Gitleaks scans only authored added lines. The local
bin/pr-ready flow runs these gates before spending local
subscription capacity on semantic review.

Ownership matrix (source of truth)

Check Hard gate (bin/ci / ci.yml) Local semantic review Why
Reek (diff) Project smell ratchet
yard-lint (touched files) Project .yard-lint.yml is the real gate
bundle-audit CVE scan of Gemfile.lock; Dependabot covers Ruby and JavaScript alerts
Zeitwerk ✅ (local + CI test boot) The keyed CI test process eager-loads once and checks the loaded graph
FA-icon ERB cops ✅ (pure Ruby; local + ci.yml) Diff-scoped without booting Rails or reading the master key
JavaScript behavior tests ✅ (node:test; local + ci.yml) Dependency-free browser-logic regressions
Minitest ✅ (ci-suite.yml self-hosted; local bin/ci) Behavior
DatabaseConsistency ✅ (once per full run, after the test database is migrated) Curated model/schema mismatch ratchet
Changed-line coverage ✅ (90% executable additions under app/, lib/, script/) Prevent untested branches without imposing a legacy global target
RuboCop / ESLint / Stylelint ✅ (added lines) Existing language/style debt cannot grow
ShellCheck / Hadolint ✅ (added lines) First-party replacement for CodeRabbit's shell/container tools
Gitleaks ✅ (authored added lines) Redacted committed-secret detection; replaces duplicate CR Gitleaks/TruffleHog scans
Brakeman ✅ (high-confidence delta) Existing findings live in config/brakeman.baseline.json
Semantic AI review ✅ (script/multi_llm_review.sh) Local panel (bin/setup --reviewers); skipped when none

Do not replace the ratchet wrappers with raw repository-wide linter or Brakeman
exit statuses: those would block on legacy debt. Keep deterministic checks in
the first-party lanes and subscription-authenticated model CLIs local.

Where What runs Gate
Lefthook pre-commit Added-line RuboCop/ESLint/Stylelint Blocks only the staged change
Lefthook pre-push Pushed-file Reek/YARD · directly related Minitest/JavaScript tests · conditional lockfile/workflow checks Fast local feedback, bounded to files being pushed
bin/ci (local, on demand) All deterministic checks · JavaScript behavior · full Minitest Full local verification
.github/workflows/ci.yml (PR + master) Consolidated bin/ci --quick (including diff-scoped FA-icon cops) · production webpack build Self-hosted first-party check
.github/workflows/ci-suite.yml (PR + master) Two isolated Minitest shards · Zeitwerk during the eager-loaded test boot · DatabaseConsistency once on shard 1 after migrations · aggregate 90% changed-line coverage Self-hosted Linux check
bin/pr-ready (local, on demand) bin/ci --coverage, then the local semantic-review panel (or skip) Human-triggered readiness review; never CI
design-md-lint.yml Custom design-contract validator (when DESIGN.*.md is touched) PR check
yard-docs.yml / docs portal Generates + deploys YARD / Starlight docs On push to master

Documentation-only pull requests keep the required Full suite check, but
take a successful fast path before runner secrets, coverage preparation,
Docker image builds, databases, or Rails tests are started. The fail-safe
classifier in script/ci_suite_scope permits
only Markdown files, paths under doc/, docs/, docs-site/, and .agents/,
the .claude/skills symlink itself, and skills-lock.json; an empty, unknown,
runtime, test, dependency, database, or workflow change runs the full suite.
Manual workflow dispatches also always request the full suite.

bin/setup installs the committed
lefthook.yml
for the whole clone. Worktrees share the dispatcher. Pre-commit stays fast and staged.
Pre-push passes Lefthook's pushed-file list to script/pre_push, which runs only
Reek/YARD for those Ruby files, changed or conventionally related tests, and
conditional lockfile/workflow checks. It deliberately omits Zeitwerk, Brakeman,
Webpack, coverage, and the full suites. GitHub remains authoritative for those
broader checks. Lefthook is the sole hook manager; do not layer Overcommit on
top of it. Installation, per-worktree environment refresh, and one-command
bypasses are documented under Local Git hooks
(Lefthook)
.

Verification should be proportional to the change. Developers are expected to
use judgment: the goal is useful evidence, not repeating every expensive check
for a change that cannot exercise it. GitHub's required checks remain the
merge gate when a local step is intentionally skipped.

Change Recommended local verification Notes
Ruby behavior, data, migrations, security, concurrency, or broad refactor Focused tests with COVERAGE=1, then bin/pr-ready Runs the full suite, changed-line coverage, and semantic review. Use Linux parity where AGENTS.md requires it.
Model/schema contract change (associations, enums, nullability, tables, or implicit ordering) Run affected tests and script/check_new_migrations.sh when migrations changed, then full bin/pr-ready The full runner invokes the curated DatabaseConsistency set after Minitest has migrated the test database. Use RAILS_ENV=test mise exec -- bundle exec database_consistency for a focused reproduction.
Non-Ruby code or configuration with meaningful behavior Focused tests, then bin/pr-ready --quick if the full Ruby suite cannot add confidence --quick keeps deterministic checks and semantic review but skips Minitest and changed-line coverage. Run the relevant JavaScript, build, shell, Terraform, or other focused command.
Documentation-only change Relevant formatter, validator, link, or render check; otherwise the normal commit/push hooks are sufficient A developer may skip bin/pr-ready. GitHub recognizes documentation-only PRs and gives the required full-suite status a fast path.
Tiny typo or copy-only correction Inspect the diff and use the normal commit/push hooks A developer may skip bin/pr-ready when application behavior cannot change. Run a render or focused UI check if markup or interpolation changed.

The ordinary sequence for a behavior change is:

implement → focused tests → bin/pr-ready → commit → push/open PR
          → watch required GitHub checks → merge when green

For a model/schema contract change, insert the migration harness before
bin/pr-ready. Do not run DatabaseConsistency against an unmigrated database:

implement → focused tests → check_new_migrations (when applicable)
          → bin/pr-ready (Minitest migrates DB, then DatabaseConsistency runs)
          → commit → push/open PR → watch checks → merge when green

bin/pr-ready can inspect committed, staged, unstaged, and untracked changes,
so the normal run belongs before the commit. Use bin/pr-ready --pr NUMBER to
review the exact head of an existing PR in an isolated worktree; this is useful
for someone else's PR or when the pushed head specifically needs revalidation,
not a mandatory second run after a successful local check.

Speeding up or narrowing local checks

Prefer a narrower command over a blanket bypass:

# Skip the full Minitest suite and changed-line coverage, but retain static,
# security, JavaScript, and semantic review.
bin/pr-ready --quick

# Run deterministic checks without semantic model review.
bin/ci --quick

# Run only the test pipeline, optionally with changed-line coverage.
bin/ci --tests-only
bin/ci --tests-only --coverage

# Run one affected test directly.
COVERAGE=1 mise exec -- bin/rails test test/path/to/affected_test.rb

The lower-level bin/ci environment overrides are escape hatches for a
deliberately narrowed diagnostic run, not substitutes for relevant
verification:

CI_SKIP_BOOT_CHECKS=1 bin/ci --quick  # omit the Rails Zeitwerk boot
CI_SKIP_QUALITY=1 bin/ci --tests-only # omit linters and Brakeman
CI_SKIP_BUNDLE_AUDIT=1 bin/ci --quick # omit the dependency advisory scan

If a skipped category is relevant to the change, run it separately or let the
required GitHub job complete before calling the PR ready. Always say which
verification was skipped and why.

Commit and push hooks are intentionally localized and usually faster than
bin/pr-ready. Let them run by default. Developers can bypass Lefthook for one
command when the check is irrelevant; see
Bypass a hook once. Do not bypass a
hook to hide a failure. Fix the failure, or record why the check is irrelevant
and ensure the equivalent GitHub check is green.

bin/ci — the local runner

A single entrypoint that runs every static check, the JavaScript behavior
tests, and the full Minitest suite in order, then reports a final pass/fail
summary. ~6-8 minutes for a typical change (linters and JavaScript tests fast,
Minitest ~5 min).

bin/ci                        # full pipeline (run locally before opening a PR)
bin/ci --quick                # skip Minitest; still run fast JavaScript behavior tests
bin/ci --tests-only           # skip linters; run JavaScript behavior tests + Minitest
bin/ci --coverage             # full pipeline + exact 90% changed-line coverage gate
bin/ci --since=HEAD~1         # tighten the diff window for diff-only checks
bin/ci --help                 # full option reference
bin/setup --reviewers         # pick this machine's default panel (or none)
bin/pr-ready                  # full CI + diff coverage, then the local panel
bin/pr-ready --quick --deep   # quick deterministic pass, then all four models
bin/pr-ready --only none      # deterministic checks only, this run
bin/pr-ready --timeout 300    # bound each provider invocation to five minutes

# Reproduce one Linux PR shard, including its isolated pg + Redis stack
TEST_SHARD_INDEX=1 TEST_SHARD_TOTAL=2 CI_DOCKER_TESTS=1 bin/ci --tests-only

Hard-gate steps:

  1. Zeitwerk (bin/rails zeitwerk:check) — skipped when CI_SKIP_BOOT_CHECKS=1; the CI test boot performs the equivalent eager-load check
  2. Reek on the diff (script/reek_diff vs origin/master by default)
  3. RuboCop / ESLint / Stylelint / ShellCheck / Hadolint on added lines (script/quality_diff)
  4. Gitleaks on authored added lines (script/gitleaks_diff; findings are redacted)
  5. yard-lint on touched Ruby files (app/**/*.rb, lib/**/*.rb)
  6. FA-icon/ERB cops — pure Ruby and diff-scoped; still runs when CI_SKIP_BOOT_CHECKS=1
  7. bundle-audit (Gemfile.lock CVE scan)
  8. Brakeman high-confidence delta (script/brakeman_delta)
  9. JavaScript behavior tests (node --test test/javascript/*.test.js)
  10. Minitest full suite (skipped with --quick)
  11. Changed-line coverage (--coverage) — at least 90% of executable Ruby
    additions under app/, lib/, and script/; full bin/pr-ready enables it

Steps don't short-circuit — every step runs even if an earlier one failed,
so you see the full picture in one run.

The FA-icon/ERB step loads lib/tasks/lint_erb.rake directly with Rake's
--rakefile option. It does not load the application's default Rakefile,
initialize Rails, connect to a database, or read the master key.

JavaScript behavior tests use Node's built-in runner and require no dependency
install. Local runs use the Node version pinned by mise; ci.yml provisions
the matching Node release before invoking bin/ci --quick.

The Minitest step runs on the host — no Docker required. parallelize(with: :processes) is fork-safe on native macOS/arm64 (PGGSSENCMODE=disable in
test/test_helper.rb), and the dev Postgres carries CI's
max_locks_per_transaction=1024 (docker-compose.yml). The self-hosted GitHub
runner still runs the suite in the Linux CI container (ci-suite.yml) as the
source of truth; to reproduce that container locally, set CI_DOCKER_TESTS=1.

Pull-request test shards

When the pull-request scope requires Rails tests, the required Full suite
check runs the non-system Minitest inventory as two stable path-hash shards.
Documentation-only changes retain the fast path described above and start no
Rails tests. Each test file belongs to exactly one shard, and adding a file does
not reshuffle existing files. Both jobs retain four Rails process workers and
run on different snapshot-reset VMs with separately named Compose projects,
PostgreSQL containers, Redis containers, databases, and test worker databases.
No test database or service state is shared between shards or pull requests.

For an ordinary application change, each job bind-mounts the checked-out source
over the trusted snapshot's heatwave-test:baseline image and skips the image
build. A change to Ruby, gem, JavaScript dependency, Docker, Compose, or
PostgreSQL-image inputs—or a missing or bundle-incompatible baseline—falls back
to isolated run-scoped image builds. This keeps the dependency/runtime trust
boundary while avoiding two duplicate source-copy builds on the pull-request
critical path.

When a shard fails, its job summary includes the exact shard number. Reproduce
that same Linux selection locally with:

TEST_SHARD_INDEX=1 TEST_SHARD_TOTAL=2 CI_DOCKER_TESTS=1 bin/ci --tests-only

Use index 2 for the other half. script/test_shard
owns the deterministic selection; explicit test paths and shard variables are
mutually exclusive so a partial invocation cannot silently omit tests.

Each successful shard uploads its Minitest log. When changed-line coverage
applies, it also uploads its SimpleCov result set; failed shards upload a
separate attempt-scoped diagnostics artifact. Shard 1 owns the required Full suite check and stays on its existing runner until shard 2's report arrives.
It verifies both statuses, combines their coverage hits, and applies the single
90% changed-line gate without scheduling a third job. This measures the union
of the suite rather than judging either half in isolation. Reports expire after
seven days and contain no database or application secrets. Stable successful
report names let GitHub's Re-run failed jobs command reuse a successful
sibling report and replace only the rerun job's report.

The 2026-08-11 allocation baseline was deliberately close on three static
proxies for runtime:

Shard Files test declarations Bytes
1 840 7,441 4,689,092
2 811 7,327 4,708,926

Hashing cannot promise equal runtime as tests evolve, so each shard summary
retains Minitest's Finished in duration and run count. Review the median of
comparable pull-request runs rather than reacting to one noisy job. If one
shard is consistently more than 20% slower across ten full-suite runs, replace
the static proxy with a checked-in timing manifest and deterministic weighted
allocation. Do not add a third shard merely to hide imbalance: that consumes
the pool slot reserved for concurrent pull requests and adds another isolated
database setup.

The repository has pre-existing lint and Brakeman debt, so full scans are not
usable as binary gates. The first-party runners ratchet instead:

  • script/quality_diff reports only findings whose location intersects an
    added line. Existing findings elsewhere in a touched file do not fail.
  • script/brakeman_delta scans the whole Rails graph and compares only
    high-confidence warnings with config/brakeman.baseline.json. New warnings
    fail. Fixed warnings also fail with a request to shrink the baseline, so they
    cannot silently return later.

After fixing an existing Brakeman warning, regenerate the baseline, confirm the
diff only removes the intended fingerprint, and re-run the delta:

mise exec -- bundle exec brakeman --quiet --no-pager -w 3 \
  --no-exit-on-warn --format json --output /tmp/brakeman.json
jq '{warnings: .warnings}' /tmp/brakeman.json > config/brakeman.baseline.json
mise exec -- bundle exec ruby script/brakeman_delta origin/master

A verified Brakeman false positive may be waived in the automatically loaded
config/brakeman.ignore file (mise exec -- bundle exec brakeman -I). Every
waiver needs a reviewable note explaining why the result is safe; true positives
must be fixed rather than moved into either the ignore file or debt baseline.

Changed-line coverage is calculated by the full, non-system Minitest suite.
Only executable added lines in Ruby sources under app/, lib/, and script/
count, and 90% must be covered; the global historical percentage is reported
but does not gate.

The browser system suite runs nightly and on demand in system-tests.yml, on
the same snapshot-reset pool but intentionally outside the PR critical path.
The 2026-08-10 scheduled run completed successfully in about five minutes.

Quick Reference

# Ruby/Rails
mise exec -- bundle exec rubocop                         # Ruby style & best practices
mise exec -- bundle exec rubocop --autocorrect-all       # Auto-fix Ruby issues
mise exec -- bundle exec brakeman                        # Security vulnerabilities
mise exec -- bundle exec bundler-audit check --update    # Dependency vulnerabilities
mise exec -- bundle exec database_consistency            # Model/DB consistency (eager_loads app; needs clean boot)
mise exec -- bundle exec ruby script/reek_diff origin/master # Reek on added lines
mise exec -- ruby script/deps_impact --out tmp/deps-impact.md # outdated gems/npm + call-site buckets

# ERB Templates (Herb — HTML-aware)
mise exec -- yarn lint:erb                  # Lint views and components
mise exec -- yarn lint:erb:fix              # Auto-fix ERB issues
mise exec -- yarn herb-lint app/views       # Lint specific directory

# JavaScript
mise exec -- yarn lint:js                   # ESLint for JS/JSX
mise exec -- yarn eslint . --fix            # Auto-fix JS issues

# CSS/SCSS
mise exec -- yarn lint:css                  # Stylelint for SCSS
mise exec -- yarn lint:css:fix              # Auto-fix SCSS issues

# All JS + CSS
mise exec -- yarn lint                      # Run both JS and CSS linters

# Shell, containers, and secrets
mise exec -- shellcheck path/to/script.sh
mise exec -- hadolint Dockerfile
mise exec -- ruby script/gitleaks_diff origin/master | \
  mise exec -- gitleaks stdin --no-banner --no-color --redact --verbose

The added-line secret scan fails closed for changed text files over 5 MiB;
split or explicitly audit an oversized file instead of silently bypassing it.

Ruby Linters

Rubocop (Core)

  • Config: .rubocop.yml
  • Purpose: Ruby style, syntax, and best practices

Plugins included:

Plugin Purpose
rubocop-rails Rails-specific cops
rubocop-performance Performance optimizations
rubocop-minitest Minitest best practices
rubocop-capybara Capybara test syntax
rubocop-factory_bot FactoryBot syntax
rubocop-yard YARD documentation

Brakeman

  • Purpose: Static security analysis
  • Run: bundle exec brakeman

Bundler Audit

  • Purpose: Check for known vulnerabilities in dependencies
  • Run: bundle exec bundler-audit check --update
  • Suppressions: .bundler-audit.yml lists GHSAs we've intentionally deferred. Each entry carries an exposure analysis and a re-evaluate date — when you see a new advisory in CI output, either upgrade or add a justified suppression with the same shape; never silence one with a bare GHSA id and no comment.

Reek diff runner

  • Config: .reek.yml; script/reek_diff owns Git diff filtering.
  • Purpose: Runs Reek on changed Ruby sources and reports only smells whose
    reported line intersects an added line between the working tree and the
    selected base. This preserves the useful part of Pronto without its GitLab,
    Rugged, Octokit, and runner-plugin dependency tree.
  • Why diff-only at all? A full reek / rubycritic report surfaces a large
    pre-existing backlog that nobody is going to fix mid-feature. The runner
    narrows feedback to things the branch touched.
  • ⚠️ "Diff-only" is per-line, and method-level smells anchor to the nearest
    changed line inside the method.
    A one-line edit to an already-oversized
    method can therefore fail the hard gate on a method you only touched. Options,
    in order of preference: extract the lines you touched into a small private
    method; move the edit outside the offending method; or, if the smell is
    genuinely pre-existing and out of scope, add a narrowly scoped :reek:
    comment on the method with a reason. Do not widen .reek.yml exclusions
    merely to get green.
  • The comparison includes committed, staged, unstaged, and untracked Ruby
    changes, so a local run checks the exact working tree that will be committed.

Local usage:

mise exec -- bundle exec ruby script/reek_diff origin/master
mise exec -- bundle exec ruby script/reek_diff HEAD~3
mise exec -- bundle exec reek path/to/file.rb
mise exec -- bundle exec rubocop path/to/file.rb
mise exec -- bundle exec brakeman --quiet --no-pager -w 3

CI: bin/ci --quick (via .github/workflows/ci.yml) invokes the runner.
There is no separate .github/workflows/pronto.yml.

Disabling on a single line: use Reek's underlying pragma
(# :reek:SmellName). Tune project-wide behavior in .reek.yml.

Dependency impact (script/deps_impact)

Read-only freshness report for direct Gemfile gems and package.json
dependencies (app + docs-site). Each package is bucketed:

Bucket Meaning
safe Patch, or a minor with no Heatwave call sites. Constraint already allows the latest. Eligible for one grouped lockfile PR.
review Rails / intl-tel-input / exact-pin patches / minors that we actually call. Own PR + bin/pr-ready --deep.
stop Major, git-sourced, known ceiling (sidekiq, pg_party, parallel, playwright, webpack, sass-loader), or a range that blocks the latest. Task doc only.

The script does not apply updates, commit, or open a pull request.
Dependabot still opens the weekly non-major PRs; use this before a deliberate
sweep, or to write the impact brief those PRs otherwise lack.

mise exec -- ruby script/deps_impact --out tmp/deps-impact.md
mise exec -- ruby script/deps_impact --ecosystem gems --json

After applying only the safe set on a worktree: bin/pr-ready, then
gh pr create --draft -F tmp/deps-impact.md.

Database Consistency

  • Config: .database_consistency.yml
  • Purpose: Curated, default-deny model/schema safety ratchet. The full CI
    suite runs CaseSensitiveUniqueValidationChecker, EnumTypeChecker,
    ImplicitOrderingChecker, MissingAssociationClassChecker,
    MissingTableChecker, and PolymorphicAssociationNullabilityChecker once,
    after Minitest has prepared and migrated the test database. A fully enabled
    run reports thousands of mostly legacy or policy-dependent findings; see
    doc/tasks/202608141706_DATABASE_CONSISTENCY_ROLLOUT.md for the staged audit.
  • Autofix: bundle exec database_consistency -f (review all changes)
  • Note: Needs a clean boot (eager_load) and a fully migrated database.
    Never run unrestricted autofix; scope it to a reviewed checker and inspect
    every generated model or migration change.

ERB Linting (Herb)

  • Config: .herb.yml
  • Purpose: HTML-aware ERB parser with intelligent linting
  • Package: @herb-tools/linter (npm)

Key features:

  • Understands HTML structure and context
  • Detects unclosed tags, invalid nesting
  • Smart indentation awareness
  • VS Code extension: Herb LSP (marcoroth.herb-lsp)

Run:

mise exec -- yarn lint:erb                  # Lint views and components
mise exec -- yarn lint:erb:fix              # Auto-fix issues
mise exec -- yarn herb-lint --simple        # Quick summary output

(The Ruby erb_lint / better_html stack was removed; Herb covers ERB linting for this project.)

JavaScript Linting

ESLint

  • Config: eslint.config.mjs
  • Purpose: JavaScript/JSX linting

Plugins:

  • React
  • React Hooks
  • JSX Accessibility
  • Import

CSS/SCSS Linting

Stylelint

  • Config: .stylelintrc.json
  • Purpose: SCSS/CSS linting

Rules focus on:

  • Proper SCSS syntax
  • Nesting depth limits
  • Property ordering (relaxed)
  • No vendor prefix warnings

CI/CD Integration

Add to your CI pipeline:

# Example GitHub Actions
- name: Ruby Linting
  run: |
    bundle exec rubocop --parallel
    bundle exec brakeman --no-pager

- name: Security Audit
  run: bundle exec bundler-audit check --update

- name: ERB Linting
  run: yarn lint:erb

- name: JS Linting
  run: yarn lint:js

- name: CSS Linting
  run: yarn lint:css

Pre-commit Hook (Optional)

Add to .husky/pre-commit:

#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

# Run quick lints on staged files only
yarn pretty-quick --staged
bundle exec rubocop --force-exclusion $(git diff --cached --name-only --diff-filter=ACM | grep '\.rb$' | tr '\n' ' ')

Disabling Rules

Rubocop

# rubocop:disable Metrics/AbcSize
def complex_method
  # ...
end
# rubocop:enable Metrics/AbcSize

ESLint

// eslint-disable-next-line no-console
console.log('debug');

Stylelint

/* stylelint-disable-next-line selector-class-pattern */
.legacyClassName { }