drive 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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +6 -0
  3. data/CLAUDE.md +690 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +77 -0
  6. data/Rakefile +32 -0
  7. data/STYLE.md +435 -0
  8. data/app/controllers/recourses_controller.rb +96 -0
  9. data/app/javascript/recourse/phone_controller.js +33 -0
  10. data/app/views/layouts/application.html.erb +69 -0
  11. data/app/views/recourses/_breadcrumb.html.erb +18 -0
  12. data/app/views/recourses/_combobox.html.erb +23 -0
  13. data/app/views/recourses/_fields.html.erb +3 -0
  14. data/app/views/recourses/_flash.html.erb +13 -0
  15. data/app/views/recourses/_form.html.erb +9 -0
  16. data/app/views/recourses/_none.html.erb +1 -0
  17. data/app/views/recourses/_row.html.erb +4 -0
  18. data/app/views/recourses/_sidebar.html.erb +10 -0
  19. data/app/views/recourses/_table.html.erb +30 -0
  20. data/app/views/recourses/edit.html.erb +3 -0
  21. data/app/views/recourses/index.html.erb +14 -0
  22. data/app/views/recourses/new.html.erb +3 -0
  23. data/lib/drive.rb +3 -0
  24. data/lib/recourse/controllers.rb +13 -0
  25. data/lib/recourse/engine.rb +27 -0
  26. data/lib/recourse/helpers/cells.rb +44 -0
  27. data/lib/recourse/helpers/comboboxes.rb +33 -0
  28. data/lib/recourse/helpers/constraints.rb +94 -0
  29. data/lib/recourse/helpers/examples.rb +35 -0
  30. data/lib/recourse/helpers/fields.rb +54 -0
  31. data/lib/recourse/helpers/navigation.rb +68 -0
  32. data/lib/recourse/helpers/references.rb +89 -0
  33. data/lib/recourse/helpers.rb +60 -0
  34. data/lib/recourse/icons.rb +21 -0
  35. data/lib/recourse/recoursive.rb +19 -0
  36. data/lib/recourse/routes.rb +14 -0
  37. data/lib/recourse/version.rb +4 -0
  38. data/lib/recourse.rb +31 -0
  39. data/vendor/recourse/bootstrap-icons.min.css +5 -0
  40. data/vendor/recourse/bootstrap.bundle.min.js +9 -0
  41. data/vendor/recourse/bootstrap.min.css +2 -0
  42. data/vendor/recourse/fonts/bootstrap-icons.woff +0 -0
  43. data/vendor/recourse/fonts/bootstrap-icons.woff2 +0 -0
  44. data/vendor/recourse/stimulus.js +2563 -0
  45. metadata +144 -0
