diff --git a/.rubocop.yml b/.rubocop.yml index f2deb02..3990e0d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -21,6 +21,26 @@ AllCops: Style/Documentation: Enabled: false +# The custom RuboCop cops ship rubocop as a gemspec DEVELOPMENT dependency on +# purpose: `docs_kit/rubocop` requires it lazily (never a runtime dep), and +# pinning it here lets the gem's own cop specs run. This is a deliberate design +# choice (see issue #22), not a stray dependency to move to the Gemfile. +Gemspec/DevelopmentDependencies: + Exclude: + - "docs-kit.gemspec" + +# lib/rubocop/cop/** holds RuboCop cops. RenderComponentPreferred is upstreamed +# VERBATIM from the proven consumer copy (issue #22 says: don't rewrite it), so +# its on_send node-matching dispatch trips the size/complexity metrics by design. +# Cops are inherently branchy AST walkers — exempt the tree from the two metrics +# it doesn't already cover elsewhere (AbcSize/MethodLength are extended below). +Metrics/CyclomaticComplexity: + Exclude: + - "lib/rubocop/cop/**/*" +Metrics/PerceivedComplexity: + Exclude: + - "lib/rubocop/cop/**/*" + # lib/docs-kit.rb must match the hyphenated gem name for Bundler's auto-require; # it just requires the underscored real entrypoint. Naming/FileName: @@ -49,6 +69,8 @@ Metrics/AbcSize: - "app/components/docs_ui/shell.rb" - "app/components/docs_ui/page.rb" - "lib/docs_kit/configuration.rb" + # Verbatim-upstreamed cop (issue #22) — its AST dispatch is branchy by design. + - "lib/rubocop/cop/**/*" # ApiRequest/ApiClient are Data value objects and RequestExample is a public # component; their keyword-arg constructors mirror the documented API (method:, @@ -70,6 +92,9 @@ Style/StringLiterals: Layout/LineLength: Max: 120 + Exclude: + # Verbatim-upstreamed cop (issue #22) — one add_offense line runs to 122. + - "lib/rubocop/cop/**/*" Metrics/BlockLength: Exclude: @@ -93,9 +118,15 @@ Metrics/MethodLength: # length is the knob count, not logic (AbcSize is excluded for the same reason). Exclude: - "lib/docs_kit/configuration.rb" + # Verbatim-upstreamed cop (issue #22) — on_send is one long dispatch method. + - "lib/rubocop/cop/**/*" RSpec/ExampleLength: Max: 12 + # Cop specs pair an expect_offense fixture with an expect_correction fixture, + # both multi-line heredocs — the example length is the fixture size, not logic. + Exclude: + - "spec/rubocop/**/*" RSpec/MultipleExpectations: Max: 6 diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ecc0f..4af7cff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,3 +14,10 @@ `all`/`from_slug`/`grouped` + the "authored" filter). - `DocsKit::NavItem` value object consumed by the sidebar. - `DocsKit::Controller#render_page` and a Rails engine that wires it. +- Custom RuboCop cops shipped from the gem (`require: docs_kit/rubocop` + + `inherit_gem: { docs-kit: config/rubocop/docs_kit.yml }`, wired automatically by + the install generator): `DocsKit/RenderComponentPreferred` (prefer the Phlex-kit + helper form `DocsUI::Code(...)` over `render DocsUI::Code.new(...)`) and + `DocsKit/EscapedInterpolationInHeredoc` (steer `\#{...}` escapes in a + double-quoted heredoc to a single-quoted delimiter). RuboCop stays a + development-time dependency of the host — never a runtime dependency. diff --git a/README.md b/README.md index 2b894d1..6ebfd58 100644 --- a/README.md +++ b/README.md @@ -582,6 +582,36 @@ bun install && bun run build:css Then add pages one command at a time — `rails g docs_kit:page "Title" --group=Guide` (see [Add a page](#add-a-page)). +## Lint — the docs-kit RuboCop cops + +docs-kit ships two custom cops so every site enforces the same authoring idioms +instead of hand-copying a cop file that drifts: + +- **`DocsKit/RenderComponentPreferred`** — prefers the Phlex-kit helper form + `DocsUI::Code(...)` over `render DocsUI::Code.new(...)` (autocorrectable). +- **`DocsKit/EscapedInterpolationInHeredoc`** — flags the `\#{...}` "escape tax" + inside a double-quoted heredoc and steers you to a single-quoted delimiter + (`<<~'RUBY'`), where `#{...}` is literal. Autocorrects when the heredoc has no + live interpolation; otherwise it reports and leaves the fix to you. + +Both are scoped to `app/views/docs/**/*` by default. The install generator wires +them into your `.rubocop.yml` automatically — two lines, merged idempotently +(your existing `inherit_gem` / `require` entries are preserved): + +```yaml +# .rubocop.yml +require: + - docs_kit/rubocop +inherit_gem: + docs-kit: config/rubocop/docs_kit.yml +``` + +RuboCop is a **development-time** dependency of your app, never a runtime +dependency of docs-kit — `docs_kit/rubocop` requires `rubocop` lazily. Every +generated site already has `rubocop` in its Gemfile (via `rubocop-rails-omakase` +from `rails new`); if yours doesn't, add `gem "rubocop"` to the `:development` +group. Then `bundle exec rubocop` runs the docs-kit cops. + ## Deploy a new docs site The build + deploy is defined **once** in this gem's reusable workflow diff --git a/config/rubocop/docs_kit.yml b/config/rubocop/docs_kit.yml new file mode 100644 index 0000000..38fc254 --- /dev/null +++ b/config/rubocop/docs_kit.yml @@ -0,0 +1,24 @@ +# docs-kit's custom RuboCop cops, shipped from the gem so consuming sites stop +# hand-copying them. Wire this into a site's `.rubocop.yml` with two lines +# (the install generator does it automatically): +# +# require: +# - docs_kit/rubocop +# inherit_gem: +# docs-kit: config/rubocop/docs_kit.yml +# +# Both cops are scoped to the docs page tree (app/views/docs/**/*) by default — +# that is where the kit-helper form and heredoc examples live. A site can widen +# or narrow the `Include` in its own `.rubocop.yml`. + +DocsKit/RenderComponentPreferred: + Description: "Prefer the Phlex-kit helper form (DocsUI::Code(...)) over `render DocsUI::Code.new(...)`." + Enabled: true + Include: + - "app/views/docs/**/*" + +DocsKit/EscapedInterpolationInHeredoc: + Description: "Use a single-quoted heredoc delimiter instead of escaping `\\#{...}` in a double-quoted one." + Enabled: true + Include: + - "app/views/docs/**/*" diff --git a/docs-kit.gemspec b/docs-kit.gemspec index 705a0c8..3253bd5 100644 --- a/docs-kit.gemspec +++ b/docs-kit.gemspec @@ -70,4 +70,11 @@ Gem::Specification.new do |s| # phlex-reactive (reactive demos) and pgbus (Postgres-SSE transport) are # intentionally NOT dependencies — they are optional, runtime-detected. A site # that wants reactive examples adds phlex-reactive itself. + + # RuboCop is a DEVELOPMENT-time dependency: docs-kit ships custom cops under + # lib/rubocop/cop/docs_kit/, but `require "docs_kit/rubocop"` loads rubocop + # lazily, so it is never pulled into a host app's runtime. A consuming site + # already has `rubocop` in its Gemfile (every generated site does) — that is + # what runs the shipped cops. Pinned here so the gem's own cop specs can run. + s.add_development_dependency "rubocop", ">= 1.75" end diff --git a/lib/docs_kit.rb b/lib/docs_kit.rb index 52cf819..ea4878c 100644 --- a/lib/docs_kit.rb +++ b/lib/docs_kit.rb @@ -54,6 +54,11 @@ module DocsUI loader.push_dir(File.expand_path("../app/components/docs_ui", __dir__), namespace: DocsUI) loader.ignore(File.expand_path("docs_kit/version.rb", __dir__)) loader.ignore(File.expand_path("docs_kit/configuration.rb", __dir__)) +# docs_kit/rubocop.rb is the RuboCop-cop entry point: it defines cops under +# RuboCop::Cop::DocsKit::*, not a DocsKit::Rubocop constant, so zeitwerk must not +# manage it. It (and the cops under lib/rubocop/, which are outside the loader's +# push_dirs entirely) load only when a `.rubocop.yml` requires "docs_kit/rubocop". +loader.ignore(File.expand_path("docs_kit/rubocop.rb", __dir__)) # engine.rb is required explicitly below only under Rails, so zeitwerk never # manages it (it would otherwise expect a DocsKit::Engine constant outside Rails). loader.ignore(File.expand_path("docs_kit/engine.rb", __dir__)) diff --git a/lib/docs_kit/rubocop.rb b/lib/docs_kit/rubocop.rb new file mode 100644 index 0000000..6395655 --- /dev/null +++ b/lib/docs_kit/rubocop.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# Entry point for docs-kit's custom RuboCop cops. A consuming site loads them +# with a single line in its `.rubocop.yml`: +# +# require: +# - docs_kit/rubocop +# inherit_gem: +# docs-kit: config/rubocop/docs_kit.yml +# +# RuboCop is required LAZILY here — it is a development-time dependency of the +# HOST app (every generated docs site has `rubocop` in its Gemfile), never a +# runtime dependency of docs-kit itself. Requiring this file outside a RuboCop +# run (e.g. if a stray `require` reaches it) still works: it pulls in rubocop on +# demand rather than assuming it is already loaded. +require "rubocop" + +require_relative "../rubocop/cop/docs_kit/render_component_preferred" +require_relative "../rubocop/cop/docs_kit/escaped_interpolation_in_heredoc" diff --git a/lib/generators/docs_kit/install/install_generator.rb b/lib/generators/docs_kit/install/install_generator.rb index 0f55e08..98a6b42 100644 --- a/lib/generators/docs_kit/install/install_generator.rb +++ b/lib/generators/docs_kit/install/install_generator.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "erb" +require "yaml" require "rails/generators/base" module DocsKit @@ -29,6 +30,27 @@ class InstallGenerator < ::Rails::Generators::Base AGENTS_END = "" AGENTS_BLOCK_RE = /#{Regexp.escape(AGENTS_BEGIN)}.*#{Regexp.escape(AGENTS_END)}/m + # The RuboCop wiring docs-kit injects. REQUIRE loads the cops; + # INHERIT_GEM/INHERIT_PATH enable + scope them (see config/rubocop/docs_kit.yml). + RUBOCOP_REQUIRE = "docs_kit/rubocop" + RUBOCOP_INHERIT_GEM = "docs-kit" + RUBOCOP_INHERIT_PATH = "config/rubocop/docs_kit.yml" + + # The .rubocop.yml written when a site has none yet. + RUBOCOP_STARTER = <<~YAML.freeze + # docs-kit ships its custom cops from the gem — load + enable them here. + # (RuboCop is a development-time dependency; add `gem "rubocop"` to your + # Gemfile if it isn't there.) + require: + - #{RUBOCOP_REQUIRE} + + inherit_gem: + #{RUBOCOP_INHERIT_GEM}: #{RUBOCOP_INHERIT_PATH} + + AllCops: + NewCops: enable + YAML + def create_phlex_initializer # Phlex autoload namespaces (Views:: / Components::). Skip if the app # already configures phlex-rails so we don't clobber a bespoke setup. @@ -134,6 +156,25 @@ def create_agent_docs write_write_docs_page_skill end + # Wire docs-kit's shipped RuboCop cops into the site's .rubocop.yml: a + # `require: docs_kit/rubocop` entry (loads the cops) plus an + # `inherit_gem: { docs-kit: config/rubocop/docs_kit.yml }` entry (enables + # + scopes them). RuboCop is a dev-time dependency the host already has — + # docs-kit never requires it at runtime. Created minimal when absent, + # MERGED into an existing config (a `rails new` app ships an omakase + # inherit_gem we must not drop), and idempotent on re-run. + def wire_rubocop_cops + path = File.join(destination_root, ".rubocop.yml") + return create_file(".rubocop.yml", RUBOCOP_STARTER) unless File.exist?(path) + + existing = File.read(path) + merged = merge_rubocop_config(existing) + return say_status(:identical, ".rubocop.yml", :blue) if merged == existing + + File.write(path, merged) + say_status(:update, ".rubocop.yml (docs-kit cops)", :green) + end + def register_stimulus_controller index = stimulus_index_path return say_status(:skip, "no controllers/index.js — add: #{REGISTER_LINE}", :yellow) unless index @@ -206,6 +247,34 @@ def write_write_docs_page_skill create_file skill, render_template("skill.md.erb") end + # Merge docs-kit's require + inherit_gem entries into an existing + # .rubocop.yml, preserving everything else. Idempotent: entries already + # present are left untouched, so re-running yields byte-identical output. + # Returns the (possibly unchanged) YAML string. + def merge_rubocop_config(existing) + config = YAML.safe_load(existing) || {} + config = {} unless config.is_a?(Hash) + + config["require"] = ensure_in_list(config["require"], RUBOCOP_REQUIRE) + + inherit_gem = config["inherit_gem"].is_a?(Hash) ? config["inherit_gem"] : {} + inherit_gem[RUBOCOP_INHERIT_GEM] = ensure_in_list(inherit_gem[RUBOCOP_INHERIT_GEM], RUBOCOP_INHERIT_PATH) + config["inherit_gem"] = inherit_gem + + # Round-trip through the same load the merge started from: if nothing + # changed, return the original text verbatim (so :identical is reported + # and re-runs don't churn formatting). + YAML.safe_load(existing) == config ? existing : YAML.dump(config) + end + + # Normalise a RuboCop scalar-or-list field to an array and append `value` + # unless already present. `nil` (absent key) becomes `[value]`; a bare + # string is promoted to a list so we never drop the site's own entry. + def ensure_in_list(current, value) + list = Array(current) + list.include?(value) ? list : list + [value] + end + # Render an ERB template from source_root against the generator binding, so # helpers like app_brand resolve — used where we need the rendered string in # memory (block extraction/merge) rather than Thor's file-to-file `template`. diff --git a/lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb b/lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb new file mode 100644 index 0000000..6ad82cd --- /dev/null +++ b/lib/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module DocsKit + # Flags an ESCAPED interpolation (`\#{...}`) inside a double-quoted heredoc + # and steers to the single-quoted delimiter (`<<~'RUBY'`), where `#{...}` is + # already literal so no backslash is needed. + # + # Docs pages constantly embed Ruby examples that themselves contain + # `#{...}`. In a double-quoted heredoc every one of those has to be escaped + # as `\#{...}` or Ruby interpolates it — the recurring "escape tax" every + # audited docs site paid. A single-quoted heredoc delimiter turns the whole + # body literal, so the examples read exactly as they will render. + # + # Ruby interpolates three sigils in a double-quoted string — `#{expr}`, + # `#@ivar` (also `#@@cvar`), and `#$global` — so the cop treats all three + # escape forms (`\#{`, `\#@`, `\#$`) as the escape tax, and a LIVE (unescaped) + # occurrence of any of them blocks autocorrection. + # + # @example + # # bad — escape tax + # source = <<~RUBY + # puts "hello \#{name}" + # RUBY + # + # # good — single-quoted delimiter, `#{...}` is literal + # source = <<~'RUBY' + # puts "hello #{name}" + # RUBY + # + # Autocorrection is UNSAFE and only offered when the heredoc has no LIVE + # (unescaped) interpolation: switching the delimiter to single-quoted would + # freeze a live interpolation into literal text, changing behaviour. When a + # live interpolation is present the cop reports but leaves the fix to a human. + class EscapedInterpolationInHeredoc < Base + extend AutoCorrector + + MSG = "Use a single-quoted heredoc delimiter (`%s`) so " \ + "`\#{...}` is literal without escaping." + MSG_LIVE = "#{MSG} This heredoc also has a live interpolation — fix by hand.".freeze + + # A backslash directly in front of an interpolation opener. Ruby opens an + # interpolation with `#{`, `#@` (ivar/cvar), or `#$` (global), so an escape + # is a backslash + `#` + one of those sigil characters. The lookahead keeps + # the sigil out of the match, so de-escaping only strips the backslash. + ESCAPED_INTERPOLATION = /\\#(?=[{@$])/ + + def on_str(node) + check_heredoc(node) + end + + # A heredoc with a live `#{...}` parses as a dstr; the escaped ones inside + # it still show up in the body source. Same check. + def on_dstr(node) + check_heredoc(node) + end + + private + + def check_heredoc(node) + return unless node.heredoc? + + opening = node.loc.expression + return if single_quoted?(opening.source) + return unless escaped_interpolation?(node) + + live = live_interpolation?(node) + add_offense(opening, message: message(opening.source, live: live)) do |corrector| + next if live # unsafe to autocorrect — a live #{...} would freeze + + autocorrect(corrector, node, opening) + end + end + + def message(delimiter, live:) + format(live ? MSG_LIVE : MSG, delimiter: single_quote(delimiter)) + end + + # `<<~RUBY` → `<<~'RUBY'`. The prefix (`<<`, `<<~`, or `<<-`) is kept; only + # the identifier gets wrapped in single quotes. + def single_quote(delimiter) + delimiter.sub(/(<<[~-]?)(\w+)\z/, "\\1'\\2'") + end + + def single_quoted?(delimiter) + delimiter.include?("'") + end + + def escaped_interpolation?(node) + node.loc.heredoc_body.source.match?(ESCAPED_INTERPOLATION) + end + + # A live interpolation makes the heredoc a dstr whose children include a + # non-`str` node: `#{expr}` → a `begin` child, `#@ivar` → an `ivar` child, + # `#$global` → a `gvar` child. Escaped forms stay inside plain `str` + # children, so a str heredoc — or a dstr of only `str` children — is safe + # to convert. (Checking "not a str" rather than enumerating begin/ivar/gvar + # covers every interpolation node the parser can emit.) + def live_interpolation?(node) + node.dstr_type? && node.children.any? { |child| !child.str_type? } + end + + # Swap the opening delimiter for its single-quoted form and drop the + # backslash from every escaped interpolation (`\#{`, `\#@`, `\#$`) in the + # body — the single-quoted delimiter already makes each one literal, and + # leaving a backslash behind would turn an escape-consumed byte into a + # literal one, changing the string. + def autocorrect(corrector, node, opening) + corrector.replace(opening, single_quote(opening.source)) + + body = node.loc.heredoc_body + unescaped = body.source.gsub(ESCAPED_INTERPOLATION, "#") + corrector.replace(body, unescaped) + end + end + end + end +end diff --git a/lib/rubocop/cop/docs_kit/render_component_preferred.rb b/lib/rubocop/cop/docs_kit/render_component_preferred.rb new file mode 100644 index 0000000..af4a78e --- /dev/null +++ b/lib/rubocop/cop/docs_kit/render_component_preferred.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module DocsKit + # Enforces the kit helper form over `render ::.new(...)`. + # + # The docs-kit `DocsUI` module and the `DaisyUI` gem are both extended with + # `Phlex::Kit`, which defines a singleton method per component class. That + # makes `DocsUI::Code(...)` equivalent to `render DocsUI::Code.new(...)` but + # terser and consistent. Adapted from cosmos' Cosmos/RenderComponentPreferred. + # + # @example + # # bad + # render DocsUI::Code.new(source, filename: "a.rb") + # render DocsUI::Section.new("Title") { ... } + # render DaisyUI::Button.new(:primary) { "Save" } + # + # # good + # DocsUI::Code(source, filename: "a.rb") + # DocsUI::Section("Title") { ... } + # DaisyUI::Button(:primary) { "Save" } + # + # The cop keeps the namespace prefix (`DocsUI::Code(...)` rather than + # `Code(...)`) because the unqualified helper may resolve to a different kit + # depending on inclusion order. Keeping the prefix makes the rewrite + # mechanically safe in every rendering context. + # + # Contexts the cop does NOT fire in: + # - `render ` like `render UI::Modal.clear` — not a .new. + # - elements of a `turbo_stream: [...]` array — class-method calls that + # return Turbo Stream payloads, not `.new` component instances. + class RenderComponentPreferred < Base + extend AutoCorrector + + MSG = "Use `%s` instead of `%s`." + + # Kit modules recognised by the cop. + KIT_MODULES = %w[ + DocsUI + DaisyUI + ].to_set.freeze + + # `render Kit::Class.new(args)` — plain send. + def_node_matcher :render_new_send, <<~PATTERN + (send nil? :render $(send $const :new ...)) + PATTERN + + # `render Kit::Class.new(args) { ... }` — brace block glued onto .new. + def_node_matcher :render_new_block, <<~PATTERN + (send nil? :render (block $(send $const :new ...) _ _)) + PATTERN + + def on_send(node) + return if inside_array_literal?(node) + return unless node.arguments.length == 1 + + match = render_new_send(node) || render_new_block(node) + return unless match + + new_call_node, const_node = match + + namespace = kit_namespace(const_node) + return unless namespace + + helper_headline = helper_headline(new_call_node, const_node) + original_headline = "render #{new_call_node.source}" + + add_offense(node, message: format(MSG, suggestion: helper_headline, original: original_headline)) do |corrector| + # Replace ONLY `render ::.new(args)` with the helper call, + # leaving any trailing block (`do...end` or `{ ... }`) untouched. That + # keeps the rewrite range off the block body, so a nested kit render + # inside the block corrects independently instead of clobbering. + range = node.source_range.begin.join(new_call_node.source_range.end) + corrector.replace(range, helper_headline) + end + end + + private + + def inside_array_literal?(node) + node.parent&.array_type? + end + + def kit_namespace(const_node) + segments = const_segments(const_node) + return nil if segments.nil? || segments.length < 2 + + KIT_MODULES.include?(segments.first) ? segments.first : nil + end + + def const_segments(node) + parts = [] + cur = node + while cur&.const_type? + parts.unshift(cur.short_name.to_s) + cur = cur.children.first + end + parts + end + + # `DocsUI::Code(args)` — the helper form the render becomes. This is both + # the offense-message suggestion and the exact replacement text (the block, + # if any, is preserved separately by keeping the rewrite range off it). + def helper_headline(new_call_node, const_node) + args_source = call_args_source(new_call_node) + prefix = const_node.source + args_source.empty? ? "#{prefix}()" : "#{prefix}(#{args_source})" + end + + def call_args_source(new_call_node) + return "" if new_call_node.arguments.empty? + + first = new_call_node.arguments.first + last = new_call_node.arguments.last + first_pos = first.source_range.begin_pos + last_pos = last.source_range.end_pos + new_call_node.source_range.source_buffer.source[first_pos...last_pos] + end + end + end + end +end diff --git a/spec/docs_kit_spec.rb b/spec/docs_kit_spec.rb index e13c4ad..8b29635 100644 --- a/spec/docs_kit_spec.rb +++ b/spec/docs_kit_spec.rb @@ -96,4 +96,38 @@ expect(DocsUI::Code.ancestors).to include(Phlex::SGML) end end + + # The RuboCop cops live under lib/rubocop/cop/docs_kit/ — OUTSIDE the gem's + # zeitwerk push_dirs (lib/docs_kit + app/components/docs_ui) — and the cop + # entry point lib/docs_kit/rubocop.rb defines RuboCop::Cop::DocsKit::*, not a + # DocsKit::Rubocop constant, so it is explicitly ignored. The gem's loader + # must therefore never try to autoload them (which would raise a Zeitwerk + # NameError the moment the const was referenced). + describe "the shipped RuboCop cops" do + let(:loader) do + found = nil + Zeitwerk::Registry.loaders.each { |l| found = l if l.tag == "docs_kit" } + found + end + + it "boots the gem's zeitwerk loader" do + expect(loader).not_to be_nil + end + + it "does not register a DocsKit::Rubocop autoload for the cop entry point" do + # If lib/docs_kit/rubocop.rb were NOT ignored, zeitwerk would set an + # autoload for DocsKit::Rubocop and raise a NameError (mismatched constant) + # the instant it was referenced. Ignored => plain uninitialized constant. + expect(described_class.autoload?(:Rubocop)).to be_nil + expect { DocsKit::Rubocop }.to raise_error(NameError) + expect(described_class.const_defined?(:Rubocop, false)).to be(false) + end + + it "loads the cops under the RuboCop namespace when the entry point is required" do + require "docs_kit/rubocop" + + expect(RuboCop::Cop::DocsKit::RenderComponentPreferred.ancestors).to include(RuboCop::Cop::Base) + expect(RuboCop::Cop::DocsKit::EscapedInterpolationInHeredoc.ancestors).to include(RuboCop::Cop::Base) + end + end end diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index 0d2db7d..66a1ba5 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -33,7 +33,7 @@ def stimulus_index_source end # Build a minimal Rails-ish skeleton the generator's injections expect to find. - def build_skeleton(routes: true, app_controller: true, stimulus_index: true, package_json: nil) + def build_skeleton(routes: true, app_controller: true, stimulus_index: true, package_json: nil, rubocop_yml: nil) FileUtils.mkdir_p(File.join(destination, "config/initializers")) FileUtils.mkdir_p(File.join(destination, "app/controllers")) FileUtils.mkdir_p(File.join(destination, "app/javascript/controllers")) @@ -46,6 +46,7 @@ def build_skeleton(routes: true, app_controller: true, stimulus_index: true, pac end write("app/javascript/controllers/index.js", stimulus_index_source) if stimulus_index write("package.json", package_json) if package_json + write(".rubocop.yml", rubocop_yml) if rubocop_yml end def write(rel, content) @@ -379,4 +380,89 @@ def silence_stream end end end + + # The RuboCop wiring: the site's .rubocop.yml gets `require: docs_kit/rubocop` + # and `inherit_gem: { docs-kit: config/rubocop/docs_kit.yml }` so the gem's + # cops run. Created minimal when absent; merged (not clobbered) into an + # existing one; idempotent on re-run. + describe "RuboCop cop wiring (wire_rubocop_cops)" do + def rubocop_config + require "yaml" + YAML.safe_load(read(".rubocop.yml")) + end + + context "when the site has no .rubocop.yml" do + before do + build_skeleton + run_generator + end + + it "creates one that requires the gem cop entry point" do + expect(rubocop_config["require"]).to include("docs_kit/rubocop") + end + + it "inherits the shipped cop config from the gem" do + expect(rubocop_config.dig("inherit_gem", "docs-kit")).to include("config/rubocop/docs_kit.yml") + end + end + + context "when the site already has a .rubocop.yml (e.g. rails new omakase)" do + let(:omakase) do + <<~YAML + # Omakase Ruby styling for Rails + inherit_gem: { rubocop-rails-omakase: rubocop.yml } + YAML + end + + before do + build_skeleton(rubocop_yml: omakase) + run_generator + end + + it "adds the docs-kit cop require without dropping the existing inherit_gem" do + config = rubocop_config + expect(config["require"]).to include("docs_kit/rubocop") + expect(config.dig("inherit_gem", "rubocop-rails-omakase")).to eq("rubocop.yml") + expect(config.dig("inherit_gem", "docs-kit")).to include("config/rubocop/docs_kit.yml") + end + end + + context "when the site's .rubocop.yml already has a require list" do + let(:existing) do + <<~YAML + require: + - rubocop-rspec + AllCops: + NewCops: enable + YAML + end + + before do + build_skeleton(rubocop_yml: existing) + run_generator + end + + it "appends to the existing require list rather than replacing it" do + requires = rubocop_config["require"] + expect(requires).to include("rubocop-rspec") + expect(requires).to include("docs_kit/rubocop") + end + end + + context "when re-run (idempotence)" do + before do + build_skeleton + run_generator + run_generator + end + + it "does not duplicate the docs_kit/rubocop require" do + expect(rubocop_config["require"].count("docs_kit/rubocop")).to eq(1) + end + + it "does not duplicate the docs-kit inherit_gem entry" do + expect(rubocop_config.dig("inherit_gem", "docs-kit").count("config/rubocop/docs_kit.yml")).to eq(1) + end + end + end end diff --git a/spec/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc_spec.rb b/spec/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc_spec.rb new file mode 100644 index 0000000..ab6cb58 --- /dev/null +++ b/spec/rubocop/cop/docs_kit/escaped_interpolation_in_heredoc_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require "docs_kit/rubocop" +require_relative "../../cop_spec_helper" + +# The new cop. Docs pages routinely embed Ruby examples that contain literal +# `#{...}` — inside a double-quoted heredoc that means every interpolation has to +# be escaped as `\#{...}`, an "escape tax" that recurred in every audited site. +# The fix is a single-quoted heredoc delimiter (`<<~'RUBY'`), where `#{...}` is +# already literal. The cop flags `\#{` in a double-quoted heredoc and, when the +# heredoc has no LIVE interpolation, autocorrects to the single-quoted delimiter +# with the backslashes stripped. +# +# NOTE: the fixtures under test use `RUBY` as their heredoc tag, so the outer +# expect_* heredocs use a DIFFERENT tag (`CODE`) — otherwise the inner `RUBY` +# terminator would close the outer heredoc early. They are single-quoted +# (`<<~'CODE'`) so `\#{` and `#{` in the fixtures stay literal. +RSpec.describe RuboCop::Cop::DocsKit::EscapedInterpolationInHeredoc do + include_context "with cop spec support" + + context "with an escaped interpolation in a squiggly double-quoted heredoc" do + it "registers an offense on the heredoc delimiter" do + expect_offense(<<~'CODE') + source = <<~RUBY + ^^^^^^^ Use a single-quoted heredoc delimiter (`<<~'RUBY'`) so `#{...}` is literal without escaping. + puts "hello \#{name}" + RUBY + CODE + end + + it "autocorrects to a single-quoted delimiter and strips the backslash" do + expect_offense(<<~'CODE') + source = <<~RUBY + ^^^^^^^ Use a single-quoted heredoc delimiter (`<<~'RUBY'`) so `#{...}` is literal without escaping. + puts "hello \#{name}" + RUBY + CODE + + expect_correction(<<~'CODE') + source = <<~'RUBY' + puts "hello #{name}" + RUBY + CODE + end + end + + context "with an escaped interpolation in a dash double-quoted heredoc" do + it "registers an offense and autocorrects the delimiter" do + expect_offense(<<~'CODE') + source = <<-RUBY + ^^^^^^^ Use a single-quoted heredoc delimiter (`<<-'RUBY'`) so `#{...}` is literal without escaping. + val = \#{x} + RUBY + CODE + + expect_correction(<<~'CODE') + source = <<-'RUBY' + val = #{x} + RUBY + CODE + end + end + + context "when the heredoc also contains a LIVE (unescaped) interpolation" do + it "reports the offense but does NOT autocorrect (delimiter swap would break the live one)" do + expect_offense(<<~'CODE') + source = <<~RUBY + ^^^^^^^ Use a single-quoted heredoc delimiter (`<<~'RUBY'`) so `#{...}` is literal without escaping. This heredoc also has a live interpolation — fix by hand. + literal = \#{keep_me} + live = #{value} + RUBY + CODE + + expect_no_corrections + end + end + + # Ruby interpolates `#@ivar` and `#$global` in a double-quoted heredoc too, not + # only `#{...}`. A delimiter swap would freeze those live interpolations into + # literal text — the cop must recognise them as live and refuse to autocorrect. + context "when the live interpolation is an ivar sigil form (not a brace)" do + it "reports it as live and does NOT autocorrect" do + expect_offense(<<~'CODE') + source = <<~RUBY + ^^^^^^^ Use a single-quoted heredoc delimiter (`<<~'RUBY'`) so `#{...}` is literal without escaping. This heredoc also has a live interpolation — fix by hand. + literal = \#{keep_me} + live = #@name + RUBY + CODE + + expect_no_corrections + end + end + + context "when the live interpolation is a global-variable sigil form" do + it "reports it as live and does NOT autocorrect" do + expect_offense(<<~'CODE') + source = <<~RUBY + ^^^^^^^ Use a single-quoted heredoc delimiter (`<<~'RUBY'`) so `#{...}` is literal without escaping. This heredoc also has a live interpolation — fix by hand. + literal = \#{keep_me} + live = #$stdout + RUBY + CODE + + expect_no_corrections + end + end + + # When converting to a single-quoted delimiter, EVERY escaped interpolation + # form must lose its backslash — not just `\#{`. Otherwise `\#@name` keeps a + # backslash that was escape-consumed in the double-quoted original, silently + # changing the string bytes. + context "with escaped brace AND escaped sigil interpolations in one body" do + it "fires and de-escapes both when there is no live interpolation" do + expect_offense(<<~'CODE') + source = <<~RUBY + ^^^^^^^ Use a single-quoted heredoc delimiter (`<<~'RUBY'`) so `#{...}` is literal without escaping. + braced = \#{x} + ivar = \#@name + RUBY + CODE + + expect_correction(<<~'CODE') + source = <<~'RUBY' + braced = #{x} + ivar = #@name + RUBY + CODE + end + end + + context "with only an escaped sigil interpolation (no escaped brace)" do + it "still fires and de-escapes it" do + expect_offense(<<~'CODE') + source = <<~RUBY + ^^^^^^^ Use a single-quoted heredoc delimiter (`<<~'RUBY'`) so `#{...}` is literal without escaping. + ivar = \#@name + RUBY + CODE + + expect_correction(<<~'CODE') + source = <<~'RUBY' + ivar = #@name + RUBY + CODE + end + end + + context "with a single-quoted heredoc delimiter (the recommended form)" do + it "does not fire — interpolation is already literal, no escape needed" do + expect_no_offenses(<<~'CODE') + source = <<~'RUBY' + puts "hello #{name}" + RUBY + CODE + end + end + + context "with a double-quoted heredoc that has no escaped interpolation" do + it "does not fire (nothing to un-escape)" do + expect_no_offenses(<<~'CODE') + source = <<~RUBY + puts "plain text, live #{value}" + RUBY + CODE + end + end +end diff --git a/spec/rubocop/cop/docs_kit/render_component_preferred_spec.rb b/spec/rubocop/cop/docs_kit/render_component_preferred_spec.rb new file mode 100644 index 0000000..9e7c179 --- /dev/null +++ b/spec/rubocop/cop/docs_kit/render_component_preferred_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "docs_kit/rubocop" +require_relative "../../cop_spec_helper" + +# The cop is proven in the consuming sites; these specs pin its contract so the +# gem copy never regresses. It enforces the Phlex-kit helper form +# (`DocsUI::Code(...)`) over `render DocsUI::Code.new(...)` for the DocsUI and +# DaisyUI kit modules, keeping the namespace prefix so the rewrite is safe in +# every rendering context. +RSpec.describe RuboCop::Cop::DocsKit::RenderComponentPreferred do + include_context "with cop spec support" + + context "with a plain `render Kit::Class.new(...)`" do + it "registers an offense and autocorrects to the kit helper form" do + expect_offense(<<~RUBY) + render DocsUI::Code.new(source, filename: "a.rb") + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `DocsUI::Code(source, filename: "a.rb")` instead of `render DocsUI::Code.new(source, filename: "a.rb")`. + RUBY + + expect_correction(<<~RUBY) + DocsUI::Code(source, filename: "a.rb") + RUBY + end + end + + context "with a no-argument component" do + it "keeps the empty-parens helper form" do + expect_offense(<<~RUBY) + render DocsUI::OnThisPage.new + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `DocsUI::OnThisPage()` instead of `render DocsUI::OnThisPage.new`. + RUBY + + expect_correction(<<~RUBY) + DocsUI::OnThisPage() + RUBY + end + end + + context "with a brace block glued onto .new" do + it "moves the block onto the helper call" do + expect_offense(<<~RUBY) + render DocsUI::Section.new("Title") { text "body" } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `DocsUI::Section("Title")` instead of `render DocsUI::Section.new("Title")`. + RUBY + + expect_correction(<<~RUBY) + DocsUI::Section("Title") { text "body" } + RUBY + end + end + + context "with a do...end block on the render call" do + it "moves the block onto the helper call" do + expect_offense(<<~RUBY) + render DocsUI::Section.new("Title") do + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `DocsUI::Section("Title")` instead of `render DocsUI::Section.new("Title")`. + text "body" + end + RUBY + + expect_correction(<<~RUBY) + DocsUI::Section("Title") do + text "body" + end + RUBY + end + end + + context "with a kit render nested inside another kit render's block" do + # A very common Phlex idiom. Both offend; the outer correction must replace + # only its own send (not the whole block, which spans the inner render), or + # the two rewrites overlap and RuboCop raises a clobbering error. + it "corrects both without overlapping rewrites" do + expect_offense(<<~RUBY) + render DocsUI::Section.new("Title") do + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `DocsUI::Section("Title")` instead of `render DocsUI::Section.new("Title")`. + render DocsUI::Code.new(source) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `DocsUI::Code(source)` instead of `render DocsUI::Code.new(source)`. + end + RUBY + + expect_correction(<<~RUBY) + DocsUI::Section("Title") do + DocsUI::Code(source) + end + RUBY + end + end + + context "with the DaisyUI kit module" do + it "also fires (DaisyUI is a recognised kit)" do + expect_offense(<<~RUBY) + render DaisyUI::Button.new(:primary) { "Save" } + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `DaisyUI::Button(:primary)` instead of `render DaisyUI::Button.new(:primary)`. + RUBY + + expect_correction(<<~RUBY) + DaisyUI::Button(:primary) { "Save" } + RUBY + end + end + + context "when the render target is not a recognised kit" do + it "does not fire for a plain component" do + expect_no_offenses(<<~RUBY) + render SomeComponent.new(:x) + RUBY + end + + it "does not fire for an unqualified kit-looking constant" do + # The cop requires the two-segment namespaced form (Kit::Class); a bare + # constant may resolve to a different kit depending on inclusion order. + expect_no_offenses(<<~RUBY) + render Code.new(source) + RUBY + end + end + + context "when the render argument is not a .new call" do + it "ignores a class-method call like `render UI::Modal.clear`" do + expect_no_offenses(<<~RUBY) + render DocsUI::Modal.clear + RUBY + end + end + + context "when the .new call is an element of an array literal" do + it "ignores it (turbo_stream: [...] payloads are class-method calls, not instances)" do + expect_no_offenses(<<~RUBY) + render turbo_stream: [DocsUI::Code.new(source)] + RUBY + end + end + + context "when render is given more than one argument" do + it "does not fire (a single component instance is required)" do + expect_no_offenses(<<~RUBY) + render DocsUI::Code.new(source), layout: false + RUBY + end + end +end diff --git a/spec/rubocop/cop_spec_helper.rb b/spec/rubocop/cop_spec_helper.rb new file mode 100644 index 0000000..0947c36 --- /dev/null +++ b/spec/rubocop/cop_spec_helper.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Cop-spec support, scoped to the cop specs only. +# +# We deliberately do NOT `require "rubocop/rspec/support"`: that file calls +# RSpec.configure and globally `config.include`s CopHelper + ExpectOffense into +# EVERY example group. CopHelper defines a `registry` method, which shadows the +# unrelated `let(:registry)` in spec/docs_kit/registry_spec.rb (DocsKit::Registry +# is a different Registry) and breaks it under random ordering. Requiring the +# modules directly and mixing them in LOCALLY keeps the rubocop-rspec harness +# contained to the groups that opt in via `include_context "cop spec"`. +require "rubocop" +require "rubocop/rspec/cop_helper" +require "rubocop/rspec/expect_offense" +require "rubocop/rspec/shared_contexts" + +RSpec.shared_context "with cop spec support" do + include CopHelper + include RuboCop::RSpec::ExpectOffense + + include_context "config" +end