habit-hooks-ruby 1.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- habit_hooks_ruby/__init__.py +1 -0
- habit_hooks_ruby/config.toml +47 -0
- habit_hooks_ruby/guides/oversized-block.md +9 -0
- habit_hooks_ruby/guides/parse-error.md +7 -0
- habit_hooks_ruby/sensors/rubocop.toml +1 -0
- habit_hooks_ruby/sensors/rubocop_report.py +110 -0
- habit_hooks_ruby/sensors/rubocop_sensor.py +159 -0
- habit_hooks_ruby-1.5.0.dist-info/METADATA +289 -0
- habit_hooks_ruby-1.5.0.dist-info/RECORD +11 -0
- habit_hooks_ruby-1.5.0.dist-info/WHEEL +4 -0
- habit_hooks_ruby-1.5.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The ruby Habit Hooks plugin: package data discovered via the habit_hooks.plugins entry point."""
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Ruby plugin defaults.
|
|
2
|
+
language = "ruby"
|
|
3
|
+
# A project naming no `files` of its own scans what its plugins declare, so this
|
|
4
|
+
# is the first run for anyone `habit-hooks init` set up. Ruby keeps source in
|
|
5
|
+
# files RuboCop already lints without an extension between them — a Rakefile, a
|
|
6
|
+
# Gemfile and a gemspec are Ruby, and a project's own cops apply to them.
|
|
7
|
+
#
|
|
8
|
+
# The exclusions name Ruby's own source shapes one by one — every included
|
|
9
|
+
# shape needs its own negative, or it leaks back in: an installed gem ships
|
|
10
|
+
# its own `Rakefile` and `.gemspec`, and Rails' scratch directory copies
|
|
11
|
+
# whatever it scratches. They still never name the whole directory, because a
|
|
12
|
+
# plugin's exclusions bind the union of every active plugin's globs, not just
|
|
13
|
+
# its own (docs/config.md, and the comment in the java plugin's config that a
|
|
14
|
+
# bare `!**/build/**` once stopped a python+java project scanning its own
|
|
15
|
+
# `scripts/build/*.py`). `vendor/` is where Bundler puts installed gems and
|
|
16
|
+
# `tmp/` is Rails' scratch directory: Ruby the project did not write. Rails
|
|
17
|
+
# generates `db/schema.rb`, so it is nobody's to fix by hand.
|
|
18
|
+
files = [
|
|
19
|
+
"**/*.rb",
|
|
20
|
+
"**/*.rake",
|
|
21
|
+
"**/Rakefile",
|
|
22
|
+
"**/Gemfile",
|
|
23
|
+
"**/*.gemspec",
|
|
24
|
+
"!**/vendor/**/*.rb",
|
|
25
|
+
"!**/vendor/**/*.rake",
|
|
26
|
+
"!**/vendor/**/Rakefile",
|
|
27
|
+
"!**/vendor/**/Gemfile",
|
|
28
|
+
"!**/vendor/**/*.gemspec",
|
|
29
|
+
"!**/tmp/**/*.rb",
|
|
30
|
+
"!**/tmp/**/*.rake",
|
|
31
|
+
"!**/tmp/**/Rakefile",
|
|
32
|
+
"!**/tmp/**/Gemfile",
|
|
33
|
+
"!**/tmp/**/*.gemspec",
|
|
34
|
+
"!**/db/schema.rb",
|
|
35
|
+
]
|
|
36
|
+
sensors = ["rubocop"]
|
|
37
|
+
transformers = []
|
|
38
|
+
|
|
39
|
+
# The rubocop sensor spawns the rubocop binary directly. `bundle binstubs
|
|
40
|
+
# rubocop` puts the project's own copy at `bin/rubocop`, and for a
|
|
41
|
+
# `.rubocop.yml` naming extension gems, the project's copy is the only one that
|
|
42
|
+
# can read it (see this plugin's README). `bin` is not on the default search
|
|
43
|
+
# path — a project may keep its own scripts under a name like that — so the
|
|
44
|
+
# detector names it, and only the lookups for rubocop search it.
|
|
45
|
+
detectors = [
|
|
46
|
+
{ name = "rubocop", kind = "command", install = "gem install rubocop", search_paths = ["bin"] },
|
|
47
|
+
]
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
A block that has grown past a dozen lines almost always has more than one responsibility, and that is the smell to chase, not the line count itself.
|
|
2
|
+
|
|
3
|
+
Analyse responsibilities first: what distinct concerns does this block handle? Do these concerns belong in methods? Is the block a candidate for a method on a class?
|
|
4
|
+
|
|
5
|
+
Find true responsibility boundaries rather than splitting at the threshold: `apply_part_one` / `apply_part_two` carved off to fit the line count is one responsibility wearing two names.
|
|
6
|
+
|
|
7
|
+
A concrete technique: write what the block does in one short sentence and refactor until the code reads as close to it as possible. If you cannot say what it does in one sentence, it almost certainly does more than one thing.
|
|
8
|
+
|
|
9
|
+
{% include "includes/line_level_issues.md" %}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
A fatal parse error means RuboCop could not analyse the file at all, so every other cop went unchecked there too. Real issues in this file are currently invisible.
|
|
2
|
+
|
|
3
|
+
Check, in order: a syntax error (`ruby -c <file>` names the line); a `TargetRubyVersion` in `.rubocop.yml` older than the syntax the file uses (pattern matching or endless methods under a 2.x target); a generated file that is not valid Ruby.
|
|
4
|
+
|
|
5
|
+
You've fixed it when a deliberate change to the file produces the ordinary offence you'd expect, since that proves analysis is running again. Keep the file in the lint set: it is exactly the file most in need of the other cops.
|
|
6
|
+
|
|
7
|
+
{% include "includes/file_level_issues.md" %}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
argv = ["${python}", "${dir}/rubocop_sensor.py", "${detector:rubocop}", "${files}"]
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""RuboCop's JSON report, parsed and shaped into the canonical findings.
|
|
2
|
+
|
|
3
|
+
RuboCop's own JSON nests offences under each file; this flattens them, groups by
|
|
4
|
+
smell and shapes each into the canonical finding.
|
|
5
|
+
|
|
6
|
+
**An unmapped cop is forwarded, not dropped**, which is the opposite of the knip
|
|
7
|
+
sensor and the same as the eslint one. CLAUDE.md's test for a wrapped tool is
|
|
8
|
+
"whose vocabulary is it?" Knip's key set is knip's own, but a cop that fired is
|
|
9
|
+
one the project's ``.rubocop.yml`` turned on, so forwarding it saves running
|
|
10
|
+
RuboCop separately. Its smell key is the cop name verbatim
|
|
11
|
+
(``Style/StringLiterals``). A ``/`` in a smell key is already precedented by
|
|
12
|
+
eslint forwarding ``@typescript-eslint/no-explicit-any``. Nothing downstream
|
|
13
|
+
breaks on one. An uncatalogued smell renders through ``uncoached.md``, and the
|
|
14
|
+
root ``uncoached`` key (default ``suggest``) decides whether it fails the run.
|
|
15
|
+
|
|
16
|
+
``Metrics/ClassLength`` and ``Metrics/ModuleLength`` are the two cops
|
|
17
|
+
deliberately left uncoached: they measure a class or module, and the vocabulary
|
|
18
|
+
has no class-scoped smell for them to back. Like every unmapped cop they are
|
|
19
|
+
forwarded under their own names and rendered through ``uncoached.md``,
|
|
20
|
+
``suggest`` until a class and module scoped coach exists.
|
|
21
|
+
|
|
22
|
+
The three complexity cops share one smell on purpose. They are correlated but
|
|
23
|
+
independent. A method tripping two cops keeps both measurements inside a single coaching
|
|
24
|
+
block.
|
|
25
|
+
|
|
26
|
+
``Metrics/BlockLength`` maps to its own smell rather than to
|
|
27
|
+
``oversized-function``: a block is an anonymous function the project never
|
|
28
|
+
named, and its coaching (name the work as a method, let the declaration point
|
|
29
|
+
at it) differs enough from a method's to warrant the ruby plugin's own guide.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
import subprocess
|
|
36
|
+
|
|
37
|
+
COP_SMELLS = {
|
|
38
|
+
"Metrics/ParameterLists": "too-many-parameters",
|
|
39
|
+
"Metrics/MethodLength": "oversized-function",
|
|
40
|
+
"Metrics/BlockLength": "oversized-block",
|
|
41
|
+
"Metrics/CyclomaticComplexity": "high-complexity",
|
|
42
|
+
"Metrics/PerceivedComplexity": "high-complexity",
|
|
43
|
+
"Metrics/AbcSize": "high-complexity",
|
|
44
|
+
"Metrics/BlockNesting": "deep-nesting",
|
|
45
|
+
"Lint/UselessAssignment": "unused-variable",
|
|
46
|
+
"Lint/SuppressedException": "swallowed-exception",
|
|
47
|
+
"Lint/Syntax": "parse-error",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def report(result: subprocess.CompletedProcess[str]) -> dict | None:
|
|
52
|
+
"""RuboCop's JSON report, or ``None`` where it produced none.
|
|
53
|
+
|
|
54
|
+
``files`` has to be there, not merely valid JSON. That key is what makes it
|
|
55
|
+
a report rather than something else that happens to parse.
|
|
56
|
+
"""
|
|
57
|
+
text = result.stdout.strip()
|
|
58
|
+
if not text:
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
parsed = json.loads(text)
|
|
62
|
+
except json.JSONDecodeError:
|
|
63
|
+
return None
|
|
64
|
+
return parsed if isinstance(parsed, dict) and "files" in parsed else None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def offenses(parsed: dict) -> list[dict]:
|
|
68
|
+
"""RuboCop's per-file nesting flattened, each offence carrying its path."""
|
|
69
|
+
return [
|
|
70
|
+
{"file": entry["path"], "offense": offense}
|
|
71
|
+
for entry in parsed.get("files", [])
|
|
72
|
+
for offense in entry["offenses"]
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def smell_of(cop_name: str) -> str:
|
|
77
|
+
"""This plugin's smell for a cop, or the cop itself where it has none.
|
|
78
|
+
|
|
79
|
+
``.get`` with the cop as its own default, never a bare lookup. The string
|
|
80
|
+
comes from RuboCop and nothing constrains it to the table above (issue #83).
|
|
81
|
+
"""
|
|
82
|
+
return COP_SMELLS.get(cop_name, cop_name)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def issue(entry: dict) -> dict:
|
|
86
|
+
offense = entry["offense"]
|
|
87
|
+
return {
|
|
88
|
+
"key": entry["file"],
|
|
89
|
+
"details": {
|
|
90
|
+
"file": entry["file"],
|
|
91
|
+
"line": offense["location"]["line"],
|
|
92
|
+
"column": offense["location"]["column"],
|
|
93
|
+
"message": offense["message"],
|
|
94
|
+
"source": "rubocop:" + offense["cop_name"],
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def findings(entries: list[dict]) -> list[dict]:
|
|
100
|
+
by_smell: dict[str, list[dict]] = {}
|
|
101
|
+
for entry in entries:
|
|
102
|
+
by_smell.setdefault(smell_of(entry["offense"]["cop_name"]), []).append(entry)
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
"smell": smell,
|
|
106
|
+
"details": {},
|
|
107
|
+
"issues": [issue(entry) for entry in by_smell[smell]],
|
|
108
|
+
}
|
|
109
|
+
for smell in sorted(by_smell)
|
|
110
|
+
]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Run RuboCop and print canonical findings, mapped from cop name to smell.
|
|
2
|
+
|
|
3
|
+
**Which cops run is the project's business, not ours.** The sensor passes no
|
|
4
|
+
``--only`` and no ``--config``. RuboCop finds ``.rubocop.yml`` by walking up from
|
|
5
|
+
each inspected file, exactly as it does when run by hand, so a project's habit-
|
|
6
|
+
hooks run is the run it gets from the tool directly. ``--force-exclusion`` is the
|
|
7
|
+
one flag that keeps that true. Without it, naming files on the command line
|
|
8
|
+
overrides the project's own ``AllCops: Exclude:``.
|
|
9
|
+
|
|
10
|
+
Parsing and shaping live next door in ``rubocop_report``, a neighbour imported
|
|
11
|
+
as a top-level module because a helper runs as a loose script — the interpreter
|
|
12
|
+
puts the helper's own directory first on ``sys.path`` (CLAUDE.md, "A plugin
|
|
13
|
+
helper imports its neighbours as top-level modules").
|
|
14
|
+
|
|
15
|
+
The plugin ships no RuboCop of its own, so the sensor names ``${detector:rubocop}``
|
|
16
|
+
and its ``sys.argv[1]`` is the file this project runs for it, and the scoped
|
|
17
|
+
files follow. A rubocop nobody installed never reaches here at all. The part
|
|
18
|
+
has no file for it, so the run answers with the missing-command notice before
|
|
19
|
+
anything is spawned.
|
|
20
|
+
|
|
21
|
+
**A scope filename is a name, never an option or a pattern.** RuboCop reads its
|
|
22
|
+
file arguments both ways, and the argv guards each: ``--`` ends option parsing
|
|
23
|
+
before any file is named, and :func:`literal_spelling_of` escapes the glob
|
|
24
|
+
metacharacters RuboCop would otherwise expand.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import re
|
|
31
|
+
import subprocess
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
from rubocop_report import findings, offenses, report
|
|
35
|
+
|
|
36
|
+
# RuboCop's own contract: 0 is clean, 1 is "offences found". Anything else
|
|
37
|
+
# means RuboCop never produced a real report, the same distinction
|
|
38
|
+
# `part_output.py`'s TOOL_EXIT_CODES draws for a part run directly.
|
|
39
|
+
#
|
|
40
|
+
# The exit code alone is not enough here, which is a Ruby problem rather than a
|
|
41
|
+
# RuboCop one. `rubocop` is a RubyGems binstub with a `#!/usr/bin/env ruby`
|
|
42
|
+
# shebang, so it is only as good as the `ruby` that answers first. Point it at
|
|
43
|
+
# an interpreter whose gems it is not installed into, whether a version
|
|
44
|
+
# manager left off PATH, the wrong bundle, or macOS's system Ruby 2.6, and it
|
|
45
|
+
# dies in `find_spec_for_exe` with a Ruby traceback and **exit 1**, the code
|
|
46
|
+
# reserved for "I found offences". Trusting that would report a rubocop that
|
|
47
|
+
# never started as a clean file (#88).
|
|
48
|
+
#
|
|
49
|
+
# So the report is the evidence, not the code. `--format json` prints the
|
|
50
|
+
# envelope on every run RuboCop actually completed, down to `"files": []` when
|
|
51
|
+
# it inspected nothing. No report means no run.
|
|
52
|
+
TOOL_EXIT_CODES = (0, 1)
|
|
53
|
+
|
|
54
|
+
# The characters `Dir[]` reads as a pattern rather than a name. RuboCop hands
|
|
55
|
+
# an argument to `Dir[]` only when it contains a `*` (see
|
|
56
|
+
# `literal_spelling_of`), so these matter only inside an argument that does.
|
|
57
|
+
_GLOB_METACHARACTERS = re.compile(r"[*?\[\]{}\\]")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def literal_spelling_of(path: str) -> str:
|
|
61
|
+
"""A scope filename spelled so RuboCop reads it as the file it names.
|
|
62
|
+
|
|
63
|
+
RuboCop globs any argument containing a ``*`` —
|
|
64
|
+
``TargetFinder#process_explicit_path`` passes it to ``Dir[]`` — so a
|
|
65
|
+
literal star would sweep in every file it matches. A backslash is ``Dir[]``'s
|
|
66
|
+
own escape for "this character, literally", which leaves the escaped
|
|
67
|
+
string still containing a ``*`` for RuboCop to notice and hand to ``Dir[]``
|
|
68
|
+
in the first place.
|
|
69
|
+
|
|
70
|
+
The other metacharacters are escaped only inside such an argument. A path
|
|
71
|
+
without a ``*`` RuboCop takes verbatim, backslashes included, so escaping a
|
|
72
|
+
``?`` there would name a file that does not exist and the run would die on
|
|
73
|
+
``Error: No such file or directory``.
|
|
74
|
+
"""
|
|
75
|
+
if "*" not in path:
|
|
76
|
+
return path
|
|
77
|
+
return _GLOB_METACHARACTERS.sub(r"\\\g<0>", path)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def run_rubocop(rubocop: str, files: list[str]) -> subprocess.CompletedProcess[str]:
|
|
81
|
+
"""What RuboCop said, spawned as the file this sensor was handed for it.
|
|
82
|
+
|
|
83
|
+
The file rather than the name. A name would be looked up again by the
|
|
84
|
+
spawn, and Windows' own lookup adds ``.exe`` and nothing else, where
|
|
85
|
+
RubyGems installs a ``.bat`` shim.
|
|
86
|
+
|
|
87
|
+
``--force-exclusion`` is not a nicety. RuboCop applies ``AllCops: Exclude:``
|
|
88
|
+
to the files it discovers, but a file named explicitly on the command line
|
|
89
|
+
is taken as a deliberate request and linted anyway. Without it, a
|
|
90
|
+
project's own exclusions stop meaning anything the moment habit-hooks passes
|
|
91
|
+
a scope.
|
|
92
|
+
|
|
93
|
+
``--`` ends option parsing before the files, so a valid filename beginning
|
|
94
|
+
with ``-`` is a file rather than a pile of short options — ``-c`` among them,
|
|
95
|
+
which takes the rest as its ``--config`` value.
|
|
96
|
+
|
|
97
|
+
``--raise-cop-error`` is what makes a cop that raises an ``Error:`` and
|
|
98
|
+
exit 2, detectable by habit-hooks as a failed run. A run with offences
|
|
99
|
+
and no crash is untouched, they exit 1 with a valid envelope.
|
|
100
|
+
"""
|
|
101
|
+
return subprocess.run(
|
|
102
|
+
[
|
|
103
|
+
rubocop,
|
|
104
|
+
"--format",
|
|
105
|
+
"json",
|
|
106
|
+
"--force-exclusion",
|
|
107
|
+
"--raise-cop-error",
|
|
108
|
+
"--",
|
|
109
|
+
*[literal_spelling_of(file) for file in files],
|
|
110
|
+
],
|
|
111
|
+
capture_output=True,
|
|
112
|
+
encoding="utf-8",
|
|
113
|
+
errors="replace", # sensors.spawn's policy
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def rubocop_crashed(
|
|
118
|
+
result: subprocess.CompletedProcess[str], parsed: dict | None
|
|
119
|
+
) -> bool:
|
|
120
|
+
"""Whether this run is one whose answer can be believed.
|
|
121
|
+
|
|
122
|
+
Both halves are needed. The exit code catches the failures RuboCop reports
|
|
123
|
+
as failures; the missing report catches the one it cannot: a binstub that
|
|
124
|
+
never reached RuboCop at all, which exits 1 like a run full of offences
|
|
125
|
+
(see :data:`TOOL_EXIT_CODES`).
|
|
126
|
+
|
|
127
|
+
The report is passed in rather than parsed here so that this is the only
|
|
128
|
+
place the rule is written. :func:`main` needs the parsed report anyway, and
|
|
129
|
+
a version of this that re-derived it would be a second copy of the decision,
|
|
130
|
+
free to drift from the one the tests exercise.
|
|
131
|
+
"""
|
|
132
|
+
return result.returncode not in TOOL_EXIT_CODES or parsed is None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def main() -> int:
|
|
136
|
+
rubocop = sys.argv[1]
|
|
137
|
+
files = sys.argv[2:]
|
|
138
|
+
# A scope that resolved to nothing measured nothing, and RuboCop handed no
|
|
139
|
+
# paths falls back to its own default and scans everything under the
|
|
140
|
+
# current directory. Without this guard a docs-only change reports every
|
|
141
|
+
# legacy smell in the tree and fails the run (#93).
|
|
142
|
+
if not files:
|
|
143
|
+
print("[]")
|
|
144
|
+
return 0
|
|
145
|
+
result = run_rubocop(rubocop, files)
|
|
146
|
+
parsed = report(result)
|
|
147
|
+
if rubocop_crashed(result, parsed):
|
|
148
|
+
# `or result.stdout`: a binstub that could not find its own gem writes
|
|
149
|
+
# its traceback to stderr, but a RuboCop that failed on the config
|
|
150
|
+
# writes `Error: ...` to stdout. Whichever one it is, the tool's own
|
|
151
|
+
# words are the only thing the reader can act on.
|
|
152
|
+
sys.stderr.write(result.stderr or result.stdout)
|
|
153
|
+
return 2
|
|
154
|
+
print(json.dumps(findings(offenses(parsed))))
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
sys.exit(main())
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: habit-hooks-ruby
|
|
3
|
+
Version: 1.5.0
|
|
4
|
+
Summary: The Ruby Habit Hooks plugin
|
|
5
|
+
Project-URL: Homepage, https://habit-hooks.com
|
|
6
|
+
Project-URL: Repository, https://github.com/habit-hooks/habit-hooks
|
|
7
|
+
Project-URL: Issues, https://github.com/habit-hooks/habit-hooks/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/habit-hooks/habit-hooks/blob/main/CHANGELOG.md
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
Keywords: ai-coding-agents,ci,code-quality,code-review,code-smells,coding-agents,developer-tools,linter,refactoring,rubocop,ruby,static-analysis,technical-debt
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
20
|
+
Classifier: Topic :: Software Development :: Testing
|
|
21
|
+
Classifier: Topic :: Utilities
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# habit-hooks-ruby
|
|
26
|
+
|
|
27
|
+
The Ruby Habit Hooks plugin: wraps [`rubocop`](https://rubocop.org/) for
|
|
28
|
+
code-smell detection. Instead of a bare offence, each finding is
|
|
29
|
+
coached by listing the smell, guide for fixing, then a list of file locations.
|
|
30
|
+
This causes the agent to focus on fixing the smell rather than the metric.
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
── high-complexity (1 issue) ──
|
|
34
|
+
|
|
35
|
+
High cyclomatic complexity means one function makes too many decisions at once. The count is the symptom; tangled responsibilities are the cause.
|
|
36
|
+
|
|
37
|
+
**Untangle the decisions:**
|
|
38
|
+
1. Lift guards out first — turn precondition checks into early returns so the happy path stays flat. Much of the count is preconditions wrapped around the real work.
|
|
39
|
+
2. Change the shape of what remains: an `if`/`else` chain switching on one value is often a lookup table or polymorphism in disguise; a nested loop is often a filter/map pipeline.
|
|
40
|
+
3. If the branches are genuinely separate jobs, extract one function per branch, each named for the responsibility it handles.
|
|
41
|
+
|
|
42
|
+
Useful tip: describe each branch in one sentence. Two branches with the same sentence belong together; a branch you cannot name cleanly wants its own function.
|
|
43
|
+
|
|
44
|
+
**AVOID**: merging conditions with and/or, or rewriting branches as ternaries, just to lower the score — the decisions remain, only the counter moves. You are done when a first-time reader can hold the whole function in their head.
|
|
45
|
+
|
|
46
|
+
app/services/billing.rb:6
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
# Setup
|
|
50
|
+
|
|
51
|
+
The steps are:
|
|
52
|
+
|
|
53
|
+
1. Install the plugin.
|
|
54
|
+
2. Enable it in your `.habit-hooks/config.toml`.
|
|
55
|
+
3. Make sure you have rubocop installed.
|
|
56
|
+
4. Make sure your `.rubocop.yml` enables the cops you want to see.
|
|
57
|
+
|
|
58
|
+
## Install the plugin
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
uv tool install "habit-hooks[ruby]" # pip and pipx work too
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Or let setup do it: with habit-hooks already installed, `habit-hooks init` in your
|
|
65
|
+
project detects ruby, names this plugin in `.habit-hooks/config.toml`, and offers to
|
|
66
|
+
run the install for you — see the [Habit-Hooks README #install](https://github.com/habit-hooks/habit-hooks#install).
|
|
67
|
+
|
|
68
|
+
## Enable the plugin
|
|
69
|
+
|
|
70
|
+
Installing a plugin does not switch it on — it has to be named in
|
|
71
|
+
`plugins` before habit-hooks runs it.
|
|
72
|
+
|
|
73
|
+
```toml
|
|
74
|
+
# .habit-hooks/config.toml
|
|
75
|
+
plugins = ["ruby", "generic"]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Keep `generic` in the list. Most of the smells this plugin reports are coached
|
|
79
|
+
by guides `generic` ships; without it they fall back to a short generic prompt.
|
|
80
|
+
|
|
81
|
+
## Sensors: install rubocop
|
|
82
|
+
|
|
83
|
+
The ruby plugin supports the rubocop sensor. It can use any rubocop on your `PATH`, but it is best to point it at the one your project uses.
|
|
84
|
+
|
|
85
|
+
Point it at your project's own rubocop:
|
|
86
|
+
|
|
87
|
+
**If your `Gemfile` pins rubocop, or your `.rubocop.yml` names an extension gem
|
|
88
|
+
(`rubocop-rails`, `rubocop-rspec`, `rubocop-performance`), generate binstubs:**
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
bundle binstubs rubocop
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Otherwise, if you have no rubocop yet, install one:
|
|
95
|
+
|
|
96
|
+
- `rubocop` — `gem install rubocop`
|
|
97
|
+
|
|
98
|
+
### How it finds rubocop
|
|
99
|
+
|
|
100
|
+
habit-hooks' rubocop detector searches your project's `bin/` ahead of the
|
|
101
|
+
machine's `PATH`, so `bin/rubocop` is what it will run — under your bundle,
|
|
102
|
+
with your extension gems loaded.
|
|
103
|
+
|
|
104
|
+
Without this, habit-hooks runs whatever `rubocop` your `PATH` answers with, and
|
|
105
|
+
a config naming cops that rubocop cannot load is a hard error rather than a
|
|
106
|
+
lint result:
|
|
107
|
+
|
|
108
|
+
```
|
|
109
|
+
Error: `Rails/*` has been extracted to the `rubocop-rails` gem.
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
habit-hooks reports that as a failed run.
|
|
113
|
+
|
|
114
|
+
### Configure your `.rubocop.yml`
|
|
115
|
+
|
|
116
|
+
The sensor runs `rubocop` and reads what comes back. RuboCop discovers your `.rubocop.yml`
|
|
117
|
+
exactly as it does when you run rubocop by hand. The `--force-exclusion` is the one flag
|
|
118
|
+
habit-hooks adds, so your `AllCops: Exclude:` keeps applying even though habit-hooks names files
|
|
119
|
+
explicitly.
|
|
120
|
+
|
|
121
|
+
Cops are mapped to canonical smells and are reported under it:
|
|
122
|
+
|
|
123
|
+
| Cop | Smell |
|
|
124
|
+
|-----|-------|
|
|
125
|
+
| `Metrics/ParameterLists` | `too-many-parameters` |
|
|
126
|
+
| `Metrics/MethodLength` | `oversized-function` |
|
|
127
|
+
| `Metrics/BlockLength` | `oversized-block` |
|
|
128
|
+
| `Metrics/CyclomaticComplexity` | `high-complexity` |
|
|
129
|
+
| `Metrics/PerceivedComplexity` | `high-complexity` |
|
|
130
|
+
| `Metrics/AbcSize` | `high-complexity` |
|
|
131
|
+
| `Metrics/BlockNesting` | `deep-nesting` |
|
|
132
|
+
| `Lint/UselessAssignment` | `unused-variable` |
|
|
133
|
+
| `Lint/SuppressedException` | `swallowed-exception` |
|
|
134
|
+
| `Lint/Syntax` | `parse-error` |
|
|
135
|
+
|
|
136
|
+
All other cops are **forwarded under their own name** by default.
|
|
137
|
+
Recommended: add coaches for cops as needed (see [Customization](#customization)).
|
|
138
|
+
Set the root `uncoached` key to `ignore` to drop these cops,
|
|
139
|
+
or `enforce` to fail the run on them
|
|
140
|
+
([config.md](https://github.com/habit-hooks/habit-hooks/blob/main/docs/config.md)).
|
|
141
|
+
|
|
142
|
+
#### A starting `.rubocop.yml`
|
|
143
|
+
|
|
144
|
+
If you have no config yet, this turns on the structural cops this plugin maps
|
|
145
|
+
and nothing else. It is a suggestion, not a default. Habit-hooks never writes
|
|
146
|
+
it for you and never passes it to rubocop.
|
|
147
|
+
|
|
148
|
+
```yaml
|
|
149
|
+
AllCops:
|
|
150
|
+
NewCops: enable
|
|
151
|
+
Exclude:
|
|
152
|
+
- 'db/schema.rb'
|
|
153
|
+
- 'vendor/**/*'
|
|
154
|
+
- 'tmp/**/*'
|
|
155
|
+
|
|
156
|
+
Metrics/ParameterLists:
|
|
157
|
+
Max: 4
|
|
158
|
+
Metrics/MethodLength:
|
|
159
|
+
Max: 20
|
|
160
|
+
Metrics/CyclomaticComplexity:
|
|
161
|
+
Max: 10
|
|
162
|
+
Metrics/BlockNesting:
|
|
163
|
+
Max: 3
|
|
164
|
+
Lint/UselessAssignment:
|
|
165
|
+
Enabled: true
|
|
166
|
+
Lint/SuppressedException:
|
|
167
|
+
Enabled: true
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
# Customization
|
|
171
|
+
|
|
172
|
+
Ruby Habit Hooks is customizable without modifying the installed package. Project
|
|
173
|
+
files under `.habit-hooks/ruby/` override the corresponding files shipped by the
|
|
174
|
+
Ruby plugin. Commit these overrides if they are intended to apply to the whole
|
|
175
|
+
project.
|
|
176
|
+
|
|
177
|
+
## Add project-specific coaching
|
|
178
|
+
|
|
179
|
+
Replace any Ruby guide by creating a file with the same name under
|
|
180
|
+
`.habit-hooks/ruby/guides/`. For example,
|
|
181
|
+
`.habit-hooks/ruby/guides/high-complexity.md` replaces the default coaching for
|
|
182
|
+
the `high-complexity` smell while leaving all other guides unchanged.
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
An unmapped cop forwarded under its own name can use a custom filename through the project
|
|
186
|
+
config:
|
|
187
|
+
|
|
188
|
+
```toml
|
|
189
|
+
[smells."Style/StringLiterals"]
|
|
190
|
+
guide = "style-string-literals.md"
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Then add a guide at `.habit-hooks/ruby/guides/style-string-literals.md`. For example:
|
|
194
|
+
|
|
195
|
+
```markdown
|
|
196
|
+
Use the quote style established by this project. preserve interpolation and readability.
|
|
197
|
+
|
|
198
|
+
{% for issue in issues -%}
|
|
199
|
+
{{ issue.details.file }}:{{ issue.details.line }}
|
|
200
|
+
{% endfor %}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Guides are Markdown Jinja templates. They can use `smell` and `language`, read
|
|
204
|
+
smell-level `details`, and loop over `issues` to show each offense. The loop in
|
|
205
|
+
the example above writes the file and line of each issue (required, otherwise the
|
|
206
|
+
agent will not know where the offense occurred).
|
|
207
|
+
|
|
208
|
+
## Add a cop-to-smell mapping
|
|
209
|
+
|
|
210
|
+
Prefer mapping a cop to an existing general smell when the guidance fits. This
|
|
211
|
+
lets the project benefit from shared coaching and keeps the smell vocabulary
|
|
212
|
+
small. If the cop represents a smell that should be useful beyond your project,
|
|
213
|
+
consider contributing the mapping, guide, and any needed vocabulary changes in
|
|
214
|
+
a pull request to the [Habit Hooks project](https://github.com/habit-hooks/habit-hooks).
|
|
215
|
+
|
|
216
|
+
The built-in cop mappings live in `sensors/rubocop_report.py`, in the
|
|
217
|
+
`COP_SMELLS` table.
|
|
218
|
+
|
|
219
|
+
To add a mapping for a project, copy that file to
|
|
220
|
+
`.habit-hooks/ruby/sensors/rubocop_report.py` and add an entry, for example:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
COP_SMELLS = {
|
|
224
|
+
# existing mappings ...
|
|
225
|
+
"Style/StringLiterals": "project-style",
|
|
226
|
+
}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Because the sensor helper imports this file from its own directory, also copy
|
|
230
|
+
`rubocop_sensor.py` and `rubocop.toml` to the same override directory (.habit-hooks/ruby/sensors).
|
|
231
|
+
Keep the recipe in the TOML file the same; its `${dir}` then points at the override and
|
|
232
|
+
loads your customized report module.
|
|
233
|
+
|
|
234
|
+
If the new mapping uses an existing smell, its existing guide and severity are
|
|
235
|
+
used. For a new smell, add a guide and configure its severity as needed:
|
|
236
|
+
|
|
237
|
+
```toml
|
|
238
|
+
[smells.project-style]
|
|
239
|
+
severity = "suggested"
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Then add `.habit-hooks/ruby/guides/project-style.md`.
|
|
243
|
+
|
|
244
|
+
## Add or replace a sensor
|
|
245
|
+
|
|
246
|
+
A sensor is a TOML recipe under `sensors/`. To replace the RuboCop sensor,
|
|
247
|
+
override `.habit-hooks/ruby/sensors/rubocop.toml`; to add a separate sensor,
|
|
248
|
+
create a new recipe such as `.habit-hooks/ruby/sensors/custom-check.toml` and
|
|
249
|
+
add its name to the Ruby plugin's `sensors` list in
|
|
250
|
+
`.habit-hooks/ruby/config.toml`:
|
|
251
|
+
|
|
252
|
+
```toml
|
|
253
|
+
sensors = ["rubocop", "custom-check"]
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The plugin config override is a complete replacement, so copy the shipped
|
|
257
|
+
Ruby `config.toml` and preserve its `language`, `files`, `transformers`, and
|
|
258
|
+
`detectors` entries when adding a sensor. Declare every external command the
|
|
259
|
+
sensor uses in `detectors`, and use `${detector:<name>}` in its recipe when the
|
|
260
|
+
sensor invokes that command.
|
|
261
|
+
|
|
262
|
+
The sensor must print a JSON array of Habit Hooks findings. See the
|
|
263
|
+
[sensor interface](https://github.com/habit-hooks/habit-hooks/blob/main/docs/sensor-interface.spec.md)
|
|
264
|
+
for the finding shape and available recipe placeholders.
|
|
265
|
+
|
|
266
|
+
For example, a simple custom sensor returning a canned finding to demonstrate the customization.
|
|
267
|
+
|
|
268
|
+
- Create
|
|
269
|
+
`.habit-hooks/ruby/sensors/custom-check.toml`:
|
|
270
|
+
|
|
271
|
+
```toml
|
|
272
|
+
command = "${dir}/custom-check.sh"
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
- Then create the executable `.habit-hooks/ruby/sensors/custom-check.sh`:
|
|
276
|
+
|
|
277
|
+
```sh
|
|
278
|
+
#!/bin/sh
|
|
279
|
+
printf '%s\n' '[{"smell":"custom-check","details":{},"issues":[{"key":"app/models/example.rb","details":{"file":"app/models/example.rb","line":1,"message":"Canned custom-check result","source":"custom-check"}}]}]'
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
The sensor's issue paths should be relative to the project, and its
|
|
283
|
+
output must always be a JSON array of findings.
|
|
284
|
+
|
|
285
|
+
- Enable it by copying the entire `plugins/ruby/src/habit_hooks_ruby/config.toml` to `.habit-hooks/ruby/config.toml` and adding `"custom-check"` to the `sensors` list in the plugin config
|
|
286
|
+
override. for example:
|
|
287
|
+
```
|
|
288
|
+
sensors = ["rubocop", "custom-check"]
|
|
289
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
habit_hooks_ruby/__init__.py,sha256=IDQkTs3XygPeFrj24uTPDXiU165SPzfjWddf4QFrCAk,100
|
|
2
|
+
habit_hooks_ruby/config.toml,sha256=HPAT3pBTy8PVt3n094T_Rw_U96b1-5aXd_GuPR-YrsM,2072
|
|
3
|
+
habit_hooks_ruby/guides/oversized-block.md,sha256=Siy5k3RC9rNsE0LTD5I5OpHu7Y-eTnQqiwMZt0zBQUk,777
|
|
4
|
+
habit_hooks_ruby/guides/parse-error.md,sha256=ZGJijGB9_YX2w1bXJfkG8IxV6lIdAOL3V1TwThkqYlA,683
|
|
5
|
+
habit_hooks_ruby/sensors/rubocop.toml,sha256=uH-Vb_xiubtU0ZirIWS7dX1_XQg3iqHI_ngMHlEYBHY,84
|
|
6
|
+
habit_hooks_ruby/sensors/rubocop_report.py,sha256=d8xFDVHBvuaUZJmCFCUN0xb_vKGAPUSZ0lqoS3insmU,4215
|
|
7
|
+
habit_hooks_ruby/sensors/rubocop_sensor.py,sha256=OckPokxoOQIGJTDN8J2pDcu0MjSewiXIh839s5Y_ucA,7045
|
|
8
|
+
habit_hooks_ruby-1.5.0.dist-info/METADATA,sha256=KxMhBYeBLV1UWVUeSCrI8S5d5VGFZBrZee9YCBa5EsI,10827
|
|
9
|
+
habit_hooks_ruby-1.5.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
habit_hooks_ruby-1.5.0.dist-info/entry_points.txt,sha256=kC7UTDgDJIj_h_d9uO18B6H66q0l97MW8aqPJrK_vP4,46
|
|
11
|
+
habit_hooks_ruby-1.5.0.dist-info/RECORD,,
|