8000 adding rubocopy support as ruby plugin by ghouston · Pull Request #150 · habit-hooks/habit-hooks · GitHub
[go: up one dir, main page]

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
df8a312
adding rubocopy support as ruby plugin
ghouston Aug 25, 2026
e78a7e0
update ci rubocop version. unslop docs and comments
ghouston Aug 25, 2026
1cc446e
added more coaching of ruby cops
ghouston Aug 25, 2026
4fa87c9
add details about generic sensors and some uncoached rubocops
ghouston Aug 25, 2026
dbca798
provided some ruby specific guides to override the generic guides
ghouston Aug 25, 2026
b340fa6
add first draft of custom rubocop line level details
ghouston Aug 26, 2026
59043d0
improved ruby guides, used pococks writing_for_agents skill plus addi…
ghouston Aug 26, 2026
e0e48d3
add rubocop [Correctable] output to issue source
ghouston Aug 26, 2026
c5a0341
improve ruby's readme and uncoached
ghouston Aug 26, 2026
726d92d
improve ruby's README.md install instructions
ghouston Aug 27, 2026
c2e3037
Merge branch 'main' into add-rubocop-support
ghouston Aug 28, 2026
f6d5e3f
ci: install rubocop with a managed Ruby
devill Aug 28, 2026
94df522
refactor: split RuboCop report shaping from execution
devill Aug 28, 2026
6e1c7f3
fix: keep RuboCop output and paths literal
devill Aug 28, 2026
dc21bb1
fix: exclude non-project Ruby source shapes
devill Aug 28, 2026
3eac7b8
fix: route uncoached guides by finding language
devill Aug 28, 2026
1cd3918
fix for a cop that raises an exception
ghouston Aug 28, 2026
4787c0a
feat: let detectors declare project search paths
devill Aug 28, 2026
c0ba3e9
fix: validate detector search paths
devill Aug 28, 2026
2486d51
fix: resolve bare programs through their detectors
devill Aug 28, 2026
66b0a8c
prompts: keep only the ruby guides Ruby forces
devill Aug 29, 2026
81ebbe9
Merge remote-tracking branch 'upstream/add-rubocop-support' into add-…
ghouston Sep 4, 2026
0ada0cd
major update to ruby plugins README.md
ghouston Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,26 @@ jobs:
}
Expand-Archive "$env:RUNNER_TEMP\pmd.zip" -DestinationPath "$env:RUNNER_TEMP\pmd"
"$env:RUNNER_TEMP\pmd\pmd-bin-7.26.0\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
# The ruby plugin's sensor and its spec cases need the real rubocop. The
# gem's version is pinned for the reason PMD's is: an unpinned lint tool
# turns every new cop into a CI failure on a commit that did not cause it.
# Bumping it means changing the one line below.
#
# setup-ruby rather than the image's preinstalled Ruby, on both platforms.
# rubocop 1.89 pulls rubocop-ast 1.50, which depends on prism — and prism
# ships no precompiled gems at all (only the `ruby` platform source gem,
# unlike nokogiri), so its C extension compiles on every install. The
# system Ruby on ubuntu cannot write /var/lib/gems (Gem::FilePermissionError),
# and on Windows its extconf picks Git-for-Windows' MSYS make, which reads
# `C:/...` as `/C/...` and dies with "No rule to make target". setup-ruby
# owns a runner-writable toolcache and wires the MSYS2 mingw toolchain in
# on Windows, so the one gem install works unchanged on both legs.
- name: Setup Ruby
uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
with:
ruby-version: '3.4'
- name: Install rubocop
run: gem install --no-document rubocop -v 1.89.0
Comment thread
ghouston marked this conversation as resolved.
Resolved
- name: Test
run: uv run pytest
# Dogfooding: habit-hooks gates its own source. Without this the tool can
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
10000
Original file line number Diff line numberDiff line change
Expand Up @@ -34,6 +34,7 @@ jobs:
- { dist: habit_hooks_typescript, environment: pypi-typescript }
- { dist: habit_hooks_php, environment: pypi-php }
- { dist: habit_hooks_java, environment: pypi-java }
- { dist: habit_hooks_ruby, environment: pypi-ruby }
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
Expand Down
2 changes: 2 additions & 0 deletions .habit-hooks/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ files = [
"plugins/java/tests/**/*.py",
"plugins/php/src/**/*.py",
"plugins/php/tests/**/*.py",
"plugins/ruby/src/**/*.py",
"plugins/ruby/tests/**/*.py",
"plugins/typescript/src/**/*.py",
"plugins/typescript/tests/**/*.py",
]
42 changes: 42 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,47 @@
# Changelog

