CLAUDE.md ยท diff
git:20261012.f5e6030 to git:20261012.d1a7de7
1 added, 4 removed. Audit A to A.
# Project conventions for Claude
This file captures project-specific conventions Claude should follow when working in this repo.
## Repository tooling
- `bin/validate-patterns` validates every detection pattern YAML file under `rails-upgrade/detection-scripts/patterns/`. Run it before committing any change to a pattern file. Pure-stdlib Ruby, no Bundler or Gemfile required.
- `bin/validate-patterns` validates every file
- `bin/validate-patterns path/to/file.yml` validates one or more specific files
- `bin/validate-patterns --self-test` runs built-in fixture assertions covering the four positive `kind:` values and the rejection paths (missing top-level key, missing required pattern key, broken regex, unknown `kind:` value, a control character from a single-backslash YAML escape, catastrophic backtracking). CI runs this alongside the file-validation step
- Checks: YAML parses, required top-level keys present, the eight required pattern keys present on each entry, every `pattern` / `exclude` regex compiles, contains no control characters (in a double-quoted YAML string `"\b"` is a backspace; the word boundary is `"\\b"`), and finishes within 0.5s on long stress inputs (Ruby 3.2+, which has Regexp timeouts), and the `kind:` value is one of the allowed enum values (`breaking`, `deprecation`, `migration`, `optional`)
- Exits 0 on success, 1 on any failure with a per-file error report. Every error line ends with a `hint:` saying how to fix it
- `bin/test-patterns` runs fixture tests for a pattern file against its sibling `*.expectations.yml` (e.g. `rails-40-patterns.yml` + `rails-40-patterns.expectations.yml`). It confirms a pattern's regex actually matches what its explanation claims and skips what it shouldn't โ `validate-patterns` only confirms the regex compiles, not that it's correct. Pure-stdlib Ruby, same shape as `bin/validate-patterns`.
- `bin/test-patterns` tests every pattern file that has an expectations sibling; files without one are reported `SKIP`, not a failure
- `bin/test-patterns path/to/file.yml` tests one or more specific files
- `bin/test-patterns --self-test` runs built-in fixture assertions. CI runs this alongside the fixture-test step
- Expectations are keyed by `variable_name` and list `match` (lines the pattern MUST flag) and `no_match` (lines it MUST NOT flag) โ see the worked example in `rails-40-patterns.expectations.yml`
- Exits 0 on success, 1 on any failure with a per-pattern error report. Each failure carries a `hint:`: for a missed match it says whether the pattern or the `exclude:` dropped the line
- `rails-upgrade/detection-scripts/scan_patterns.rb` is the scanner the skill runs inside the user's app in Workflow 05. It runs every pattern of one hop and prints a markdown findings table (or `--format json`). It ships inside the skill, so it runs with the app's Ruby: keep it stdlib only and Ruby 2.1 compatible (no `&.`, no `<<~`, no `String#match?`, no `Array#sum`, no `Dir.children`).
- `ruby rails-upgrade/detection-scripts/scan_patterns.rb --root path/to/app` scans the hop after the app's `Gemfile.lock` Rails version; `--target X.Y` picks the hop
- `ruby rails-upgrade/detection-scripts/scan_patterns.rb --self-test` runs built-in assertions and loads every patterns file. CI runs it
- It matches file content, not single lines, so a pattern that crosses newlines (`[^)]*`, `\s*`) can report more sites than `bin/test-patterns` fixtures suggest. That is intended
- Each finding carries `guide_entry` (file, heading, line range), derived from the guide's `**Pattern:**` markers, and `--explain VAR` prints that entry's text. Both are empty for a guide without markers
- Prove Ruby 2.1 compatibility by running it, not by reading it: `docker run --rm -v "$PWD":/w -w /w ruby:2.1 ruby rails-upgrade/detection-scripts/scan_patterns.rb --self-test`. The image's locale is US-ASCII, as on many legacy app boxes, so read any UTF-8 file (the guides have emoji) with an explicit `:encoding => "UTF-8"`
- - `rails-upgrade/detection-scripts/gem_limits.rb` lists the locked gems whose own gemspec excludes the target Rails, from the lockfile alone. Same constraints as `scan_patterns.rb`: it runs with the app's Ruby, so stdlib only and Ruby 2.1 compatible. Both load `rails-upgrade/detection-scripts/hop.rb` and call `Hop.resolve` for the hop (locked version, next hop from the guides, `--target` checks, the hop source line), so they always name the same hop; change it there, not in either script. `Hop.self_test_failures` holds the shared assertions both self-tests run.
- - `ruby rails-upgrade/detection-scripts/gem_limits.rb --root path/to/app` checks the hop after the lockfile's Rails version; `--target X.Y` picks the hop, `--lockfile Gemfile.next.lock` reads the next boot
- - `ruby rails-upgrade/detection-scripts/gem_limits.rb --self-test` runs built-in assertions against a fixture lockfile. CI runs it
- - Prove Ruby 2.1 compatibility the same way: `docker run --rm -v "$PWD":/w -w /w ruby:2.1 ruby rails-upgrade/detection-scripts/gem_limits.rb --self-test`
+ - `rails-upgrade/detection-scripts/gem_limits.rb` lists the locked gems whose gemspec excludes the target Rails, from the lockfile alone. Same constraints and Ruby 2.1 check as `scan_patterns.rb`; `--self-test` runs in CI. Both scripts take the hop from `Hop.resolve` in `rails-upgrade/detection-scripts/hop.rb`: change the hop logic there.
## Workflows and references (`rails-upgrade/workflows/`, `rails-upgrade/references/`)
The skill body follows the progressive-disclosure practice from the skill-creator skill and the [Agent Skills specification](https://agentskills.io/specification): `SKILL.md` is the short entry point, procedures live in `workflows/`, on-demand material lives in `references/`. Two kinds of file, named so a search hit shows which kind it is:
- **Workflow**: one numbered unit of the upgrade flow, one file, `workflows/NN-name-workflow.md`. The number is the position in the flow, zero-padded so the directory listing sorts. `SKILL.md`'s workflow index lists every one in order.
- **Step**: a `Step N` header inside a workflow file. Flat integers only, no `4.5`, no `2a`: nested numbering was hard to track and to reference. A sub-step is either two sequential steps or a conditional branch (`### A.` / `### B.`).
- **Reference**: anything loaded on demand (lookup tables, playbooks, conditional branches), `references/name-reference.md`. Every reference is listed in `SKILL.md` under Reference Materials with a "load when" hint.
Every workflow file opens with the same header, in this order: numbered title, `**Purpose:**`, `**When to use:**`, `## Inputs` (artifacts handed forward by earlier workflows, nouns), `## Outputs` (artifacts this workflow hands forward, nouns), `## Gates` (boolean stop conditions before the next workflow that runs, predicates), then an optional `## Pre-upgrade checklist` (unchecked FastRuby.io recommendations, not gates), then its Steps, then `## Self-review checklist` where one exists. Inputs and Gates never repeat the same fact. Provenance of a rule belongs in `CHANGELOG.md`, not in the workflow file.
Refer to a whole workflow as "Workflow NN". Refer to a step as the file path plus the step (`workflows/01-run-test-suite-workflow.md` Step 4), never as a bare "Step N of the upgrade workflow". `bin/lint-skill` check 1 validates paths, so a renumber breaks the old path and lint goes red. Bare numbers are invisible to it.
Inserting a workflow mid-flow renumbers every later file and its inbound links. That is accepted churn; the path check catches every stale link in markdown. Paths inside pattern YAML `explanation:` / `fix:` strings are not scanned by lint, so grep for them by hand after a rename.
`bin/lint-skill` skips any line containing the word `dual-boot` (a cross-plugin exception): in every markdown file for the path check, and in SKILL.md for the index check. A stale path on such a line is invisible to lint. Keep that word out of workflow file names and index rows, and do not put a file path on a line that also mentions `dual-boot`.
Files that carry a type suffix: `-workflow.md`, `-reference.md`, `-template.md`, `-patterns.yml`. `examples/` and `version-guides/` do not (yet).
## Version guides (`rails-upgrade/version-guides/*.md`)
- **Do NOT include "Difficulty" or "Estimated Time" in the header.** These are subjective, application-dependent, and drift out of date. Keep the header minimal: title, Ruby requirement, and the attribution line.
- Base content on primary sources: the official Rails upgrade guide, the FastRuby.io blog, the OmbuLabs ebook chapter, and RailsDiff for the matching versions.
- Organize breaking changes under ๐ด HIGH / ๐ก MEDIUM / ๐ข LOW priority sections.
- Each breaking change entry should include: "What Changed", a detection pattern, and a BEFORE/AFTER fix.
- Use `NextRails.next?` (never `respond_to?` or `Gem::Version` comparisons) in dual-boot code examples.
- Every guide ends its breaking-changes material with a `## Common Issues โ Quick Reference` table: one row per symptom the agent may see after the bump (error string or observable behavior), pointing at the quoted entry heading plus a few-word hint. No fix bodies in the table: the entry holds the fix, the table only indexes it by symptom. A symptom with no entry gets an entry, not a table-only fix.
- **Name the patterns behind each entry.** The first line under a breaking-change `#### ` heading is a marker: ``**Pattern:** `SERIALIZE_SYNTAX` `` (several names are comma-separated), or `**Pattern:** none (why)` when no regex can find the change (a behavior change, a test-adapter setting). The `variable_name` is the durable link between an entry and its pattern: headings can be reworded, names are referenced from code. Do not store the reverse link (entry heading or anchor) in the pattern YAML; tools derive it from the markers. `bin/lint-skill` check 9 checks each guide that has at least one marker. Errors: a name that is not in the hop's `rails-XY-patterns.yml`, or a name on two entries. Warnings, for now: an entry under `## Breaking Changes` with no marker, and a pattern of the hop that no entry names. The warnings are the backlog of missing entries; the fix is an entry, even a short one (every guide gets a "Ruby Version Requirement" entry), and they become errors once the backlog is empty. Guides without markers are not checked, so they can be migrated one at a time.
- **Do NOT number the breaking-change entries.** Headings are `#### Title`, never `#### 3. Title`. The priority section already orders them, and a number means renumbering every later entry on each insertion, which conflicts across parallel PRs.
- Cross-reference an entry by its heading text in quotes, not by a number: `"Fixture Dates Must Be Cast to Strings"`, never `Section 16`. This holds for the quick-reference tables, for prose in other guides, and for `explanation:` / `fix:` strings in the pattern YAML. `bin/lint-skill` check 8 enforces both rules: the numbered-heading half for markdown under `version-guides/`, the by-number cross-reference half for every markdown file in the skill, since a pointer into a guide can be written from a workflow or a reference too. Lines inside fenced code blocks, `Section N:` headings (the upgrade-report workflow numbers the report's own sections that way) and `CHANGELOG.md` are exempt. Pattern YAML is not scanned, so grep it by hand after a retitle.
## Detection patterns (`rails-upgrade/detection-scripts/patterns/rails-*-patterns.yml`)
- File naming: `rails-{VERSION}-patterns.yml` where `{VERSION}` is the major+minor without a dot (e.g., `rails-42-patterns.yml` for Rails 4.2).
- Organize patterns under `high_priority`, `medium_priority`, and `low_priority`.
- Each pattern needs: `name`, `kind` (one of `breaking` / `deprecation` / `migration` / `optional` โ see "Assigning kind" below), `pattern` (regex), `exclude` (regex, empty string if none), `search_paths`, `explanation`, `fix`, `variable_name`. Place `kind:` immediately after `name:` for visual scannability.
- Include a `dependencies` section for any bridge/compatibility gems mentioned in the guide.
- **Required before committing any change to a detection pattern file:** run `bin/validate-patterns` (or `bin/validate-patterns path/to/file.yml` for the file you touched). Do not commit a pattern change without a clean run; broken YAML or schema drift in this directory breaks the skill at runtime. See the `## Repository tooling` section above for what the script checks.
- **New pattern? Add a fixture expectation.** Every new `variable_name` needs a `match`/`no_match` entry in the sibling `*.expectations.yml` file (create the file if the version doesn't have one yet), then run `bin/test-patterns path/to/file.yml` clean before committing. Exception: a pattern whose `pattern` is `""` (pure path-based detection, e.g. `VENDOR_PLUGINS`) can never flag a line and should stay uncovered โ note why in a comment instead of forcing a fixture.
## Assigning priority (๐ด HIGH / ๐ก MEDIUM / ๐ข LOW)
Priority applies to both version guide sections and detection pattern entries. Assign based on **blast radius** and **reversibility**, not on how much code typically needs to change.
### ๐ด HIGH
A change belongs in HIGH when at least one of these is true:
- **Prevents boot, bundle, or test-suite startup** (e.g., a gem removed from Rails core that the app still requires; a renamed config key that raises on load).
- **Causes runtime errors in typical code paths** (e.g., a removed method that most apps call โ `update_attributes`, `deliver`, `find_all_by_*`).
- **Required to upgrade at all** (Ruby version bump, mandatory base-class change like `ApplicationRecord`, removed DSL options that raise).
- **Silently wrong behavior with production impact** โ data loss, security regression, broken auth/CSRF, or cache key mismatches that invalidate stored data.
If the user cannot complete the upgrade without addressing it, it is HIGH.
### ๐ก MEDIUM
A change belongs in MEDIUM when:
- **Affects many apps but not all** (gem extractions like `responders`, test-helper changes like `assigns`/`assert_template`).
- **Behavioral change in a commonly-used API** that usually works fine but has known edge-case breakage (HTML sanitizer output, serialized attribute nil handling, per-request CSRF tokens).
- **Config rename or relocation** that doesn't raise but should be updated for forward compatibility.
If the upgrade completes without it but a noticeable class of apps will see problems, it is MEDIUM.
### ๐ข LOW
A change belongs in LOW when:
- **Opt-in or optional improvement** (Timecop โ `travel_to`, Foreigner โ native FKs, adopting new Gemfile defaults).
- **Environment-specific config tweak** (`rails server` bind host, dev-only settings).
- **Cosmetic/tooling changes** (schema.rb column ordering, .gitignore recommendations, new `bin/setup` script).
If most apps will ignore it without consequence, it is LOW.
### How to decide when unsure
1. **Would a typical Rails app's test suite fail to run after the version bump without this fix?** โ HIGH.
2. **Would the app boot and tests run, but produce a future blocker for a noticeable class of apps?** โ MEDIUM.
3. **Would the app be unaffected unless the user opts into a new feature or runs in a specific environment?** โ LOW.
Priority is about **urgency during an upgrade**, not editorial weight.
## Assigning `kind:` (`breaking` / `deprecation` / `migration` / `optional`)
`kind:` describes **what the change is**; `priority` describes **how urgent it is**. The two are orthogonal. A HIGH `deprecation` (silently wrong, like `DIRTY_TRACKING_AFTER_SAVE`) and a HIGH `breaking` (won't boot) are both "fix first" but for different reasons.
**Judge `kind` at the target hop, not the API's historical timeline.** Each `rails-XY-patterns.yml` file is a statement *about that hop* โ what changes when the user upgrades INTO that version. A removal that was first deprecated in an earlier Rails minor is `breaking` in the file for the version where it actually raises, not `deprecation` because of its history. The same API can legitimately be `deprecation` in `rails-31-patterns.yml` and `breaking` in `rails-40-patterns.yml`. Apply the rule to all four kinds: `kind` reflects what the change *is at this hop*, not what it *was* earlier or *will become* later.
Concrete example: `SCOPE_WITHOUT_LAMBDA` (`scope :active, where(...)`) only emits a deprecation warning on Rails 4.0 and raises on 4.1 when the scope is called, so it is `deprecation` in `rails-40-patterns.yml` and `breaking` in `rails-41-patterns.yml`.
The four values:
- **`breaking`** โ Raises, removed, or prevents the app from booting / bundling / running its test suite. The user cannot complete the upgrade without addressing it. Example: `update_attributes` removed in 6.1, `redirect_to :back` removed in 5.1.
- **`deprecation`** โ Works at this hop but emits a deprecation warning. Removal is scheduled for a later Rails version. Example: dynamic `:controller` route segments in 5.2, string `if:` conditions on callbacks.
- **`migration`** โ Works today, no warning, but a recommended migration target. Adopting it now avoids rework on the next hop or an entirely new approach. Example: `Rails.application.secrets` โ `credentials.yml.enc` in 5.2.
- **`optional`** โ Opt-in feature or improvement. The user can ignore it without consequence. Example: `bootsnap`, `webpacker` in 5.1, `propshaft` adoption ahead of 8.0.
How to decide:
1. **Will the upgrade fail (boot, bundle, tests) without this fix?** โ `breaking`.
2. **Does Rails emit a deprecation warning when this code runs at the target version?** โ `deprecation`.
3. **Is this a recommended path forward (e.g. `secrets.yml` โ `credentials.yml.enc`) that does not yet warn?** โ `migration`.
4. **Is this purely opt-in / cosmetic / a new feature?** โ `optional`.
If `kind` and `priority` seem to conflict, trust both. They answer different questions.
## How the `dependencies:` section relates to `kind`
The top-level `dependencies:` block in each `rails-*-patterns.yml` file is not bound to a single `kind` value. It serves two distinct purposes:
1. **Bridge / compatibility gems for `breaking` patterns** โ gems that rescue functionality removed from Rails core, so the user can keep shipping while migrating call sites. A `breaking` pattern with a corresponding bridge entry is a *softenable* break: install the gem to keep the upgrade landing while migration happens separately. Examples:
- `protected_attributes` rescues `attr_accessible` / `attr_protected` (4.0)
- `activerecord-deprecated_finders` rescues removed dynamic finders (4.0)
- `rails-observers` rescues `ActiveRecord::Observer` and `ActionController::Caching::Sweeper` (4.0)
- `responders` rescues `respond_with` and class-level `respond_to` (4.2)
- `rails-controller-testing` rescues `assigns` / `assert_template` (5.0)
2. **New gems Rails introduces or recommends at this version** โ gems that are not in the previous version's Gemfile. These pair with `optional` patterns (the user can ignore them) or with no pattern at all. Examples:
- `bootsnap` (5.2), `web-console` (4.2), `webpacker` (5.1)
- `propshaft` (8.0), `solid_cache` / `solid_queue` / `solid_cable` (8.0)
- `kamal` (8.0), `bundler-audit` (8.1)
The `check: true` / `check: false` flag on each `dependencies:` entry is **editorial advice about whether the gem applies broadly**, not a per-app determination. The actual applicability depends on whether the user's app triggers the `breaking` pattern the bridge rescues:
- `check: true` โ the gem rescues a `breaking` that most apps will trigger (e.g., `responders` rescues `respond_with`, which most controller-heavy apps use). Default to recommending it.
- `check: false` โ either a bridge for a `breaking` that not all apps will trigger (e.g., `actionpack-action_caching` only matters if the app uses `caches_page` / `caches_action`), or a new-default gem the user can adopt at their own pace (e.g., `bootsnap`, `solid_cache`). The user's actual code drives whether they need it.
A `check: false` bridge gem still becomes effectively required for any specific app that triggers its rescued `breaking` pattern. Treat the flag as a starting recommendation; the per-app `fix:` field on each pattern entry is what tells the user whether they actually hit it.
`kind: deprecation`, `migration`, and `optional` patterns are resolved in code via the per-pattern `fix:` field, not via `dependencies:`.
`bin/validate-patterns` does not enforce the rules in this section โ they are editorial guidance for authors and reviewers. The validator only checks schema (required keys, regex compilation, `kind:` enum membership), not the semantic relationship between `dependencies:` entries and `kind:` values.