data/CLAUDE.md ADDED
@@ -0,0 +1,690 @@
1
+ # Coding Guidelines
2
+
3
+ **Scope:** This file is the authority for code style in this project. The
4
+ baseline is standard community Ruby/Rails practice, plus the learned preferences
5
+ recorded at the bottom.
6
+
7
+ ## Ruby
8
+
9
+ - Two-space indentation, no tabs. No trailing whitespace; newline at EOF.
10
+ - `snake_case` for methods/variables, `CamelCase` for classes/modules,
11
+ `SCREAMING_SNAKE_CASE` for constants.
12
+ - Predicate methods end in `?`; mutating/dangerous variants end in `!`.
13
+ - `do...end` for multi-line blocks, `{...}` for single-line blocks.
14
+ - Guard clauses over nested conditionals. Return early.
15
+ - Use `unless` for simple negatives; never `unless ... else`.
16
+ - Prefer `&.`, `||=`, `Array()`, `Hash#fetch` with defaults, and keyword
17
+ arguments over positional args once there are more than two.
18
+ - Keep methods short and single-purpose. Extract private methods freely.
19
+ - Comment *why*, not *what*. Don't narrate the code.
20
+
21
+ ## Rails
22
+
23
+ - **Follow the Rails Way.** Reach for framework features before writing
24
+ custom infrastructure.
25
+ - Fat model / skinny controller. Controllers do routing, authorization,
26
+ params, and rendering — nothing else.
27
+ - RESTful routes and the seven standard actions. Prefer nested resources or
28
+ new controllers over custom actions.
29
+ - Use strong parameters; never pass raw `params` to a model.
30
+ - Scopes for reusable queries; avoid query logic in controllers or views.
31
+ - Guard against N+1 with `includes` / `preload` / `eager_load`.
32
+ - Validations and DB constraints together — the database is the last line of
33
+ defense (null constraints, unique indexes, foreign keys).
34
+ - Migrations are reversible; never edit a migration that has shipped.
35
+ - Concerns for genuinely shared behavior, not as a dumping ground.
36
+ - Background jobs (Active Job) for anything slow or external.
37
+ - Secrets in credentials/ENV — never committed.
38
+ - Views: partials and helpers over logic in ERB. No queries in views.
39
+ - I18n for user-facing strings.
40
+
41
+ ## Testing
42
+
43
+ - Never test another library. `validates :name, presence: true` is Rails'
44
+ behavior, already tested in Rails, so a test asserting a blank name is
45
+ invalid tests nothing of ours. Same for a unique index raising, or pagy
46
+ splitting 25 rows across two pages.
47
+ - Test our data, our wiring, our own methods: what a backfill contains, what a
48
+ helper returns, what markup a view produces, how many queries a page costs.
49
+ - Every behavior change comes with a test.
50
+ - Test behavior and public interfaces, not private implementation details.
51
+ - Descriptive test names that state the expected outcome.
52
+ - Prefer fixtures/factories that are minimal and explicit.
53
+ - Keep tests independent and order-agnostic.
54
+
55
+ ## Git
56
+
57
+ - Small, focused commits. Imperative mood subject lines ("Add", not "Added").
58
+ - Explain *why* in the body when the change isn't self-evident.
59
+
60
+ ---
61
+
62
+ ## Learned preferences
63
+
64
+ Guidance from Claudio, recorded as it comes up. These override the baseline
65
+ above when they conflict.
66
+
67
+ Grouped under what each rule is *for*: performance, security, testing,
68
+ maintainability, readability, internationalization. A new rule goes under the
69
+ heading it serves, not at the end of the file, and a rule that could sit under
70
+ two is filed under the one a reader would look in first.
71
+
72
+ ### PERFORMANCE
73
+
74
+ #### PostgreSQL, always
75
+
76
+ - When an app needs a database, it is PostgreSQL. Never MySQL, never SQLite —
77
+ including for test-only apps like `test/dummy`, and including cases where
78
+ SQLite would be less setup.
79
+ - Running the test suite therefore needs a PostgreSQL server. `test/test_helper`
80
+ creates the database on first run, so `rake test` is still the only command
81
+ needed once the server is up.
82
+
83
+ #### Cache a query or a fragment that repeats
84
+
85
+ - Reach for `Rails.cache` wherever the same rows would be read or the same markup
86
+ re-rendered. An index table and the option list behind a combobox are both
87
+ cached; `/locations/new` costs six queries cold and three warm, and the
88
+ 40,965-row `SELECT` behind its ZIP menu is one of the three that go.
89
+ - Key a fragment on the *relation*, `cache recourses do`, never on a hand-rolled
90
+ string. Rails builds the key from a digest of the SQL — so a different page of
91
+ the same table is a different key — and pairs it with a version from the row
92
+ count and the newest `updated_at`, so nothing has to remember to expire it.
93
+ - That version check is itself a `SELECT COUNT(*), MAX(updated_at)`, and it is the
94
+ price of never serving a stale menu. Keying on `recourses.cache_key` instead
95
+ skips it and queries nothing at all, at the cost of a list that never notices a
96
+ new row — only worth it behind an `expires_in`, and only for data that may lag.
97
+ - The check is free where the relation is already loaded, which is why the index
98
+ costs nothing extra: `blank?` loads it before the table renders, so the version
99
+ is counted in Ruby. Another reason to prefer `blank?` over `empty?`.
100
+ - Cache the table, not the pagination around it. The nav changes with the page and
101
+ is cheap to draw.
102
+ - A cache that is off in tests is a cache nobody tests. The dummy app sets
103
+ `config.cache_store = :memory_store` for the test environment: caching stays on,
104
+ and the run leaves no files behind.
105
+
106
+ #### Fewest SQL queries to render a page
107
+
108
+ - Rendering a page should issue as few queries as it can. Treat an extra query
109
+ as a defect, not a detail.
110
+ - When checking whether a relation has records *and then looping over them*, use
111
+ `present?` and `blank?`, never `any?` and `empty?`. `blank?` runs the
112
+ `SELECT "contacts".*` that the loop needs anyway and caches it; `empty?` runs a
113
+ separate `SELECT 1 ... LIMIT 1` first, so the page costs two queries instead of
114
+ one.
115
+ - `any?` and `empty?` are still right when nothing will be looped over.
116
+ - An index eager-loads every `belongs_to` its table can name, since each cell that
117
+ shows a referenced record would otherwise be a query of its own:
118
+ `resource_class.includes(*names)`. Twenty locations cost five queries rather than
119
+ forty-two, and the count no longer grows with the page.
120
+ - Worth a test: assert the query count, so a later edit cannot quietly add one
121
+ back. `test_it_costs_one_count_and_one_select` does this by subscribing to
122
+ `sql.active_record`, and it is one of the two tests exempt from "as few tests
123
+ as coverage needs".
124
+
125
+ #### Select only the columns a query displays
126
+
127
+ - A query built to display something fetches those columns and no others.
128
+ The combobox of states shows a name per row and submits an id, so it reads
129
+ `State.select(:id, :name).order(:name)` — not `State.order(:name)`.
130
+ - This sits alongside "fewest SQL queries to render a page": the count of
131
+ queries is one cost and the width of each is another.
132
+ - It applies where the columns are known. A generic table hands the record to a
133
+ row partial that may touch anything, so it selects everything on purpose.
134
+
135
+ #### Emails are citext
136
+
137
+ - A plaintext email column is `citext`, never `string`. An address is
138
+ case-insensitive in practice, so `Ada@example.com` and `ada@example.com` are
139
+ the same one, and the column type is what makes comparison and a unique index
140
+ agree with that.
141
+ - citext arrives with its extension, so a migration runs
142
+ `enable_extension 'citext'` before the first citext column is created.
143
+ - Nothing else is then needed: no `LOWER(email)` expression index, and no
144
+ downcasing on the way in.
145
+ - An *encrypted* email column is not citext — the same reasoning as the rest of
146
+ "Encrypt PII". What is stored is ciphertext, so a case-insensitive column
147
+ compares the wrong bytes and could reject two genuinely different addresses.
148
+ Normalize in Rails instead, always with both options:
149
+ `encrypts :email, deterministic: true, downcase: true`.
150
+ - `deterministic: true` is not conditional on the column being unique or
151
+ queried *today*. An address is the natural handle for finding a record, so it
152
+ will be looked up eventually, and switching afterwards means re-encrypting
153
+ every row. `downcase: true` is what earns the case-insensitivity the citext
154
+ column would have given, and it is what keeps a unique index honest: without
155
+ it two spellings of one address encrypt to two different values.
156
+
157
+ ### SECURITY
158
+
159
+ #### Encrypt PII
160
+
161
+ - Personal data is stored with Active Record Encryption: `encrypts :phone`,
162
+ `:email`, `:surname`, `:street`. Suspect a column is personal? Ask before
163
+ storing it in the clear.
164
+ - `name` is not PII and is not encrypted. A first name on its own does not
165
+ identify anyone; a surname does. Asked and settled — do not encrypt it.
166
+ - A column that is queried or must stay unique needs
167
+ `encrypts :phone, deterministic: true`. Non-deterministic ciphertext differs
168
+ every write, which silently defeats both a unique index and a uniqueness
169
+ validation — they will pass while duplicates pile up.
170
+ - Never constrain the *shape* of an encrypted value in the database. What is
171
+ stored is ciphertext, so only `null: false` and a unique index still mean
172
+ anything; the format belongs to the model.
173
+ - Encrypted columns never appear in a generic table, so encrypting a column
174
+ removes it from the index page. That is intended — see `STYLE.md`.
175
+
176
+ #### Phone numbers
177
+
178
+ - A phone number is stored in a column named `phone`, holding exactly 10
179
+ digits.
180
+ - The database enforces `null: false` and a unique index. It does not check the
181
+ ten-digit shape — a phone is PII, so it is encrypted, and no constraint can
182
+ read ciphertext.
183
+ - Rails is where the shape is enforced: the model includes a `Phonable` concern
184
+ carrying both rules:
185
+
186
+ NORTH_AMERICAN_PHONES = /\A[2-9]\d{2}[2-9]\d{6}\z/
187
+
188
+ normalizes :phone, with: ->(phone) { phone.delete('^0-9').delete_prefix '1' }
189
+
190
+ with_options format: { with: NORTH_AMERICAN_PHONES, message: '...' } do
191
+ validates :phone, allow_nil: true
192
+ end
193
+
194
+ - The concern's validation is `allow_nil`, so whether a phone is *required* is
195
+ the including model's decision — add `presence: true` there, not in the
196
+ concern.
197
+
198
+ ### TESTING
199
+
200
+ #### Coverage stays at 100%
201
+
202
+ - `simplecov` starts at the very top of `test/test_helper.rb`, before anything
203
+ else is required, with `minimum_coverage 100`. Below that the suite fails.
204
+ - `skip '/test/'` leaves the dummy app out: it is a fixture, not shipped code.
205
+ Never `add_filter` — SimpleCov deprecated it in favour of `skip`, same
206
+ arguments and same behaviour, and it warns on every run until changed.
207
+ - `lib/recourse/version.rb` is not measured, and that is expected rather than a
208
+ gap. The Gemfile's `gemspec` directive loads it during bundler setup, before
209
+ SimpleCov can start. Do not add `track_files` to pull it in — it would report
210
+ as uncovered when in fact it runs.
211
+
212
+ #### As few tests as coverage needs
213
+
214
+ - The suite exists to cover the code, so a test that can be deleted while
215
+ coverage stays at 100% is a test to delete. Write the smallest set that gets
216
+ there and stop.
217
+ - Never add a test for lines the suite already covers, even to reach a *branch*
218
+ it misses. Line coverage is the whole budget.
219
+ - Never test a migration. A backfill's row count, the values it wrote and the
220
+ invariants between them are data, and asserting them exercises no code of
221
+ ours. `TestState` was five such tests, so the whole class went, and
222
+ `TestCounty` and `TestZIP` with it.
223
+ - A model that only declares validations, associations and encryption is in the
224
+ same position: nothing measured runs, so it gets no test file. That took
225
+ `TestPhonable` and `TestEmails` too.
226
+ - This narrows the baseline's "every behavior change comes with a test": the
227
+ test comes with it only if it reaches a line nothing else does. The suite it
228
+ leaves is small on purpose — six tests for 146 lines.
229
+ - Two kinds of assertion are exempt, because a covered line cannot stand in for
230
+ them. How many queries a page costs, and whether an encrypted column reaches
231
+ the page: both run exactly the same lines whether they hold or not, so
232
+ coverage stays green while the behavior breaks. The PII leak that prompted the
233
+ second exemption printed an address at 100%.
234
+ - Nothing else is exempt. Markup, titles, breadcrumbs, icons, pagination links
235
+ and the order of the sidebar are all asserted by whichever test happens to
236
+ render the page, and no test is added to pin them down further.
237
+
238
+ ### MAINTAINABILITY
239
+
240
+ #### No metaprogramming
241
+
242
+ - Never call `send` or `public_send`. Reach the data directly instead:
243
+ `resource.attributes[column]`, not `resource.public_send column`.
244
+ - No `define_method`, `method_missing`, `instance_variable_get` / `_set`,
245
+ `const_set`, `constantize`, or `eval` of any kind.
246
+ - The single exception is an explicit instruction to use it. Never reach for
247
+ metaprogramming on your own initiative, and never treat the places that
248
+ already use it as permission to add another — ask instead.
249
+
250
+ #### Trailing comma on a multiline hash
251
+
252
+ - A multiline hash ends its last entry with a comma, so adding an entry touches
253
+ one line instead of two:
254
+
255
+ NAVIGATION_ICONS = {
256
+ 'States' => 'geo', 'ZIPs' => 'geo-alt-fill',
257
+ }.freeze
258
+
259
+ - Put the closing brace on its own line. With the brace trailing the last entry
260
+ the comma reads as `, }`, which is worse than either alternative.
261
+ - Enforced by `Style/TrailingCommaInHashLiteral` with
262
+ `EnforcedStyleForMultiline: consistent_comma`. Not `comma` — that style
263
+ *forbids* the comma unless every entry sits on its own line, and ours share
264
+ lines.
265
+
266
+ #### Keep render lines out of the logs
267
+
268
+ - An app's log never carries Action View's `Rendering ...` and `Rendered ...`
269
+ lines. One line per partial buries the request that matters — a 20-row table
270
+ rendering a row partial produces 20 of them.
271
+ - Set `config.action_view.logger = nil` in `config/application.rb`. Everything
272
+ else stays: the request, the SQL, and the `Completed 200 OK` timing line,
273
+ which still reports view time.
274
+ - This is a rule for apps we write. The gem never touches a host's logging.
275
+
276
+ #### Keep RuboCop current
277
+
278
+ - `AllCops: NewCops: enable`. Cops added by a new RuboCop release are active
279
+ immediately rather than sitting pending; fix what they surface instead of
280
+ pinning the version.
281
+ - `Gemspec/RequireMFA` is one of those, so the gemspec sets
282
+ `metadata['rubygems_mfa_required']` and publishing needs MFA on the RubyGems
283
+ account.
284
+ - `AllCops: SuggestExtensions: false`. Every run was ending with a nine-line
285
+ advert for `rubocop-minitest` and `rubocop-rake`; we are declining both, not
286
+ postponing them, so the suggestion is off rather than merely ignored.
287
+
288
+ #### Gemfile ordering and version constraints
289
+
290
+ - List gems alphabetically, in one block — no blank lines splitting the list,
291
+ since those read as separate groups.
292
+ - Never use `~>`. Use `>=` only where a minimum version is genuinely required,
293
+ and otherwise give no constraint at all.
294
+ - Every gem carries a trailing comment on the same line saying why it is
295
+ there — what would break without it, not what the gem is.
296
+ - The same applies to `add_dependency` in the gemspec.
297
+
298
+ #### No code of conduct, no ideology
299
+
300
+ - Never add a `CODE_OF_CONDUCT.md`, and never link to or mention one from the
301
+ README, gemspec, or any other file. Generators that create one (`bundle
302
+ gem`) have their output deleted.
303
+ - Keep the codebase free of content about ethics, religion, or politics —
304
+ including comments, docs, error messages, test fixtures, and sample data.
305
+ - `LICENSE.txt` is not covered by this: a license is a legal notice.
306
+
307
+ #### Target Rails 8.1+
308
+
309
+ - All Rails libraries are required at `>= 8.1`. Write against current Rails
310
+ APIs only.
311
+ - Never add version checks, shims, or fallbacks for older Rails or Ruby.
312
+
313
+ #### No static typing
314
+
315
+ - Never write Ruby type signatures, and never add a strong-typing tool.
316
+ - No RBS: no `sig/` directory, no `.rbs` files. `bundle gem` creates one —
317
+ delete it.
318
+ - No Sorbet: no `# typed:` sigils, no `sig { ... }` blocks, no `T.let` /
319
+ `T.nilable` / `T.must`, no `srb` or `tapioca`.
320
+ - Never add these gems: `sorbet`, `sorbet-runtime`, `rbs`, `steep`, `tapioca`.
321
+ - Convey intent through clear names, short methods, and tests instead.
322
+
323
+ #### Branch and commit per prompt
324
+
325
+ - Before starting a code change, if the current branch is `main`, create a
326
+ branch first. Short name, lowercase words, underscores only — no dashes,
327
+ no slashes, no ticket prefixes (`git_conventions`, `dummy_app`).
328
+ - If already on a branch other than `main`, keep working on it.
329
+ - After completing the code change a prompt asked for, commit it. The subject
330
+ is a short summary of the prompt; the body is the full response given for
331
+ that prompt.
332
+ - One prompt, one commit.
333
+
334
+ #### Ask the validators, not the schema
335
+
336
+ - What a value is allowed to be is the model's business, so read it from the
337
+ validators. A length validator gives a field its `maxlength` and `minlength`, a
338
+ format validator its `pattern`, a numericality validator its numeric keyboard.
339
+ Never reach into `columns_hash` for a `limit` or for a type.
340
+ - The schema and the validators disagree more often than it looks. A `limit: 5`
341
+ column with no length validator accepts four characters; an encrypted column's
342
+ limit describes ciphertext. Following the model keeps the browser saying what
343
+ the server will actually enforce.
344
+ - Where no validator can answer — which of `date`, `time` and `datetime` an
345
+ attribute is — ask the model anyway, through `type_for_attribute`. It reports
346
+ what the model declares, so an `attribute :opens_on, :date` override counts,
347
+ and `columns_hash` still never appears.
348
+ - Corollary for the database: a constraint the model does not also state is a
349
+ constraint the browser cannot show. Add the validator too.
350
+
351
+ #### Match Bootstrap with field_error_proc
352
+
353
+ - Wherever Bootstrap is the CSS framework, set
354
+ `config.action_view.field_error_proc`. Rails' default wraps a rejected field in
355
+ `<div class='field_with_errors'>`, which Bootstrap styles not at all: no red
356
+ border, and the message nowhere on the page.
357
+ - The proc adds `is-invalid` to the control and follows it with a
358
+ `<small class='invalid-feedback'>`, which is the pair Bootstrap needs — its
359
+ `.is-invalid ~ .invalid-feedback` reveals one only next to the other.
360
+ - Guard on the control's class, not on the tag's type. A label carries
361
+ `form-label` and falls straight through, and so does anything else without a
362
+ `form-control`. Guarding on `instance.is_a? ActionView::Helpers::Tags::Label`
363
+ instead leaves `html_tag.index 'form-control'` returning nil for every other
364
+ kind of tag, and `insert nil` raises.
365
+ - The proc is `instance_exec`'d on the view, so `tag` and `safe_join` are in
366
+ scope — no need to write markup as a string.
367
+ - Which is just as well, because `insert` on a SafeBuffer escapes what it is
368
+ given: an attribute spliced in by hand arrives as `&#39;`. Build it with
369
+ `tag.attributes` and join it with `safe_join`.
370
+ - A rule for apps we write. The gem never sets a host's Action View config, the
371
+ same line drawn for the logger and the time zone — so a control the gem draws
372
+ outside a form builder, like the combobox, carries this markup itself.
373
+
374
+ #### Every model says how it is labelled
375
+
376
+ - A model answers `recourse_label` with the column a combobox shows for one of its
377
+ records. `Recourse::Recoursive` supplies `:name`, and every Active Record model
378
+ is extended with it through `ActiveSupport.on_load :active_record`, so most
379
+ models need say nothing at all.
380
+ - A model whose identity is not a `name` overrides it — `:code` for a ZIP, `:email`
381
+ for an Agent — but never in the model body. It `include`s its own `Recoursive`
382
+ concern, in `app/models/zip/recoursive.rb`, which overrides inside
383
+ `class_methods do`. The default arrives by `extend`, so only a class method can
384
+ replace it.
385
+ - The label is what gets selected: `select(:id, label).order(label)`, per "select
386
+ only the columns a query displays". So it has to be a real column, not a method —
387
+ a method would not survive the `SELECT`.
388
+ - Reading it back is `recourse.attributes[label]`, not `public_send`, which "no
389
+ metaprogramming" rules out.
390
+ - Picking an encrypted column labels the option with its plaintext, since
391
+ `attributes` decrypts. That is a decision to make deliberately, not to fall into,
392
+ and it reaches further than a form: a foreign-key column in a *table* shows the
393
+ same label, so an encrypted one appears on the index of every model that
394
+ references it. `resource_columns` only keeps a model's own encrypted columns out.
395
+ - `recourse_typed_label?` asks whether that label has a length validator, which is
396
+ what decides between typing a value and picking from a list. A length is the only
397
+ honest signal available: it says the value is bounded, so a person can type it.
398
+ - A typed label is looked up on the way in — `ZIP.find_by code: '90210'` — and the
399
+ form asks for it under the foreign key's own name, so no host model needs a
400
+ virtual attribute and strong parameters need no special case.
401
+
402
+ #### Vendor what a page cannot render without
403
+
404
+ - A stylesheet or script a page cannot do without is vendored into the gem and
405
+ served from it, never linked to a CDN. A host that fails to reach the CDN gets
406
+ an unstyled page, and the Bootstrap 6 CSS in particular comes from a preview
407
+ host with no promise of staying put.
408
+ - The files live in `vendor/recourse/`, and an engine initializer serves them with
409
+ `Rack::Static`. That is the framework's own middleware rather than a controller
410
+ action, and it assumes no asset pipeline, which a host may well not have.
411
+ - Keep the slash on the prefix. `urls: %w[/recourse/]` matches on `start_with?`,
412
+ so `urls: %w[/recourse]` would answer `/recourses` with a 404 from the file
413
+ server before the router ever saw it — in this gem of all places.
414
+ - Vendor whatever the vendored file itself asks for. `bootstrap-icons.min.css`
415
+ loads `fonts/bootstrap-icons.woff2` relative to itself, so the CSS without the
416
+ fonts renders every icon as a blank box.
417
+ - Keep the copies byte-identical to what the CDN serves, so a later version can
418
+ be diffed against upstream. `git ls-files` puts them in the gem already.
419
+ - Our own JavaScript is not vendored. It lives in `app/javascript/recourse/` and
420
+ is served at the same prefix: the first `Rack::Static` takes `cascade: true`, so
421
+ a path it has no file for falls through to the second rather than 404ing. That
422
+ keeps `vendor/` meaning "upstream's", which is what exempts it from the lint.
423
+ - A Stimulus controller imports Stimulus by its served path, not by the bare
424
+ `@hotwired/stimulus` specifier. Resolving that name would need an import map,
425
+ and a host app may already ship one of its own.
426
+ - Start the application in the `<head>`, and guard it with `window.Stimulus`.
427
+ Turbo re-runs body scripts on every visit, and a second application connects
428
+ every controller a second time.
429
+
430
+ #### Seed data lives in migrations, so schema.rb cannot load a database
431
+
432
+ - `config.active_record.dump_schema_after_migration = false`, so no `schema.rb`
433
+ is ever written. Gitignoring it is not enough: it regenerates on every migrate
434
+ and then Rails loads it into the next empty database, stamping every version at
435
+ or below its own as already migrated — silently skipping the backfills and
436
+ leaving the tables empty for the next foreign key to trip over.
437
+ - Build a database with `db:migrate` from zero. Never `db:schema:load`, and be
438
+ wary of `db:prepare` for the same reason.
439
+ - `db:drop` will not drop a database with open connections and reports success
440
+ anyway; `dropdb --force` is the reliable reset.
441
+
442
+ #### Design lives in STYLE.md
443
+
444
+ - Every decision about how a page looks — Bootstrap conventions, class choices,
445
+ markup structure — is documented in `STYLE.md`, not here. Read that file
446
+ before writing or editing any layout, view or partial.
447
+ - This file stays the authority for code style. Where the two overlap, `STYLE.md`
448
+ wins on markup and `CLAUDE.md` wins on Ruby.
449
+
450
+ ### READABILITY
451
+
452
+ #### Files at most 100 lines
453
+
454
+ - No code file goes over 100 lines, counting blank and comment lines. When one
455
+ gets close, split it — extract a class, a concern, a partial, a second test
456
+ case.
457
+ - Enforced by `rake file_length`, part of the default task. RuboCop has no
458
+ file-length cop; `Metrics/ClassLength` and friends measure a class body, not a
459
+ file, and skip comments and blanks by default.
460
+ - `.md`, `.txt`, `.html` and `.erb` are exempt. Prose is not code, and a view
461
+ is markup whose length is driven by the page, not by design choices.
462
+ - Anything under `db/migrate/` is exempt. A migration that backfills a table is
463
+ as long as the data it carries, and splitting one to satisfy a line count
464
+ would be worse than leaving it long.
465
+ - So is anything under `vendor/`. Upstream's formatting is not ours to fix, and a
466
+ vendored font is not even text — `File.readlines` on a `.woff2` reports
467
+ thousands of lines that mean nothing.
468
+ - The task reads `git ls-files`, so an untracked file is invisible to it. A green
469
+ run before `git add` proves nothing about what the commit will contain.
470
+
471
+ #### Lines at most 100 characters
472
+
473
+ - Hard limit of 100 characters per line, enforced by RuboCop's
474
+ `Layout/LineLength` (`Max: 100`, up from its default of 120).
475
+ - Split long strings across lines with `\` continuations rather than letting a
476
+ line run over.
477
+ - Views are exempt — `.html` and `.erb` files may run past 100 characters,
478
+ since a CDN URL or a long class list cannot be wrapped usefully. RuboCop does
479
+ not lint them anyway.
480
+ - When a method call would need three lines and hanging indentation just to fit,
481
+ hoist the long arguments into a Rails `with_options` block instead. Still
482
+ three lines, but every line starts at a normal indent:
483
+
484
+ with_options format: { with: SOME_PATTERN, message: 'is invalid' } do
485
+ validates :phone, allow_nil: true
486
+ end
487
+
488
+ #### Shared behavior becomes a concern
489
+
490
+ - When two models declare the same behavior word for word, extract it into a
491
+ concern instead of leaving the copy in place.
492
+ `encrypts :email, deterministic: true, downcase: true` stood in both `Contact`
493
+ and `Agent`, so it became `Emailable`.
494
+ - Name the concern after the feature it carries, not after the models that want
495
+ it: `Emailable`, `Phonable`.
496
+ - Only what the models genuinely share moves. `Contact`'s email is optional and
497
+ `Agent`'s is required, so `presence: true` stays in each model — the same line
498
+ `Phonable` already draws for `phone`.
499
+ - This sharpens the baseline's "concerns for genuinely shared behavior": a second
500
+ identical declaration is the threshold, and anticipating one is not.
501
+
502
+ #### As few parentheses as possible
503
+
504
+ - Omit parentheses on a method call's arguments; keep the inner ones, where
505
+ parsing needs them:
506
+
507
+ Object.const_set class_name, Class.new(RecoursesController)
508
+
509
+ - Enforced by `Style/MethodCallWithArgsParentheses` with
510
+ `EnforcedStyle: omit_parentheses`. The cop is off by default, so it needs
511
+ `Enabled: true` as well as the style.
512
+
513
+ #### List concerns alphabetically, on one line
514
+
515
+ - Concerns are included in alphabetical order: `include Emailable, Phonable`,
516
+ never the other way round.
517
+ - One `include` carries the whole list. Give each its own statement only when
518
+ the single line would not fit, and then keep the order.
519
+ - Enforced by `Style/MixinGrouping` with `EnforcedStyle: grouped`. Its default
520
+ is `separated`, which demands the opposite, so the setting is not optional.
521
+ - `include A, B` inserts them in reverse, so `A` ends up ahead of `B` in
522
+ `ancestors`. It only matters when both define the same method, which two
523
+ concerns that were extracted for being distinct features should not.
524
+
525
+ #### Pass locals to partials explicitly
526
+
527
+ - A partial never reads a controller's instance variables. Declare strict
528
+ locals on its first line — `<%# locals: (resources:, pagy:) %>` — and pass
529
+ them at the call site: `render 'table', resources: @resources, pagy: @pagy`.
530
+ - A partial that takes no locals gets no comment at all. Never write
531
+ `<%# locals: () %>`.
532
+ - A template rendered by an action may read instance variables. The rule is
533
+ about partials, which should not depend on who rendered them.
534
+ - Rails enforces this: omit a declared local and the render raises instead of
535
+ quietly rendering a blank.
536
+ - Where two branches need different locals, write `if`/`else` rather than
537
+ `render cond ? 'a' : 'b'` — a single call cannot pass the right locals to
538
+ both.
539
+ - The row partial is the deliberate exception, and it breaks the rule twice.
540
+ Its record arrives under a name computed at runtime (`contact:`, `state:`),
541
+ so the gem's own `_row` cannot declare strict locals and reads
542
+ `local_assigns[resource_key]`. And whether it is drawing the header row or a
543
+ body row travels in `@recourse_headers`, set by `_table` before each render,
544
+ which `column` reads. Both were asked for; neither is a pattern to copy.
545
+ - The fields partial is the second exception, for the same two reasons. It is
546
+ handed the record under the runtime name so a host's `_fields` can declare
547
+ `<%# locals: (contact:) -%>`, while the gem's own cannot; and the form builder
548
+ travels in `@recourse_form`, set by `_form`, because `field :phone` is the
549
+ call site the host writes and threading a form through it would spoil that.
550
+
551
+ #### Spell acronyms as acronyms
552
+
553
+ - An acronym is written in capitals wherever it appears: ZIP code, not Zip code;
554
+ PIN, not Pin. That covers prose, comments, class names and labels alike.
555
+ - When a model or column names one, register it so Rails agrees:
556
+ `inflect.acronym 'ZIP'` in `config/initializers/inflections.rb`. Without it
557
+ `human_attribute_name` renders `Zip` and every heading and label is wrong.
558
+ - Registering it also fixes `camelize`, so `zip_code` becomes `ZIPCode` rather
559
+ than `ZipCode` — worth knowing before naming a class after one.
560
+
561
+ #### Name non-trivial regular expressions
562
+
563
+ - A regular expression that is not obvious at a glance gets a named constant,
564
+ so the name explains the intent and the pattern is stated once.
565
+ `/\A[2-9]\d{2}[2-9]\d{6}\z/` becomes `NORTH_AMERICAN_PHONES`.
566
+ - Put the constant on the class or module that owns the rule, and comment it
567
+ with what it accepts and rejects — the name says what, the comment says why
568
+ those bounds.
569
+ - Trivial patterns used once, like `%r{\Aexe/}`, stay inline.
570
+
571
+ #### Comment every public declaration
572
+
573
+ - Never comment a private method. The rule below is for the public surface; a
574
+ private method earns its explanation from its name and its caller.
575
+ - Never put a method in a controller that only a view calls, and never reach for
576
+ `helper_method` to expose one. If a view needs it, it belongs in a helper
577
+ module or inline in the template. A controller's private methods are for the
578
+ controller's own work.
579
+ - Indent `private` to match its `class` or `module`, not the `def`s under it, so
580
+ it stands out as a divider. Enforced by
581
+ `Layout/AccessModifierIndentation: EnforcedStyle: outdent`.
582
+ - Precede every public class, module, constant and method declaration with a
583
+ comment line saying what that object does. This narrows the baseline's
584
+ "comment why, not what" rule: declarations get a *what*, and the *why* rule
585
+ still governs comments inside method bodies.
586
+ - Say what it is for, not what the code plainly shows. `# Raised for every
587
+ failure the gem reports` earns its place; `# The Error class` does not.
588
+ - A module reopened purely as a namespace in another file is not redeclared —
589
+ document it where it is defined.
590
+ - Enforced by RuboCop: `Style/Documentation` for classes and modules,
591
+ `Style/DocumentationMethod` (off by default) for methods. Both skip `test/`,
592
+ matching RuboCop's own default, so test cases stay uncommented — their names
593
+ state the expectation.
594
+ - Constants have no cop; keep them commented by hand.
595
+ - Keep these comments to a single line whenever possible. If one line cannot
596
+ carry it, cut the aside rather than the rule — the detail belongs in the
597
+ commit message. Multi-line is a last resort, not a default.
598
+
599
+ #### Never freeze strings
600
+
601
+ - Never write `# frozen_string_literal: true`. No file gets a magic comment,
602
+ including generated ones — strip it from generator output.
603
+ - Never call `.freeze` on a string, constant or not. Array and hash constants
604
+ are still worth freezing by hand.
605
+ - Where a constant only names something, prefer a symbol over a string — it is
606
+ immutable already, so the question does not arise.
607
+ - Enforced by RuboCop: `Style/FrozenStringLiteralComment` is `never`, and
608
+ `Style/MutableConstant` is disabled because it demands `.freeze` on string
609
+ constants and cannot be told to skip them.
610
+
611
+ #### Single quotes by default
612
+
613
+ - Always use single-quoted strings.
614
+ - Double quotes only when the string genuinely needs them: interpolation
615
+ (`"#{name}"`) or escape sequences (`"\n"`, `"\x0"`).
616
+ - Enforced by RuboCop: `Style/StringLiterals` and
617
+ `Style/StringLiteralsInInterpolation` are both set to `single_quotes`.
618
+ - This covers views too, `.html` and `.html.erb` included, and applies to HTML
619
+ attributes and CSS values as much as to Ruby: `<th scope='col'>`, not
620
+ `<th scope="col">`. RuboCop does not lint views, so this half is on us.
621
+ - A Ruby string containing single quotes then *needs* double quotes, which is
622
+ why assertions on this markup read `"<table class='table table-hover'>"`.
623
+
624
+ ### INTERNATIONALIZATION
625
+
626
+ #### Eastern time
627
+
628
+ - `config.time_zone = 'Eastern Time (US & Canada)'`. That is what `Time.zone`
629
+ means, what a form reads, and what a timestamp renders as.
630
+ - Storage stays UTC. Never touch `config.active_record.default_timezone` — the
631
+ database keeps UTC and Rails converts on the way in and out, so the app zone
632
+ is a display concern only.
633
+ - A rule for apps we write. The gem never sets a host's time zone.
634
+
635
+ #### I18n is deferred
636
+
637
+ - User-facing strings stay plain English for now. This suspends the baseline's
638
+ "I18n for user-facing strings" rule until there are enough strings to be worth
639
+ a locale file — do not add one unprompted.
640
+
641
+ #### The State model
642
+
643
+ - A `State` model always represents the United States, and always has exactly
644
+ these three attributes, each non-null and unique: `code` (two capital
645
+ letters, `'CA'`), `fips` (two digits, `'06'`), `name` (`'California'`).
646
+ - It always ships with a migration that creates the table *and* backfills it
647
+ from the official list, so an app never starts with an empty states table.
648
+ Source of truth: https://www2.census.gov/geo/docs/reference/state.txt
649
+ - 51 rows: the 50 states plus the District of Columbia. Territories are not
650
+ states, so `PR`, `GU`, `VI`, `AS`, `MP` and `UM` are left out.
651
+ - `fips` is a string, never an integer — `'06'` must keep its leading zero.
652
+ - Enforce all of it in the database too: unique indexes, `null: false`,
653
+ `limit: 2`, and check constraints for the two-letter and two-digit shapes.
654
+
655
+ #### The County model
656
+
657
+ - A `County` has a unique non-null 5-digit `fips` string, a non-null `name`, and
658
+ belongs to a `state`. `name` is deliberately *not* unique — more than twenty
659
+ states have a Washington County.
660
+ - Creating a counties table always comes with a migration that backfills all
661
+ 3,143 counties of the 50 states plus DC, each joined to the right `states` row.
662
+ Source: https://www2.census.gov/geo/docs/reference/codes2020/national_county2020.txt
663
+ - The first two digits of a county's `fips` are its state's `fips`. Check that
664
+ after backfilling, not just the row count — by hand, since a migration does
665
+ not get a test.
666
+ - Territories are left out, matching the states table.
667
+ - The 3,143 rows live in `db/counties.txt`, not inside the migration. Migrations
668
+ are exempt from the file-length rule, so this is a decision rather than a
669
+ workaround: leave the data in the file and do not inline it.
670
+ - The database enforces it too: unique index on `fips`, `null: false`, a
671
+ five-digit check constraint, and a real foreign key to `states`.
672
+
673
+ #### The ZIP model
674
+
675
+ - A `ZIP` has a unique non-null 5-digit `code`, a non-null `city`, a non-null
676
+ `time_zone`, belongs to a `county`, and optionally belongs to a `market`.
677
+ - The class is `ZIP`, not `Zip`, because the acronym is registered. Register the
678
+ plural too — `inflect.acronym 'ZIPs'` — or every heading reads `Zips`.
679
+ - Registering the plural renames more than headings: Rails camelizes a migration
680
+ filename to find its class, so `create_zips.rb` must define `CreateZIPs`. It
681
+ only breaks on a migrate from zero, which is why a reset is the real test.
682
+ - Creating a zips table always comes with a migration that backfills it: every
683
+ ZIP, matched to the county it mostly belongs to and the main city in it.
684
+ - `time_zone` holds a Rails zone name, never an IANA identifier. The source mixes
685
+ both, so the backfill normalizes on the way in, matching each identifier by its
686
+ offset and DST rules: Detroit and the Kentucky zones to Eastern, Indiana's
687
+ Eastern zones to `Indiana (East)`, Knox and Tell_City to Central, Boise to
688
+ Mountain, Anchorage and Nome to Alaska, Honolulu to Hawaii. Nothing should
689
+ survive that `ActiveSupport::TimeZone::MAPPING` does not name — worth checking
690
+ by hand after a backfill, since a migration does not get a test.