Skip to content

Linting & Code Quality Tools

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

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.

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.

Prefer a narrower command over a blanket bypass:

Terminal window
# 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:

Terminal window
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.

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

Terminal window
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.

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:

Terminal window
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:

Terminal window
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.

Terminal window
# 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.

  • 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
  • Purpose: Static security analysis
  • Run: bundle exec brakeman
  • 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.
  • 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:

Terminal window
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.

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.

Terminal window
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.

  • 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.
  • 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:

Terminal window
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.)

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

Plugins:

  • React
  • React Hooks
  • JSX Accessibility
  • Import
  • Config: .stylelintrc.json
  • Purpose: SCSS/CSS linting

Rules focus on:

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

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

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' ' ')
# rubocop:disable Metrics/AbcSize
def complex_method
# ...
end
# rubocop:enable Metrics/AbcSize
// eslint-disable-next-line no-console
console.log('debug');
/* stylelint-disable-next-line selector-class-pattern */
.legacyClassName { }