## Unreleased

Ruby. A `Gemfile` or a `.rubocop.yml` in your project and habit-hooks now
coaches your Ruby the way it already coached your Python and TypeScript.

### Added

- **The `ruby` plugin.** `pip install "habit-hooks[ruby]"`, add `"ruby"` to
`plugins`, and a `rubocop` sensor translates cop names into the same smell
vocabulary every other plugin speaks: `too-many-parameters`,
`oversized-function`, `oversized-block`, `high-complexity`, `deep-nesting`,
`unused-variable`, `swallowed-exception` and `parse-error`. All three Metrics
complexity cops —
`CyclomaticComplexity`, `PerceivedComplexity` and `AbcSize` — coach as
`high-complexity`, since a nesting-heavy or call-heavy method can trip one
without the others.

Your `.rubocop.yml` decides everything. The sensor passes no `--only` and no
`--config`, so RuboCop discovers your config by its own upward walk and your
habit-hooks run is the run you get from RuboCop directly. A cop this plugin
has no smell for is forwarded under its own name rather than dropped, because
a cop only fires if your config switched it on. `--force-exclusion` is the one
flag the sensor adds, so `AllCops: Exclude:` keeps applying once habit-hooks
names files on the command line.

- **`bin/` joins the tool search path**, after `node_modules/.bin` and
`.venv/bin`. It is where `bundle binstubs` writes binstubs, and for Ruby that
matters more than a version difference usually does: a `.rubocop.yml` naming
`rubocop-rails` or `rubocop-rspec` cannot be read at all by a rubocop outside
the bundle those gems live in. It ranks last of the three because `bin` is a
name a project may keep its own scripts under.

### Fixed

- **A wrapped tool that never started no longer reads as a clean file.** The
`rubocop` binstub begins `#!/usr/bin/env ruby`, so pointing it at an
interpreter it is not installed into makes it die in `find_spec_for_exe` and
exit 1 — the code that otherwise means "I found offences". The sensor judges
the report rather than the exit code: RuboCop prints its JSON envelope on
every run it completed, so no envelope means no run, and the run fails instead
of reporting nothing wrong.

## 1.4.0

Native Windows support, plus everything else the two Windows reporters found on
Expand Down
18 changes: 13 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1054,11 +1054,19 @@ name then answers `jscpd: command not found` with the tool sitting right there.
uses.** Every one is `argv = ["${python}", "${dir}/<helper>.py", ...]` or
`["node", "...cjs", ...]`, and the helper spawns `jscpd`/`pmd`/`php`/`deptry`/
`ruff` itself, one process further in — where the tools that actually go missing
on Windows go missing. `sensors/spawn.Spawner._runnable` still resolves a bare
`argv[0]`, since off Windows it names the very file the spawn's own search would
have reached; only a **bare** name is resolved, because a path (`${python}`,
`${dir}/helper.py`) is read against the directory the command runs in, and every
argument after the first is an argument whatever it looks like.
on Windows go missing. A **bare** `argv[0]` that names a declared `command`
detector is resolved through that detector — the loader puts it in
`Part.detectors` (`named_tools._bare_program`) and `command_text._program`
substitutes the file — so the bare spelling and `${detector:<name>}` agree on
the same search paths, and a binstub in the project's `bin` is found by either
spelling or by neither. `Spawner._runnable` still resolves a bare `argv[0]`,
but only as the fallback for names nobody declared: off Windows such a name
is the very file the spawn's own search would have reached, and a path
(`${python}`, `${dir}/helper.py`) is read against the directory the command
runs in, while every argument after the first is an argument whatever it looks
like. The typescript plugin's `argv = ["node", ...]` parts now resolve `node`
through its own declaration — the same file the fallback found, and a machine
without node fails by name rather than at the spawn.

