habit-hooks-ruby 1.5.0__tar.gz

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,26 @@
1
+ # Per-plugin Node tool deps (installed via pnpm install; see pnpm-workspace.yaml)
2
+ node_modules/
3
+ dist
4
+ coverage
5
+ .DS_Store
6
+ .idea
7
+ .claude-channel/
8
+ *.tgz
9
+ *.log
10
+ .vscode/
11
+ .venv/
12
+ __pycache__/
13
+ *.pyc
14
+ .pytest_cache/
15
+ .spec-runs/
16
+
17
+ # Workflow orchestration script (run from ~/.claude, never a repo deliverable)
18
+ .claude/workflows-build-overnight.js
19
+
20
+ # Agent worktrees (created by the harness inside the checkout)
21
+ .claude/worktrees/
22
+ /scratches/
23
+
24
+ # unsupervised-issues run signals (live mode switch + stop flag)
25
+ .unsupervised-issues.mode
26
+ .unsupervised-issues.stop
@@ -0,0 +1,171 @@
1
+ # habit-hooks-ruby notes
2
+
3
+ ## Architecture
4
+
5
+ ### The project's `.rubocop.yml` decides everything
6
+
7
+ The sensor passes no `--only` and no `--config`. RuboCop finds the project's
8
+ config by its own upward walk, so a project's habit-hooks run is the run it gets
9
+ from RuboCop directly. There is no shipped fallback config, and the plugin never
10
+ writes one into a project. The README carries a suggested `.rubocop.yml` as
11
+ documentation only — unlike jscpd or pmd, RuboCop's config already exists in
12
+ almost every real Ruby project, so this plugin has no fallback case to cover.
13
+
14
+ `--force-exclusion` is the one flag the sensor adds, and it is not optional.
15
+ RuboCop applies `AllCops: Exclude:` to the files it *discovers*, but treats a
16
+ file named on the command line as a deliberate request and lints it anyway.
17
+ Since habit-hooks always names a scope, without that flag a project's own
18
+ exclusions would stop meaning anything the moment this tool ran.
19
+
20
+ ### An unmapped cop is forwarded, not dropped
21
+
22
+ This is the same exception to "a sensor emits vocabulary smells only" that the
23
+ root `CLAUDE.md` makes for eslint: a cop that fired is one the project's own
24
+ `.rubocop.yml` turned on, so forwarding it forwards the project's own decision.
25
+
26
+ The smell key is the cop name verbatim, `Style/StringLiterals`. Nothing downstream breaks on one: the
27
+ guide lookup misses, the finding renders through `uncoached.md`, and the root
28
+ `uncoached` key (default `suggest`) decides whether it fails the run.
29
+
30
+ Two cops are unmapped **on purpose** rather than by omission, so do not "fix"
31
+ them by adding rows. `Metrics/ClassLength` and `Metrics/ModuleLength` measure a
32
+ class or module, and the vocabulary has no class-scoped smell for them to back.
33
+ They stay **uncoached** — forwarded under their own names, rendered through
34
+ `uncoached.md`, `suggest` until a class and module scoped coach exists.
35
+
36
+ The three complexity cops all map to `high-complexity` (human decision). They
37
+ are correlated but independent — `PerceivedComplexity` weights nesting and
38
+ `AbcSize` counts assignments and calls, so either fires without
39
+ `CyclomaticComplexity` — and an unmapped complexity cop coached a genuinely
40
+ tangled method through `uncoached.md` when the real guide applied verbatim. The
41
+ old objection, one method reported three times over, is dissolved by the
42
+ mapper: this sensor groups every cop that fired on a smell into one finding's
43
+ issue list, which is never deduped (issue #140), so a method tripping two cops
44
+ keeps both measurements inside a single coaching block.
45
+
46
+ ## Gotchas
47
+
48
+ ### RuboCop reads a file argument as options, then as a glob
49
+
50
+ A scope filename is exact, and RuboCop reads its file arguments two other ways
51
+ before it reads them as names. A filename beginning with `-` is parsed as short
52
+ options — `-c` among them, which takes the rest as its `--config` value — and an
53
+ argument containing a `*` is handed to `Dir[]`
54
+ (`TargetFinder#process_explicit_path`), so a literal star sweeps in every file
55
+ it matches. `run_rubocop` therefore puts `--` between its flags and the files,
56
+ and `literal_spelling_of` escapes the glob metacharacters.
57
+
58
+ The escaping is narrower than it looks, and has to be. RuboCop globs only an
59
+ argument containing a `*`; every other one it takes verbatim, backslashes
60
+ included, so escaping a `?` in a starless path would name a file that does not
61
+ exist and the run would die on `Error: No such file or directory`. The other
62
+ metacharacters are escaped only inside a starred argument, where `Dir[]` is
63
+ reading the whole thing as a pattern. The literal-star behavioural test runs
64
+ only where a filesystem allows a `*` in a name — Windows forbids it — so it
65
+ skips there through
66
+ `tests/platform_probe.A_FILESYSTEM_THAT_ALLOWS_A_STAR_IN_A_FILENAME`.
67
+
68
+ ### RuboCop's exit code lies when the binstub never reached RuboCop
69
+
70
+ `ruff_sensor` can trust ruff's exit code. `rubocop` is a RubyGems
71
+ binstub beginning `#!/usr/bin/env ruby`, so it is only as good as the `ruby`
72
+ that answers first. Point it at an interpreter it is not installed into (a
73
+ version manager left off `PATH`, the wrong bundle, macOS's system Ruby 2.6) and
74
+ it dies in `find_spec_for_exe` with a Ruby traceback and **exit 1** — the code
75
+ reserved for "I found offences". Judged on the code alone, that is a clean file
76
+ from a tool that never started, which is the false-clean class issue #88 exists
77
+ for.
78
+
79
+ So the report is the evidence, not the code. `--format json` prints its envelope
80
+ on every run RuboCop completed, down to `"files": []` when it inspected nothing,
81
+ so no envelope means no run whatever it exited with. `report()` answers `None`
82
+ for anything that is not a report, and it requires the `files` key rather than
83
+ merely valid JSON. `rubocop_crashed()` takes the parsed report as an argument
84
+ instead of re-deriving it, so the rule lives in one place and `main` cannot
85
+ drift from what the tests exercise.
86
+
87
+ Ask this of any wrapped tool reached through an interpreter shim rather than a
88
+ binary. The cost of getting it wrong is silence, and silence reads as success.
89
+
90
+ The envelope check has one concern: a cop that *raises exceptions*.
91
+ By default, RuboCop rescues the exception, reports the crash on stderr,
92
+ and exits 1 with a valid envelope listing that file's offences as `[]`.
93
+ This isn't what habit-hooks needs to detect the crash. So `--raise-cop-error`
94
+ is used to make a RuboCop crash become an `Error:` and exit 2, which
95
+ habit-hooks interprets as a failed run.
96
+
97
+ `tests/test_the_sensor_runs_the_rubocop_it_is_handed.py` has to hand `ruby` back
98
+ on a directory of its own for the same reason. The binstub and its interpreter
99
+ live in one directory, so taking `rubocop` off `PATH` takes `ruby` with it, and
100
+ the test would then be proving the crash rather than the lookup.
101
+
102
+ ### RuboCop globs every ancestor directory looking for a gemspec
103
+
104
+ `TargetRuby` settles which Ruby to parse as by trying, in order,
105
+ `TargetRubyVersion` in the config, then any `*.gemspec`, then `.ruby-version`.
106
+ The gemspec step is a `Dir.glob` up every ancestor directory to the filesystem
107
+ root, and **`.ruby-version` does not prevent it**, because RuboCop looks for the
108
+ gemspec first. A Rails app, which has no gemspec, therefore sends RuboCop
109
+ climbing out of the project on every run. A gem stops the climb by having a
110
+ gemspec; everything else stops it by pinning `TargetRubyVersion`.
111
+
112
+ That is RuboCop's own behaviour and the sensor reproduces it rather than
113
+ papering over it, per the precedence rule in the root `CLAUDE.md`. It matters
114
+ for the tests because the spec harness runs each case in
115
+ `<repo>/.spec-runs/tmpXXXX/`: a case pinning neither climbs through this
116
+ checkout and out into the home directory, where a sandboxed dev machine denies
117
+ the glob outright and the sensor fails for a reason that has nothing to do with
118
+ the case. Every case in `docs/ruby-plugin.spec.md` and
119
+ `tests/installed_projects.ruby_project` pins `TargetRubyVersion`.
120
+
121
+ It is the RuboCop counterpart of the root `CLAUDE.md`'s `GIT_CEILING_DIRECTORIES`
122
+ rule and of jscpd's `.gitignore` walk: a wrapped tool that searches upward has
123
+ to be given a floor, or it finds ours.
124
+
125
+ ### A Rails project's rubocop is the only one that can read its config
126
+
127
+ A `.rubocop.yml` naming cops from an extension gem is a hard RuboCop error when
128
+ that gem is not loadable:
129
+
130
+ ```
131
+ Error: `Rails/*` has been extracted to the `rubocop-rails` gem.
132
+ ```
133
+
134
+ Nearly every Rails repo pins rubocop plus `rubocop-rails` / `rubocop-rspec` /
135
+ `rubocop-performance` in its `Gemfile`, and those gems load only under the
136
+ project's own bundle. This is the likeliest way the sensor fails in practice,
137
+ and it is why the plugin's rubocop detector declares
138
+ `search_paths = ["bin"]` (`src/habit_hooks_ruby/config.toml`):
139
+ `bundle binstubs rubocop` writes `bin/rubocop`, and every lookup for that
140
+ tool — `missing_tools` clearing it, `sensors/named_tools.py` resolving the
141
+ recipe's `${detector:rubocop}` — searches the project's `bin` ahead of the
142
+ default path.
143
+
144
+ The lookup belongs in the core and not in this sensor. `missing_tools`
145
+ clears a tool by asking `tool_executable`, and `sensors/spawn.py` spawns the
146
+ file it answered with, so a second answer here would let setup clear a rubocop
147
+ the run then does not use. And `bin` is deliberately **not** on the default
148
+ search path (`project_paths.tool_search_path`), only on this detector's:
149
+ `node_modules/.bin` and `.venv/bin` are directories an install keeps to
150
+ itself, where a `bin` may hold a project's own scripts, and a script named
151
+ after a tool should not outrank a real install beside it. Naming the
152
+ directory on the detector keeps it in play for exactly the tools that live
153
+ in it.
154
+
155
+ ## Testing
156
+
157
+ `tests/conftest.py` puts `src/habit_hooks_ruby/sensors` on `sys.path` so the
158
+ helper loads as a loose top-level module, which is how
159
+ `${python} ${dir}/rubocop_sensor.py` loads it. Reaching it as
160
+ `habit_hooks_ruby.sensors.rubocop_sensor` is a path no run ever takes.
161
+
162
+ A missing rubocop is a `pytest.fail`, not a skip, matching every other plugin: a
163
+ machine without it is a suite that has quietly stopped gating, not a machine
164
+ this plugin does not apply to.
165
+
166
+ The split between `test_the_rubocop_pipeline_maps_cops_to_smells.py` and
167
+ `test_the_rubocop_sensor_runs_the_real_tool.py` is worth keeping. The first says
168
+ what RuboCop's output *becomes* and runs no subprocess; the second proves
169
+ RuboCop still produces output of the shape the first assumes. A RuboCop upgrade
170
+ that renames `cop_name` or restructures `location` fails the second alone, which
171
+ is the signal you want.
@@ -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
+ ```