rubyn-code 0.1.0
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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +620 -0
- data/db/migrations/000_create_schema_migrations.sql +4 -0
- data/db/migrations/001_create_sessions.sql +16 -0
- data/db/migrations/002_create_messages.sql +16 -0
- data/db/migrations/003_create_tasks.sql +17 -0
- data/db/migrations/004_create_task_dependencies.sql +8 -0
- data/db/migrations/005_create_memories.sql +44 -0
- data/db/migrations/006_create_cost_records.sql +16 -0
- data/db/migrations/007_create_hooks.sql +12 -0
- data/db/migrations/008_create_skills_cache.sql +8 -0
- data/db/migrations/009_create_teams.sql +27 -0
- data/db/migrations/010_create_instincts.sql +15 -0
- data/exe/rubyn-code +6 -0
- data/lib/rubyn_code/agent/conversation.rb +193 -0
- data/lib/rubyn_code/agent/loop.rb +517 -0
- data/lib/rubyn_code/agent/loop_detector.rb +78 -0
- data/lib/rubyn_code/auth/oauth.rb +174 -0
- data/lib/rubyn_code/auth/server.rb +126 -0
- data/lib/rubyn_code/auth/token_store.rb +153 -0
- data/lib/rubyn_code/autonomous/daemon.rb +233 -0
- data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
- data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
- data/lib/rubyn_code/background/job.rb +19 -0
- data/lib/rubyn_code/background/notifier.rb +44 -0
- data/lib/rubyn_code/background/worker.rb +146 -0
- data/lib/rubyn_code/cli/app.rb +118 -0
- data/lib/rubyn_code/cli/input_handler.rb +79 -0
- data/lib/rubyn_code/cli/renderer.rb +205 -0
- data/lib/rubyn_code/cli/repl.rb +519 -0
- data/lib/rubyn_code/cli/spinner.rb +100 -0
- data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
- data/lib/rubyn_code/config/defaults.rb +43 -0
- data/lib/rubyn_code/config/project_config.rb +120 -0
- data/lib/rubyn_code/config/settings.rb +127 -0
- data/lib/rubyn_code/context/auto_compact.rb +81 -0
- data/lib/rubyn_code/context/compactor.rb +89 -0
- data/lib/rubyn_code/context/manager.rb +91 -0
- data/lib/rubyn_code/context/manual_compact.rb +87 -0
- data/lib/rubyn_code/context/micro_compact.rb +135 -0
- data/lib/rubyn_code/db/connection.rb +176 -0
- data/lib/rubyn_code/db/migrator.rb +146 -0
- data/lib/rubyn_code/db/schema.rb +106 -0
- data/lib/rubyn_code/hooks/built_in.rb +124 -0
- data/lib/rubyn_code/hooks/registry.rb +99 -0
- data/lib/rubyn_code/hooks/runner.rb +88 -0
- data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
- data/lib/rubyn_code/learning/extractor.rb +191 -0
- data/lib/rubyn_code/learning/injector.rb +138 -0
- data/lib/rubyn_code/learning/instinct.rb +172 -0
- data/lib/rubyn_code/llm/client.rb +218 -0
- data/lib/rubyn_code/llm/message_builder.rb +116 -0
- data/lib/rubyn_code/llm/streaming.rb +203 -0
- data/lib/rubyn_code/mcp/client.rb +139 -0
- data/lib/rubyn_code/mcp/config.rb +83 -0
- data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
- data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
- data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
- data/lib/rubyn_code/memory/models.rb +62 -0
- data/lib/rubyn_code/memory/search.rb +181 -0
- data/lib/rubyn_code/memory/session_persistence.rb +194 -0
- data/lib/rubyn_code/memory/store.rb +199 -0
- data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
- data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
- data/lib/rubyn_code/observability/models.rb +29 -0
- data/lib/rubyn_code/observability/token_counter.rb +42 -0
- data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
- data/lib/rubyn_code/output/diff_renderer.rb +212 -0
- data/lib/rubyn_code/output/formatter.rb +120 -0
- data/lib/rubyn_code/permissions/deny_list.rb +49 -0
- data/lib/rubyn_code/permissions/policy.rb +59 -0
- data/lib/rubyn_code/permissions/prompter.rb +80 -0
- data/lib/rubyn_code/permissions/tier.rb +22 -0
- data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
- data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
- data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
- data/lib/rubyn_code/skills/catalog.rb +70 -0
- data/lib/rubyn_code/skills/document.rb +80 -0
- data/lib/rubyn_code/skills/loader.rb +57 -0
- data/lib/rubyn_code/sub_agents/runner.rb +168 -0
- data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
- data/lib/rubyn_code/tasks/dag.rb +208 -0
- data/lib/rubyn_code/tasks/manager.rb +212 -0
- data/lib/rubyn_code/tasks/models.rb +31 -0
- data/lib/rubyn_code/teams/mailbox.rb +128 -0
- data/lib/rubyn_code/teams/manager.rb +175 -0
- data/lib/rubyn_code/teams/teammate.rb +38 -0
- data/lib/rubyn_code/tools/background_run.rb +41 -0
- data/lib/rubyn_code/tools/base.rb +84 -0
- data/lib/rubyn_code/tools/bash.rb +81 -0
- data/lib/rubyn_code/tools/bundle_add.rb +53 -0
- data/lib/rubyn_code/tools/bundle_install.rb +41 -0
- data/lib/rubyn_code/tools/compact.rb +57 -0
- data/lib/rubyn_code/tools/db_migrate.rb +52 -0
- data/lib/rubyn_code/tools/edit_file.rb +49 -0
- data/lib/rubyn_code/tools/executor.rb +62 -0
- data/lib/rubyn_code/tools/git_commit.rb +97 -0
- data/lib/rubyn_code/tools/git_diff.rb +61 -0
- data/lib/rubyn_code/tools/git_log.rb +59 -0
- data/lib/rubyn_code/tools/git_status.rb +59 -0
- data/lib/rubyn_code/tools/glob.rb +44 -0
- data/lib/rubyn_code/tools/grep.rb +81 -0
- data/lib/rubyn_code/tools/load_skill.rb +41 -0
- data/lib/rubyn_code/tools/memory_search.rb +77 -0
- data/lib/rubyn_code/tools/memory_write.rb +52 -0
- data/lib/rubyn_code/tools/rails_generate.rb +54 -0
- data/lib/rubyn_code/tools/read_file.rb +38 -0
- data/lib/rubyn_code/tools/read_inbox.rb +64 -0
- data/lib/rubyn_code/tools/registry.rb +48 -0
- data/lib/rubyn_code/tools/review_pr.rb +145 -0
- data/lib/rubyn_code/tools/run_specs.rb +75 -0
- data/lib/rubyn_code/tools/schema.rb +59 -0
- data/lib/rubyn_code/tools/send_message.rb +53 -0
- data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
- data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
- data/lib/rubyn_code/tools/task.rb +148 -0
- data/lib/rubyn_code/tools/web_fetch.rb +108 -0
- data/lib/rubyn_code/tools/web_search.rb +196 -0
- data/lib/rubyn_code/tools/write_file.rb +30 -0
- data/lib/rubyn_code/version.rb +5 -0
- data/lib/rubyn_code.rb +203 -0
- data/skills/code_quality/fits_in_your_head.md +189 -0
- data/skills/code_quality/naming_conventions.md +213 -0
- data/skills/code_quality/null_object.md +205 -0
- data/skills/code_quality/technical_debt.md +135 -0
- data/skills/code_quality/value_objects.md +216 -0
- data/skills/code_quality/yagni.md +176 -0
- data/skills/design_patterns/adapter.md +191 -0
- data/skills/design_patterns/bridge_memento_visitor.md +254 -0
- data/skills/design_patterns/builder.md +158 -0
- data/skills/design_patterns/command.md +126 -0
- data/skills/design_patterns/composite.md +147 -0
- data/skills/design_patterns/decorator.md +204 -0
- data/skills/design_patterns/facade.md +133 -0
- data/skills/design_patterns/factory_method.md +169 -0
- data/skills/design_patterns/iterator.md +116 -0
- data/skills/design_patterns/mediator.md +133 -0
- data/skills/design_patterns/observer.md +177 -0
- data/skills/design_patterns/proxy.md +140 -0
- data/skills/design_patterns/singleton.md +124 -0
- data/skills/design_patterns/state.md +207 -0
- data/skills/design_patterns/strategy.md +127 -0
- data/skills/design_patterns/template_method.md +173 -0
- data/skills/gems/devise.md +365 -0
- data/skills/gems/dry_rb.md +186 -0
- data/skills/gems/factory_bot.md +268 -0
- data/skills/gems/faraday.md +263 -0
- data/skills/gems/graphql_ruby.md +514 -0
- data/skills/gems/pundit.md +446 -0
- data/skills/gems/redis.md +219 -0
- data/skills/gems/rubocop.md +257 -0
- data/skills/gems/sidekiq.md +360 -0
- data/skills/gems/stripe.md +224 -0
- data/skills/minitest/assertions.md +185 -0
- data/skills/minitest/fixtures.md +238 -0
- data/skills/minitest/integration_tests.md +210 -0
- data/skills/minitest/mailers_and_jobs.md +218 -0
- data/skills/minitest/mocking_stubbing.md +202 -0
- data/skills/minitest/service_tests_and_performance.md +246 -0
- data/skills/minitest/structure_and_conventions.md +169 -0
- data/skills/minitest/system_tests.md +237 -0
- data/skills/rails/action_cable.md +160 -0
- data/skills/rails/active_record_basics.md +174 -0
- data/skills/rails/active_storage.md +242 -0
- data/skills/rails/api_design.md +212 -0
- data/skills/rails/associations.md +182 -0
- data/skills/rails/background_jobs.md +212 -0
- data/skills/rails/caching.md +158 -0
- data/skills/rails/callbacks.md +135 -0
- data/skills/rails/concerns_controllers.md +218 -0
- data/skills/rails/concerns_models.md +280 -0
- data/skills/rails/controllers.md +190 -0
- data/skills/rails/engines.md +201 -0
- data/skills/rails/form_objects.md +168 -0
- data/skills/rails/hotwire.md +229 -0
- data/skills/rails/internationalization.md +192 -0
- data/skills/rails/logging.md +198 -0
- data/skills/rails/mailers.md +180 -0
- data/skills/rails/migrations.md +200 -0
- data/skills/rails/multitenancy.md +207 -0
- data/skills/rails/n_plus_one.md +151 -0
- data/skills/rails/presenters.md +244 -0
- data/skills/rails/query_objects.md +177 -0
- data/skills/rails/routing.md +194 -0
- data/skills/rails/scopes.md +187 -0
- data/skills/rails/security.md +233 -0
- data/skills/rails/serializers.md +243 -0
- data/skills/rails/service_objects.md +184 -0
- data/skills/rails/testing_strategy.md +258 -0
- data/skills/rails/validations.md +206 -0
- data/skills/refactoring/code_smells.md +251 -0
- data/skills/refactoring/command_query_separation.md +166 -0
- data/skills/refactoring/encapsulate_collection.md +125 -0
- data/skills/refactoring/extract_class.md +138 -0
- data/skills/refactoring/extract_method.md +185 -0
- data/skills/refactoring/replace_conditional.md +211 -0
- data/skills/refactoring/value_objects.md +246 -0
- data/skills/rspec/build_stubbed.md +199 -0
- data/skills/rspec/factory_design.md +206 -0
- data/skills/rspec/let_vs_let_bang.md +161 -0
- data/skills/rspec/mocking_stubbing.md +209 -0
- data/skills/rspec/request_specs.md +212 -0
- data/skills/rspec/service_specs.md +262 -0
- data/skills/rspec/shared_examples.md +244 -0
- data/skills/rspec/system_specs.md +286 -0
- data/skills/rspec/test_performance.md +215 -0
- data/skills/ruby/blocks_procs_lambdas.md +204 -0
- data/skills/ruby/classes.md +155 -0
- data/skills/ruby/concurrency.md +194 -0
- data/skills/ruby/data_struct_openstruct.md +158 -0
- data/skills/ruby/debugging_profiling.md +204 -0
- data/skills/ruby/enumerable_patterns.md +168 -0
- data/skills/ruby/exception_handling.md +199 -0
- data/skills/ruby/file_io.md +217 -0
- data/skills/ruby/hashes.md +195 -0
- data/skills/ruby/metaprogramming.md +170 -0
- data/skills/ruby/modules.md +210 -0
- data/skills/ruby/pattern_matching.md +177 -0
- data/skills/ruby/regular_expressions.md +166 -0
- data/skills/ruby/result_objects.md +200 -0
- data/skills/ruby/strings.md +177 -0
- data/skills/ruby_project/bundler_dependencies.md +181 -0
- data/skills/ruby_project/cli_tools.md +224 -0
- data/skills/ruby_project/rake_tasks.md +146 -0
- data/skills/ruby_project/structure.md +261 -0
- data/skills/sinatra/application_structure.md +241 -0
- data/skills/sinatra/middleware_and_deployment.md +221 -0
- data/skills/sinatra/testing.md +233 -0
- data/skills/solid/dependency_inversion.md +195 -0
- data/skills/solid/interface_segregation.md +237 -0
- data/skills/solid/liskov_substitution.md +263 -0
- data/skills/solid/open_closed.md +212 -0
- data/skills/solid/single_responsibility.md +183 -0
- metadata +397 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# Gems: RuboCop
|
|
2
|
+
|
|
3
|
+
## Pattern
|
|
4
|
+
|
|
5
|
+
RuboCop enforces consistent Ruby style across the team. Start with a reasonable base configuration, customize to match your project's conventions, and run it in CI to catch violations before code review.
|
|
6
|
+
|
|
7
|
+
### Setup
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
group :development, :test do
|
|
12
|
+
gem "rubocop", require: false
|
|
13
|
+
gem "rubocop-rails", require: false # Rails-specific cops
|
|
14
|
+
gem "rubocop-rspec", require: false # RSpec-specific cops (if using RSpec)
|
|
15
|
+
gem "rubocop-minitest", require: false # Minitest-specific cops (if using Minitest)
|
|
16
|
+
gem "rubocop-performance", require: false # Performance-focused cops
|
|
17
|
+
gem "rubocop-rails-omakase", require: false # DHH's Rails opinions (optional)
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### Configuration
|
|
22
|
+
|
|
23
|
+
```yaml
|
|
24
|
+
# .rubocop.yml
|
|
25
|
+
require:
|
|
26
|
+
- rubocop-rails
|
|
27
|
+
- rubocop-rspec # or rubocop-minitest
|
|
28
|
+
- rubocop-performance
|
|
29
|
+
|
|
30
|
+
AllCops:
|
|
31
|
+
TargetRubyVersion: 3.3
|
|
32
|
+
NewCops: enable
|
|
33
|
+
Exclude:
|
|
34
|
+
- "db/schema.rb"
|
|
35
|
+
- "db/migrate/**/*"
|
|
36
|
+
- "bin/**/*"
|
|
37
|
+
- "vendor/**/*"
|
|
38
|
+
- "node_modules/**/*"
|
|
39
|
+
- "tmp/**/*"
|
|
40
|
+
|
|
41
|
+
# ─── Style ──────────────────────────────────────
|
|
42
|
+
Style/FrozenStringLiteralComment:
|
|
43
|
+
Enabled: true
|
|
44
|
+
EnforcedStyle: always
|
|
45
|
+
|
|
46
|
+
Style/StringLiterals:
|
|
47
|
+
EnforcedStyle: double_quotes
|
|
48
|
+
|
|
49
|
+
Style/SymbolArray:
|
|
50
|
+
EnforcedStyle: brackets
|
|
51
|
+
|
|
52
|
+
Style/WordArray:
|
|
53
|
+
EnforcedStyle: brackets
|
|
54
|
+
|
|
55
|
+
Style/Documentation:
|
|
56
|
+
Enabled: false # Don't require class documentation comments
|
|
57
|
+
|
|
58
|
+
Style/ClassAndModuleChildren:
|
|
59
|
+
Enabled: false # Allow both nested and compact styles
|
|
60
|
+
|
|
61
|
+
# ─── Layout ─────────────────────────────────────
|
|
62
|
+
Layout/LineLength:
|
|
63
|
+
Max: 120 # 80 is too aggressive for modern screens
|
|
64
|
+
AllowedPatterns:
|
|
65
|
+
- "^\\s*#" # Don't enforce on comments
|
|
66
|
+
- "https?://" # Don't break URLs
|
|
67
|
+
|
|
68
|
+
Layout/MultilineMethodCallIndentation:
|
|
69
|
+
EnforcedStyle: indented
|
|
70
|
+
|
|
71
|
+
# ─── Metrics ────────────────────────────────────
|
|
72
|
+
Metrics/MethodLength:
|
|
73
|
+
Max: 15 # Default 10 is too strict for real-world Rails
|
|
74
|
+
CountAsOne:
|
|
75
|
+
- array
|
|
76
|
+
- hash
|
|
77
|
+
- heredoc
|
|
78
|
+
Exclude:
|
|
79
|
+
- "db/migrate/**/*"
|
|
80
|
+
|
|
81
|
+
Metrics/ClassLength:
|
|
82
|
+
Max: 200 # Models can be long — extract when it hurts
|
|
83
|
+
Exclude:
|
|
84
|
+
- "app/models/**/*" # Models get a pass — concerns handle extraction
|
|
85
|
+
|
|
86
|
+
Metrics/AbcSize:
|
|
87
|
+
Max: 20 # Default 17 triggers on straightforward methods
|
|
88
|
+
|
|
89
|
+
Metrics/BlockLength:
|
|
90
|
+
Exclude:
|
|
91
|
+
- "spec/**/*" # RSpec blocks are naturally long
|
|
92
|
+
- "test/**/*" # Minitest blocks too
|
|
93
|
+
- "config/routes.rb"
|
|
94
|
+
- "config/environments/**/*"
|
|
95
|
+
- "lib/tasks/**/*"
|
|
96
|
+
- "*.gemspec"
|
|
97
|
+
|
|
98
|
+
# ─── Rails ──────────────────────────────────────
|
|
99
|
+
Rails/HasManyOrHasOneDependent:
|
|
100
|
+
Enabled: true # Force dependent: option on has_many/has_one
|
|
101
|
+
|
|
102
|
+
Rails/InverseOf:
|
|
103
|
+
Enabled: true # Suggest inverse_of on associations
|
|
104
|
+
|
|
105
|
+
Rails/UnknownEnv:
|
|
106
|
+
Environments:
|
|
107
|
+
- production
|
|
108
|
+
- development
|
|
109
|
+
- test
|
|
110
|
+
- staging
|
|
111
|
+
|
|
112
|
+
# ─── RSpec ──────────────────────────────────────
|
|
113
|
+
RSpec/ExampleLength:
|
|
114
|
+
Max: 15 # Default 5 is too strict
|
|
115
|
+
CountAsOne:
|
|
116
|
+
- array
|
|
117
|
+
- hash
|
|
118
|
+
- heredoc
|
|
119
|
+
|
|
120
|
+
RSpec/MultipleExpectations:
|
|
121
|
+
Max: 5 # Allow aggregate_failures style
|
|
122
|
+
|
|
123
|
+
RSpec/NestedGroups:
|
|
124
|
+
Max: 4
|
|
125
|
+
|
|
126
|
+
RSpec/MultipleMemoizedHelpers:
|
|
127
|
+
Max: 8 # Real specs need setup
|
|
128
|
+
|
|
129
|
+
# ─── Performance ────────────────────────────────
|
|
130
|
+
Performance/CollectionLiteralInLoop:
|
|
131
|
+
Enabled: true
|
|
132
|
+
|
|
133
|
+
Performance/Count:
|
|
134
|
+
Enabled: true # Prefer .count over .select { }.size
|
|
135
|
+
|
|
136
|
+
Performance/Detect:
|
|
137
|
+
Enabled: true # Prefer .detect over .select { }.first
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### Running
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
# Check all files
|
|
144
|
+
bundle exec rubocop
|
|
145
|
+
|
|
146
|
+
# Check specific files
|
|
147
|
+
bundle exec rubocop app/models/order.rb
|
|
148
|
+
|
|
149
|
+
# Auto-correct safe violations
|
|
150
|
+
bundle exec rubocop -a
|
|
151
|
+
|
|
152
|
+
# Auto-correct all violations (including unsafe)
|
|
153
|
+
bundle exec rubocop -A
|
|
154
|
+
|
|
155
|
+
# Generate a TODO file for existing violations (adopt incrementally)
|
|
156
|
+
bundle exec rubocop --auto-gen-config
|
|
157
|
+
# Creates .rubocop_todo.yml — inherit from it and fix violations over time
|
|
158
|
+
|
|
159
|
+
# Check only new/modified files (CI optimization)
|
|
160
|
+
bundle exec rubocop --only-changed
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### CI Integration
|
|
164
|
+
|
|
165
|
+
```yaml
|
|
166
|
+
# .github/workflows/lint.yml
|
|
167
|
+
name: Lint
|
|
168
|
+
on: [push, pull_request]
|
|
169
|
+
jobs:
|
|
170
|
+
rubocop:
|
|
171
|
+
runs-on: ubuntu-latest
|
|
172
|
+
steps:
|
|
173
|
+
- uses: actions/checkout@v4
|
|
174
|
+
- uses: ruby/setup-ruby@v1
|
|
175
|
+
with:
|
|
176
|
+
bundler-cache: true
|
|
177
|
+
- run: bundle exec rubocop --parallel
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Incremental Adoption for Existing Projects
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
# Step 1: Generate TODO file listing all current violations
|
|
184
|
+
bundle exec rubocop --auto-gen-config --auto-gen-only-exclude
|
|
185
|
+
|
|
186
|
+
# Step 2: Add to .rubocop.yml
|
|
187
|
+
# inherit_from: .rubocop_todo.yml
|
|
188
|
+
|
|
189
|
+
# Step 3: Fix violations gradually
|
|
190
|
+
# Each PR that touches a file fixes its violations
|
|
191
|
+
# Over time, the TODO file shrinks to zero
|
|
192
|
+
|
|
193
|
+
# Step 4: Remove the TODO file when it's empty
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Custom Cops (Advanced)
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
# lib/rubocop/cop/custom/no_direct_api_calls.rb
|
|
200
|
+
module RuboCop
|
|
201
|
+
module Cop
|
|
202
|
+
module Custom
|
|
203
|
+
class NoDirectApiCalls < Base
|
|
204
|
+
MSG = "Use a service object or adapter instead of calling external APIs directly in controllers."
|
|
205
|
+
|
|
206
|
+
RESTRICTED_RECEIVERS = %w[Faraday Net::HTTP HTTParty RestClient].freeze
|
|
207
|
+
|
|
208
|
+
def on_send(node)
|
|
209
|
+
return unless in_controller?(node)
|
|
210
|
+
return unless RESTRICTED_RECEIVERS.include?(node.receiver&.const_name)
|
|
211
|
+
|
|
212
|
+
add_offense(node)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
def in_controller?(node)
|
|
218
|
+
node.each_ancestor(:class).any? do |klass|
|
|
219
|
+
klass.identifier.source.end_with?("Controller")
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
```yaml
|
|
229
|
+
# .rubocop.yml
|
|
230
|
+
require:
|
|
231
|
+
- ./lib/rubocop/cop/custom/no_direct_api_calls
|
|
232
|
+
|
|
233
|
+
Custom/NoDirectApiCalls:
|
|
234
|
+
Enabled: true
|
|
235
|
+
Include:
|
|
236
|
+
- "app/controllers/**/*"
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
## Why This Is Good
|
|
240
|
+
|
|
241
|
+
- **Consistent style without arguments.** The config decides once. Every developer and every PR follows the same style. No more "tabs vs spaces" debates.
|
|
242
|
+
- **Catches real bugs.** `Rails/HasManyOrHasOneDependent` catches missing `dependent:` options. `Performance/Detect` catches `.select { }.first` instead of `.detect`. These are functional improvements, not just style.
|
|
243
|
+
- **Auto-correct saves time.** `rubocop -a` fixes 70-80% of violations automatically. String quote style, trailing whitespace, frozen string literal — all fixed in one command.
|
|
244
|
+
- **Incremental adoption.** `--auto-gen-config` lets you adopt RuboCop on existing projects without fixing 500 violations in one PR. Fix as you go.
|
|
245
|
+
- **CI enforcement.** Violations are caught before code review. Reviewers focus on design and logic, not style.
|
|
246
|
+
|
|
247
|
+
## When To Apply
|
|
248
|
+
|
|
249
|
+
- **Every Ruby project.** RuboCop is non-negotiable. Install it, configure it, run it in CI.
|
|
250
|
+
- **Day one of a new project.** Start clean — no TODO file needed.
|
|
251
|
+
- **Existing projects.** Use `--auto-gen-config` for gradual adoption. Fix violations file-by-file as you touch them.
|
|
252
|
+
|
|
253
|
+
## When NOT To Apply
|
|
254
|
+
|
|
255
|
+
- **Don't enforce every cop.** Disable cops that don't match your team's preferences. `Style/Documentation` (requires class comments) is commonly disabled. `Metrics/MethodLength: 10` is too strict for most Rails apps.
|
|
256
|
+
- **Don't auto-correct in bulk on large codebases.** A 500-file auto-correct commit is impossible to review. Fix incrementally.
|
|
257
|
+
- **Don't write custom cops until you have 50+ files.** Standard cops cover 99% of needs. Custom cops are for team-specific architectural rules.
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
# Gem: Sidekiq
|
|
2
|
+
|
|
3
|
+
## What It Is
|
|
4
|
+
|
|
5
|
+
Sidekiq processes background jobs using Redis-backed queues. It's the standard for async work in Rails — sending emails, processing uploads, calling external APIs, and running scheduled tasks. It uses threads (not processes) for concurrency, so it's memory efficient but requires thread-safe code.
|
|
6
|
+
|
|
7
|
+
## Setup Done Right
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
# Gemfile
|
|
11
|
+
gem 'sidekiq'
|
|
12
|
+
|
|
13
|
+
# config/application.rb
|
|
14
|
+
config.active_job.queue_adapter = :sidekiq
|
|
15
|
+
|
|
16
|
+
# config/initializers/sidekiq.rb
|
|
17
|
+
Sidekiq.configure_server do |config|
|
|
18
|
+
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
Sidekiq.configure_client do |config|
|
|
22
|
+
config.redis = { url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0") }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# config/sidekiq.yml
|
|
26
|
+
:concurrency: 10
|
|
27
|
+
:queues:
|
|
28
|
+
- [critical, 3]
|
|
29
|
+
- [default, 2]
|
|
30
|
+
- [low, 1]
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Gotcha #1: Jobs Must Be Idempotent
|
|
34
|
+
|
|
35
|
+
Sidekiq guarantees "at least once" delivery. Jobs can run multiple times due to network issues, process crashes, or manual retries. If your job isn't idempotent, duplicate execution causes real damage.
|
|
36
|
+
|
|
37
|
+
```ruby
|
|
38
|
+
# WRONG: Non-idempotent — double execution sends two emails
|
|
39
|
+
class OrderConfirmationJob < ApplicationJob
|
|
40
|
+
queue_as :default
|
|
41
|
+
|
|
42
|
+
def perform(order_id)
|
|
43
|
+
order = Order.find(order_id)
|
|
44
|
+
OrderMailer.confirmation(order).deliver_now
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# RIGHT: Idempotent — second execution is a no-op
|
|
49
|
+
class OrderConfirmationJob < ApplicationJob
|
|
50
|
+
queue_as :default
|
|
51
|
+
|
|
52
|
+
def perform(order_id)
|
|
53
|
+
order = Order.find(order_id)
|
|
54
|
+
return if order.confirmation_sent_at.present? # Already sent
|
|
55
|
+
|
|
56
|
+
OrderMailer.confirmation(order).deliver_now
|
|
57
|
+
order.update!(confirmation_sent_at: Time.current)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
```ruby
|
|
63
|
+
# WRONG: Non-idempotent — double execution double-charges
|
|
64
|
+
class ChargeJob < ApplicationJob
|
|
65
|
+
def perform(order_id)
|
|
66
|
+
order = Order.find(order_id)
|
|
67
|
+
Stripe::Charge.create(amount: order.total_cents, source: order.payment_token)
|
|
68
|
+
order.update!(paid: true)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# RIGHT: Check before charging, use idempotency keys
|
|
73
|
+
class ChargeJob < ApplicationJob
|
|
74
|
+
def perform(order_id)
|
|
75
|
+
order = Order.find(order_id)
|
|
76
|
+
return if order.paid? # Already charged
|
|
77
|
+
|
|
78
|
+
Stripe::Charge.create(
|
|
79
|
+
amount: order.total_cents,
|
|
80
|
+
source: order.payment_token,
|
|
81
|
+
idempotency_key: "order-#{order.id}" # Stripe deduplicates
|
|
82
|
+
)
|
|
83
|
+
order.update!(paid: true, paid_at: Time.current)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
**The trap:** The job runs, charges the card, then crashes before `update!(paid: true)`. Sidekiq retries. The card is charged again. Always check state before performing side effects, and use provider-level idempotency keys where available.
|
|
89
|
+
|
|
90
|
+
## Gotcha #2: Pass IDs, Not Objects
|
|
91
|
+
|
|
92
|
+
Sidekiq serializes arguments to JSON and stores them in Redis. ActiveRecord objects can't be serialized, and even if they could, they'd be stale by the time the job runs.
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
# WRONG: Passing an ActiveRecord object
|
|
96
|
+
OrderConfirmationJob.perform_later(order)
|
|
97
|
+
# ArgumentError: ActiveRecord objects can't be serialized to JSON
|
|
98
|
+
|
|
99
|
+
# WRONG: Passing a hash of attributes
|
|
100
|
+
OrderConfirmationJob.perform_later(order.attributes)
|
|
101
|
+
# Works but: 30 fields serialized, most unused. Stale data if order changes before job runs.
|
|
102
|
+
|
|
103
|
+
# RIGHT: Pass the ID, load fresh data in the job
|
|
104
|
+
OrderConfirmationJob.perform_later(order.id)
|
|
105
|
+
|
|
106
|
+
# RIGHT: For multiple simple values, pass them directly
|
|
107
|
+
CreditDeductionJob.perform_later(user.id, 5, "AI interaction")
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**The trap with ActiveJob:** ActiveJob has GlobalID which CAN serialize AR objects via `perform_later(order)`. But the object is loaded from the database when the job runs. If the record is deleted between enqueue and execution, you get `ActiveJob::DeserializationError`. Passing IDs and using `find_by` with nil handling is more robust.
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
# SAFER: Handle missing records
|
|
114
|
+
class OrderConfirmationJob < ApplicationJob
|
|
115
|
+
discard_on ActiveRecord::RecordNotFound
|
|
116
|
+
|
|
117
|
+
def perform(order_id)
|
|
118
|
+
order = Order.find(order_id) # Raises if deleted — discard_on handles it
|
|
119
|
+
# ...
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Gotcha #3: Thread Safety
|
|
125
|
+
|
|
126
|
+
Sidekiq uses threads. Shared mutable state across threads causes race conditions.
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
# WRONG: Class-level mutable state
|
|
130
|
+
class ImportJob < ApplicationJob
|
|
131
|
+
@@processed_count = 0 # Shared across all threads!
|
|
132
|
+
|
|
133
|
+
def perform(file_path)
|
|
134
|
+
CSV.foreach(file_path) do |row|
|
|
135
|
+
import_row(row)
|
|
136
|
+
@@processed_count += 1 # Race condition: two threads increment simultaneously
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# WRONG: Mutable instance variables that persist between jobs
|
|
142
|
+
class ApiClient
|
|
143
|
+
def initialize
|
|
144
|
+
@last_response = nil # Sidekiq reuses the instance across jobs
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# RIGHT: Use local variables or thread-safe structures
|
|
149
|
+
class ImportJob < ApplicationJob
|
|
150
|
+
def perform(file_path)
|
|
151
|
+
count = 0 # Local to this execution
|
|
152
|
+
CSV.foreach(file_path) do |row|
|
|
153
|
+
import_row(row)
|
|
154
|
+
count += 1
|
|
155
|
+
end
|
|
156
|
+
Rails.logger.info("Imported #{count} rows")
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# RIGHT: Use thread-safe operations for shared counters
|
|
161
|
+
class ImportJob < ApplicationJob
|
|
162
|
+
def perform(file_path)
|
|
163
|
+
# Redis is thread-safe — use it for shared state
|
|
164
|
+
Redis.current.set("import:#{file_path}:status", "processing")
|
|
165
|
+
# ...
|
|
166
|
+
Redis.current.set("import:#{file_path}:status", "complete")
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
**The trap:** Your job works perfectly in development (single thread). In production with `concurrency: 10`, two threads modify the same data and you get corrupted records, duplicate inserts, or wrong counts.
|
|
172
|
+
|
|
173
|
+
## Gotcha #4: Transaction + Job Enqueue Timing
|
|
174
|
+
|
|
175
|
+
If you enqueue a job inside a transaction that hasn't committed yet, the job might run before the transaction commits — and the record won't exist yet.
|
|
176
|
+
|
|
177
|
+
```ruby
|
|
178
|
+
# WRONG: Job fires before transaction commits
|
|
179
|
+
ActiveRecord::Base.transaction do
|
|
180
|
+
order = Order.create!(params)
|
|
181
|
+
OrderConfirmationJob.perform_later(order.id) # Job starts immediately
|
|
182
|
+
# Transaction hasn't committed yet — job might run and Order.find raises!
|
|
183
|
+
update_inventory(order)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# RIGHT: Enqueue after the transaction commits
|
|
187
|
+
ActiveRecord::Base.transaction do
|
|
188
|
+
order = Order.create!(params)
|
|
189
|
+
update_inventory(order)
|
|
190
|
+
end
|
|
191
|
+
# Transaction committed — record exists in DB
|
|
192
|
+
OrderConfirmationJob.perform_later(order.id)
|
|
193
|
+
|
|
194
|
+
# RIGHT: Use after_commit callback
|
|
195
|
+
class Order < ApplicationRecord
|
|
196
|
+
after_commit :send_confirmation, on: :create
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def send_confirmation
|
|
201
|
+
OrderConfirmationJob.perform_later(id)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Gotcha #5: Retry Strategy
|
|
207
|
+
|
|
208
|
+
Sidekiq retries failed jobs with exponential backoff by default (25 retries over ~21 days). This is usually too aggressive.
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
# Configure retries per job
|
|
212
|
+
class WebhookDeliveryJob < ApplicationJob
|
|
213
|
+
queue_as :webhooks
|
|
214
|
+
|
|
215
|
+
# ActiveJob retry configuration
|
|
216
|
+
retry_on Faraday::TimeoutError, wait: :polynomially_longer, attempts: 5
|
|
217
|
+
retry_on Faraday::ServerError, wait: 30.seconds, attempts: 3
|
|
218
|
+
discard_on Faraday::ClientError # 4xx errors won't succeed on retry
|
|
219
|
+
|
|
220
|
+
# OR: Sidekiq-native retry configuration (use one or the other, not both)
|
|
221
|
+
sidekiq_options retry: 5
|
|
222
|
+
|
|
223
|
+
def perform(webhook_id)
|
|
224
|
+
webhook = Webhook.find(webhook_id)
|
|
225
|
+
response = Faraday.post(webhook.url, webhook.payload.to_json)
|
|
226
|
+
raise Faraday::ServerError, "HTTP #{response.status}" unless response.success?
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Jobs that should NEVER retry
|
|
231
|
+
class OneTimeImportJob < ApplicationJob
|
|
232
|
+
sidekiq_options retry: 0 # or: discard_on StandardError
|
|
233
|
+
|
|
234
|
+
def perform(file_path)
|
|
235
|
+
# ...
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**The trap:** A job that calls an external API fails. Sidekiq retries 25 times over 21 days. The external API was down for 5 minutes. After it recovers, your job retries with stale data from 3 weeks ago. Set appropriate retry limits and consider discarding jobs that are too old.
|
|
241
|
+
|
|
242
|
+
## Gotcha #6: Queue Priority Starvation
|
|
243
|
+
|
|
244
|
+
Sidekiq processes queues in the order listed. Without weights, a flood of low-priority jobs can starve critical ones.
|
|
245
|
+
|
|
246
|
+
```yaml
|
|
247
|
+
# WRONG: No weights — Sidekiq processes strictly in order
|
|
248
|
+
:queues:
|
|
249
|
+
- critical
|
|
250
|
+
- default
|
|
251
|
+
- low
|
|
252
|
+
# If 1000 critical jobs are queued, default and low NEVER run until critical is empty
|
|
253
|
+
|
|
254
|
+
# RIGHT: Weighted queues
|
|
255
|
+
:queues:
|
|
256
|
+
- [critical, 3] # 3x more likely to be picked than low
|
|
257
|
+
- [default, 2] # 2x more likely than low
|
|
258
|
+
- [low, 1] # Baseline priority
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
```ruby
|
|
262
|
+
# Assign queues by job type
|
|
263
|
+
class PaymentJob < ApplicationJob
|
|
264
|
+
queue_as :critical
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
class EmailJob < ApplicationJob
|
|
268
|
+
queue_as :default
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
class ReportJob < ApplicationJob
|
|
272
|
+
queue_as :low
|
|
273
|
+
end
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
## Gotcha #7: Memory and Large Arguments
|
|
277
|
+
|
|
278
|
+
Sidekiq stores job arguments in Redis. Large arguments consume Redis memory and slow down serialization.
|
|
279
|
+
|
|
280
|
+
```ruby
|
|
281
|
+
# WRONG: Passing large data through Redis
|
|
282
|
+
class ProcessDataJob < ApplicationJob
|
|
283
|
+
def perform(csv_data) # csv_data could be 50MB
|
|
284
|
+
# This serializes 50MB to JSON, stores in Redis, deserializes in the worker
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# RIGHT: Pass a reference, load data in the job
|
|
289
|
+
class ProcessDataJob < ApplicationJob
|
|
290
|
+
def perform(file_path)
|
|
291
|
+
data = File.read(file_path) # OR: ActiveStorage download
|
|
292
|
+
process(data)
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# RIGHT: For S3/ActiveStorage files
|
|
297
|
+
class ProcessUploadJob < ApplicationJob
|
|
298
|
+
def perform(blob_id)
|
|
299
|
+
blob = ActiveStorage::Blob.find(blob_id)
|
|
300
|
+
blob.open do |file|
|
|
301
|
+
process(file)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
## Gotcha #8: Testing Sidekiq Jobs
|
|
308
|
+
|
|
309
|
+
```ruby
|
|
310
|
+
# spec/jobs/order_confirmation_job_spec.rb
|
|
311
|
+
RSpec.describe OrderConfirmationJob, type: :job do
|
|
312
|
+
let(:order) { create(:order) }
|
|
313
|
+
|
|
314
|
+
# Test the job logic directly
|
|
315
|
+
it "sends a confirmation email" do
|
|
316
|
+
expect {
|
|
317
|
+
described_class.perform_now(order.id)
|
|
318
|
+
}.to change { ActionMailer::Base.deliveries.count }.by(1)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
it "is idempotent" do
|
|
322
|
+
described_class.perform_now(order.id)
|
|
323
|
+
described_class.perform_now(order.id)
|
|
324
|
+
expect(ActionMailer::Base.deliveries.count).to eq(1) # Sent once, not twice
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
it "handles missing records" do
|
|
328
|
+
expect { described_class.perform_now(999_999) }
|
|
329
|
+
.not_to raise_error # discard_on handles it
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Test that the job is enqueued
|
|
333
|
+
it "is enqueued after order creation" do
|
|
334
|
+
expect { create(:order) }
|
|
335
|
+
.to have_enqueued_job(described_class)
|
|
336
|
+
.with(kind_of(Integer))
|
|
337
|
+
.on_queue("default")
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
## Do's and Don'ts Summary
|
|
343
|
+
|
|
344
|
+
**DO:**
|
|
345
|
+
- Make every job idempotent — check state before acting
|
|
346
|
+
- Pass IDs, not objects or large data
|
|
347
|
+
- Use `discard_on` for errors that won't succeed on retry
|
|
348
|
+
- Set explicit retry counts per job type
|
|
349
|
+
- Use weighted queues to prevent starvation
|
|
350
|
+
- Enqueue jobs after transaction commit
|
|
351
|
+
- Test idempotency explicitly
|
|
352
|
+
|
|
353
|
+
**DON'T:**
|
|
354
|
+
- Don't use class variables or global mutable state in jobs
|
|
355
|
+
- Don't pass large payloads (>100KB) as job arguments
|
|
356
|
+
- Don't enqueue inside transactions without `after_commit`
|
|
357
|
+
- Don't assume jobs run exactly once — they run at least once
|
|
358
|
+
- Don't use the default 25 retries for everything
|
|
359
|
+
- Don't use `perform_now` in production code (defeats the purpose of async)
|
|
360
|
+
- Don't forget to start the Sidekiq process separately (`bundle exec sidekiq`)
|