The other process gets there by **naming the tool in the recipe**:
`${detector:<name>}` (`sensors/named_tools.py`) expands to the file
Expand Down
15 changes: 11 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ answers "why is this run not reporting anything?".
Setup is four steps. A run that reports nothing is almost always a skipped one:

1. **Install habit-hooks** — you get the core and the generic, language-agnostic plugin.
2. **Install the plugin for your language** — python, typescript, php and java ship as separate packages.
2. **Install the plugin for your language** — python, typescript, php, java and ruby ship as separate packages.
3. **Enable the plugins** by naming them in `.habit-hooks/config.toml`. Installing one does not switch it on.
4. **Install the detectors** the plugins you enabled use — `jscpd`, `ruff`, `eslint` and friends.

Expand All @@ -95,7 +95,7 @@ You get **core plus the generic plugin**, and four commands on your `PATH`: `hab
`habit-mapper`, `habit-snooze`. Homebrew is the exception — it installs all five plugins, so skip to step 3.

> ⚠️ **On its own this checks nothing about your language.** The generic plugin measures file length and
> duplication. Python, TypeScript, PHP and Java each need their own plugin — installed (step 2) *and*
> duplication. Python, TypeScript, PHP, Java and Ruby each need their own plugin — installed (step 2) *and*
> enabled (step 3).

### 2. Install the plugin for your language
Expand Down Expand Up @@ -146,6 +146,7 @@ Detectors are **not** bundled: a plugin spawns the real tool, or reads it as a l
| **typescript** | `node`, [`eslint`](https://eslint.org/), [`knip`](https://knip.dev/), [`ts-morph`](https://ts-morph.com/) | `npm install --save-dev eslint knip ts-morph` (`node` from your system package manager) |
| **php** | `php` — [phpmd](https://phpmd.org/) ships bundled as a phar | nothing beyond a PHP runtime |
| **java** | [`pmd`](https://pmd.github.io/) | `brew install pmd` |
| **ruby** | [`rubocop`](https://rubocop.org/) | `gem install rubocop` (or `bundle binstubs rubocop`) |

`ts-morph` is read as a library rather than spawned, so it belongs in your `devDependencies` — being on `PATH`
does nothing for it.
Expand All @@ -155,8 +156,13 @@ does nothing for it.
> `npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin`. A project with its own
> `eslint.config.js` needs neither — yours always wins.

`habit-sensors` prepends `node_modules/.bin` and `.venv/bin` to `PATH`, so a project's local tools are found
without being installed globally.
> If your Ruby project's `Gemfile` pins rubocop, or its `.rubocop.yml` names an extension gem
> (`rubocop-rails`, `rubocop-rspec`), run `bundle binstubs rubocop`. habit-hooks looks in `bin/` first, so
> `bin/rubocop` runs under your bundle with those gems loaded. A rubocop that cannot load a gem your config
> names fails outright rather than linting, and habit-hooks reports that as a failed run.

`habit-sensors` prepends `node_modules/.bin`, `.venv/bin` and `bin` to `PATH`, so a project's local tools are
found without being installed globally.

</details>

Expand Down Expand Up @@ -330,6 +336,7 @@ The five that ship:
| `typescript` | `typescript` | `eslint`, `knip`, `comment` | eslint, knip, ts-morph |
| `php` | `php` | `phpmd` | phpmd |
| `java` | `java` | `pmd` | pmd |
| `ruby` | `ruby` | `rubocop` | rubocop |

A project turns plugins on by listing them in `.habit-hooks/config.toml`. **That list is ordered, and the
order is a priority:**
Expand Down
5 changes: 5 additions & 0 deletions docs/authoring-plugins.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,11 @@ declares, and a name declared `node-module`, are both refused as the config load
the ordinary missing-tool failure — the sensor is never spawned, and the notice
names the tool it needed, without your script having to say so.

A bare `argv[0]` that names a declared `command` detector is resolved to that
same file, along the detector's own `search_paths`, and fails the same way when
it is not installed; only a name no plugin declares is left for the spawn to
look up along the default path.

Hand that file to a bundled script rather than looking the tool up again inside
it. Every shipped sensor that names a tool passes it as the script's first
argument:
Expand Down
29 changes: 18 add 10BC8 itions & 11 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ pip install "habit-hooks[python,typescript]" # several at once
```

