Skip to content

feat(generator): ship the docs-kit RuboCop cops from the gem (#22)#38

Merged
mhenrixon merged 1 commit into
mainfrom
issue-22-rubocop-cops
Jul 3, 2026
Merged

feat(generator): ship the docs-kit RuboCop cops from the gem (#22)#38
mhenrixon merged 1 commit into
mainfrom
issue-22-rubocop-cops

Conversation

@mhenrixon

Copy link
Copy Markdown
Owner

Closes #22.

Problem

Both consumer sites carried a byte-identical hand-copied cop
(DocsKit/RenderComponentPreferred). Copy-paste cops drift and new sites never
got them. This upstreams the cops into the gem so a consuming site requires them
by gem path (two lines, scaffolded by the install generator) and deletes its
copies.

What ships

File Purpose
lib/rubocop/cop/docs_kit/render_component_preferred.rb The proven cop, upstreamed — prefers DocsUI::Code(...) over render DocsUI::Code.new(...)
lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb New — steers \#{...} escapes in a double-quoted heredoc to a single-quoted delimiter (the recurring "escape tax")
lib/docs_kit/rubocop.rb Require entry point — loads rubocop lazily (dev-time dep of the host, never a runtime dep of docs-kit)
config/rubocop/docs_kit.yml Both cops enabled, scoped to app/views/docs/**/* by default

Wiring (automatic)

The install generator and docs-kit new inject two lines into the site's
.rubocop.yml, idempotently — created minimal when absent, merged (not
clobbered) into an existing rails new omakase config:

require:
  - docs_kit/rubocop
inherit_gem:
  docs-kit: config/rubocop/docs_kit.yml

RuboCop stays a development-time dependency of the host app. Every generated
site already has it via rubocop-rails-omakase.

Autocorrect-safety fixes (found by adversarial verification)

A multi-agent verification pass (probe against adversarial fixtures → independent
skeptical judges) surfaced three real defects, all now fixed and regression-tested:

  • EscapedInterpolationInHeredoc — live sigil interpolation (HIGH). Ruby
    interpolates #@ivar and #$global in a double-quoted string too, not only
    #{...}. Live-detection was begin-child-only, so a heredoc whose live
    interpolation was #@name got its delimiter swapped and the interpolation
    silently frozen to literal text. Now any non-str dstr child counts as
    live. Verified on the real CLI: reports "fix by hand", leaves the file unchanged.
  • EscapedInterpolationInHeredoc — inconsistent de-escaping (HIGH).
    Autocorrect stripped the backslash only from \#{ while switching to a
    single-quoted delimiter (which makes all backslashes literal), so a \#@name
    kept a backslash it used to consume. Now strips every escaped form
    (\#{, \#@, \#$).
  • RenderComponentPreferred — overlapping corrections (MEDIUM). A kit render
    nested inside another kit render's block produced overlapping rewrite ranges
    (Parser::ClobberingError; the real CLI emitted a "this is a RuboCop bug"
    error). The correction now replaces only the render X.new(...) send, leaving
    the block in place so the inner render corrects independently.

Zeitwerk safety

lib/rubocop/** is outside the gem's loader dirs, and lib/docs_kit/rubocop.rb
(which defines RuboCop::Cop::DocsKit::*, not a DocsKit::Rubocop constant) is
loader.ignore-d. Specs assert the gem boots with no autoload registered for
DocsKit::Rubocop.

Test plan

  • bundle exec rake489 examples, 0 failures, 96% coverage, RuboCop clean.
  • 16 cop specs (expect_offense/expect_correction) including the sigil-interp
    live/de-escape cases and the nested-render clobber case.
  • 6 generator specs: .rubocop.yml created when absent, merged into omakase,
    appends to an existing require list, idempotent on re-run.
  • End-to-end against a fixture site + the dogfood docs/ app: cops fire,
    autocorrect, and respect the Include scope.

Note: the rubocop-rspec harness is scoped to the cop specs only. A global
require "rubocop/rspec/support" had shadowed the unrelated DocsKit::Registry
specs via CopHelper#registry under random ordering; fixed by including the
harness locally.

Out of scope

  • Deleting the consumer-repo copies + switching them to the gem require (their
    repos, a follow-up).
  • App-4's site-specific cops (they enforce a different UI kit).

https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX

## Summary

Both consumer sites carried a byte-identical hand-copied cop
(`DocsKit/RenderComponentPreferred`). Copy-paste cops drift and new sites
never got them. This upstreams the cops into the gem so a consuming site
requires them by gem path (two lines, scaffolded by the install generator)
and deletes its copies.

- Move `RenderComponentPreferred` into `lib/rubocop/cop/docs_kit/` and add
  one new cop, `EscapedInterpolationInHeredoc` (steer `\#{...}` escapes in a
  double-quoted heredoc to a single-quoted delimiter — the recurring escape
  tax every audited site paid).
- `lib/docs_kit/rubocop.rb` is the require entry point; it loads `rubocop`
  LAZILY, so RuboCop stays a development-time dependency of the host, never a
  runtime dependency of docs-kit. Added to the gemspec dev deps + zeitwerk
  `loader.ignore` (the file defines `RuboCop::Cop::DocsKit::*`, not a
  `DocsKit::Rubocop` constant).
- Ship `config/rubocop/docs_kit.yml` (both cops enabled, scoped to
  `app/views/docs/**/*`).
- Install generator + `docs-kit new` inject `require: docs_kit/rubocop` and
  `inherit_gem: { docs-kit: config/rubocop/docs_kit.yml }` into the site's
  `.rubocop.yml`, idempotently (created minimal when absent; merged — not
  clobbered — into an existing omakase config).
- README section + CHANGELOG entry.

## Autocorrect-safety fixes (found by adversarial verification)

- `EscapedInterpolationInHeredoc`: Ruby interpolates `#@ivar`/`#$global` too,
  not only `#{...}`. Live-detection now treats any non-`str` dstr child as
  live (was `begin`-only), so a delimiter swap can no longer freeze a live
  `#@name` into literal text. De-escaping now strips the backslash from every
  escaped form (`\#{`, `\#@`, `\#$`), not just `\#{`.
- `RenderComponentPreferred`: a kit render nested inside another kit render's
  block no longer produces overlapping corrections (Parser::ClobberingError).
  The correction now replaces only the `render X.new(...)` send, leaving the
  block in place, so the inner render corrects independently.

## Test Coverage

- 16 cop specs (expect_offense/expect_correction) incl. sigil-interpolation
  live/de-escape cases and the nested-render clobber case.
- 6 generator specs: `.rubocop.yml` created when absent, merged into omakase,
  appends to an existing require list, idempotent on re-run.
- Zeitwerk-safety specs: gem boots, no `DocsKit::Rubocop` autoload, cops load
  under the RuboCop namespace when required.
- Scoped the rubocop-rspec harness to the cop specs only (a global
  `require "rubocop/rspec/support"` had shadowed `DocsKit::Registry` specs via
  `CopHelper#registry` under random ordering).

## Verification

- [x] bundle exec rake (rspec + rubocop) — 489 examples, 0 failures, 96% cov
- [x] End-to-end: cops fire, autocorrect, and scope correctly on a fixture site
- [x] Real `rubocop -A`: live `#@ivar` reported "fix by hand", file unchanged;
      nested render corrects cleanly with no clobbering error

Claude-Session: https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX
@mhenrixon mhenrixon self-assigned this Jul 3, 2026
@mhenrixon mhenrixon merged commit e44e4a8 into main Jul 3, 2026
4 checks passed
@mhenrixon mhenrixon deleted the issue-22-rubocop-cops branch July 4, 2026 14:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(rubocop): ship the docs-kit RuboCop cops from the gem

1 participant