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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [habit_hooks.plugins]
2
+ ruby = habit_hooks_ruby