The `generic` plugin ships as part of the core install; each extra (`python`,
`typescript`, `php`, `java`) installs the matching
`typescript`, `php`, `java`, `ruby`) installs the matching
`habit-hooks-<name>` distribution; a third-party plugin is just a package you
`pip install habit-hooks-<name>` directly. The core then finds every installed
plugin through its entry point — nothing else is configured for discovery.
Expand Down Expand Up @@ -236,23 +236,30 @@ root keys — it describes the plugin, not the whole run:
| `language` | The language this plugin **declares**. A plugin is not a language: its name need not match, several plugins can declare the same language, and the runner stamps this onto the plugin's findings. `generic` declares none. |
| `sensors` | An ordered list of the sensor names the plugin runs. |
| `transformers` | An ordered list of the plugin's own transformers, applied to its sensors' concatenated findings before the result joins the larger run. |
| `detectors` | The external tools this plugin's sensors reach for, each a `{ name, kind, install }` table: what to look for, how to look for it (`command` — on `PATH`; `node-module` — a package `node` resolves from the project, for one read as a library rather than spawned), and the command that installs it. A project never declares these: it names the plugins it runs, and what each of those needs installed is the plugin's to say. |
| `detectors` | The external tools this plugin's sensors reach for, each a `{ name, kind, install, search_paths }` table: what to look for, how to look for it (`command` — on `PATH`; `node-module` — a package `node` resolves from the project, for one read as a library rather than spawned), the command that installs it, and — optionally — `search_paths`, a list of directories under the project that a `command` is looked for in ahead of the default path (`node_modules/.bin`, `.venv/bin`, then `PATH`) — however the recipe names it, `${detector:<name>}` or a bare `argv[0]`. A project never declares these: it names the plugins it runs, and what each of those needs installed is the plugin's to say. |

```toml
# habit_hooks_python/config.toml
language = "python"
sensors = ["ruff", "deptry"]
# habit_hooks_ruby/config.toml
language = "ruby"
sensors = ["rubocop"]
transformers = []
detectors = [
{ name = "ruff", kind = "command", install = "pip install ruff" },
# search_paths is optional: bundler's binstubs live in bin/, which the
# default path does not search.
{ name = "rubocop", kind = "command", install = "gem install rubocop", search_paths = ["bin"] },
]
```

Every field of a detector is required, and required to say something: an entry
that is not a table, one missing a field, or one whose `name` or `install` is
empty is **rejected** (exit 2) quoting the detector it is about — a tool named
with no way to install it leaves the reader to go and find it, which is the
whole thing a detector exists to avoid.
`name`, `kind` and `install` are required, and required to say something: an
entry that is not a table, one missing one of them, or one whose `name` or
`install` is empty is **rejected** (exit 2) quoting the detector it is about —
a tool named with no way to install it leaves the reader to go and find it,
which is the whole thing a detector exists to avoid. A `search_paths` entry
must be a non-empty directory under the project — absolute paths (including
Windows drive forms), `..` components and `:`/`;` separators are rejected —
because a directory the project does not keep cannot be where its pinned tools
live, and a separator would splice one into the lookup (the entries are joined
into one search path with exactly those characters).

## `[sensors.<name>]`

Expand Down
50 changes: 50 additions & 0 deletions docs/smell-vocabulary.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ exits 0. The mapper config can override it per project.
| `high-complexity` | High cyclomatic complexity | enforced |
| `deep-nesting` | Deep nesting | enforced |
| `oversized-file` | Oversized file | enforced |
| `oversized-block` | Oversized block | enforced |
| `unused-variable` | Unused variable | enforced |
| `loose-equality` | Loose equality | enforced |
| `var-declaration` | `var` declaration | enforced |
Expand Down Expand Up @@ -178,6 +179,55 @@ falls back to the bundled `pmd-ruleset.xml` — a project's own ruleset wins
([config.md](config.md)). PMD 7's `UnnecessaryImport` is the renamed
`UnusedImports`.

## Ruby plugin translation

The cops the Ruby plugin's sensor translates into smell keys (the rest of the
catalogue is shared, only the plugin's sensors differ).

| Raw key (tool:rule) | Smell key |
|----------------------------------|-----------------------|
| `rubocop:Metrics/ParameterLists` | `too-many-parameters` |
| `rubocop:Metrics/MethodLength` | `oversized-function` |
| `rubocop:Metrics/BlockLength` | `oversized-block` |
| `rubocop:Metrics/CyclomaticComplexity` | `high-complexity` |
| `rubocop:Metrics/PerceivedComplexity` | `high-complexity` |
| `rubocop:Metrics/AbcSize` | `high-complexity` |
| `rubocop:Metrics/BlockNesting` | `deep-nesting` |
| `rubocop:Lint/UselessAssignment` | `unused-variable` |
| `rubocop:Lint/SuppressedException` | `swallowed-exception` |
| `rubocop:Lint/Syntax` | `parse-error` |

**A cop with no row above is forwarded under its own name, not dropped.** This
is the second deliberate exception to "a sensor emits vocabulary smells only",
and it is the eslint one for the same reason. The test is whose vocabulary a key
belongs to: knip's key set is knip's own, but a cop only fires because the
project's `.rubocop.yml` switched it on, so forwarding it is forwarding the
project's own decision. What arrives uncatalogued is then the root `uncoached`
key's business, which coaches without failing the run unless a project says
otherwise.

The sensor passes no `--only` and no `--config`. Which cops run, and at what
thresholds, is what the project's `.rubocop.yml` already says, and RuboCop finds
that file by its own upward walk. The one flag the sensor adds is
`--force-exclusion`, which keeps `AllCops: Exclude:` applying once habit-hooks
names files on the command line.

Two cops are deliberately left uncoached.

`Metrics/ClassLength` and `Metrics/ModuleLength` measure a class or module,
and the vocabulary has no class-scoped smell for them to back. Like every
unmapped cop they are forwarded under their own names, rendered through the
generic `uncoached.md` guidance, and `suggest` by default (see
[Uncoached smells](#uncoached-smells)) until a class and module scoped coach exists.

The three Metrics complexity cops share `high-complexity`: they are correlated
but independent (`PerceivedComplexity` weights nesting, `AbcSize` counts
assignments and calls), and either can fire without `CyclomaticComplexity`.
A method tripping two of them lands in the sensor's one `high-complexity`
finding, whose issue list is never deduped — the mapper merges across findings,
never within one (#140) — so both measurements survive inside a single coaching
block rather than printing the guide twice.

## Uncoached smells

A smell with no entry above still renders — through the generic `uncoached.md`
Expand Down
1 change: 1 addition & 0 deletions packaging/npm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ plugin. Language plugins are opt-in extras:
- `habit-hooks[typescript]`
- `habit-hooks[php]`
- `habit-hooks[java]`
- `habit-hooks[ruby]`
- `habit-hooks[all]`

Project: https://github.com/habit-hooks/habit-hooks
1 change: 1 addition & 0 deletions packaging/npm/bin/habit-hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ plugin. Language plugins are opt-in extras:
habit-hooks[typescript]
habit-hooks[php]
habit-hooks[java]
habit-hooks[ruby]
habit-hooks[all]

Project: https://github.com/habit-hooks/habit-hooks
Expand Down
Loading
0