Teach the generator to read Rails 8 params.expect - #17
Open
VSN2015 wants to merge 1 commit into
Open
Conversation
VSN2015
commented
Sep 5, 2026
| ARRAY_ARG = /\A(\w+):\s*\[\s*\]\z/m | ||
| NESTED_ARG = /\A(\w+):\s*\[([^\[\]]*)\]\z/m | ||
| # expect-only: `comments: [[:body, :author]]` is an array of hashes. | ||
| NESTED_ARRAY_ARG = /\A(\w+):\s*\[\s*\[([^\[\]]*)\]\s*\]\z/m |
Owner
Author
There was a problem hiding this comment.
NESTED_ARRAY_ARG pattern cleanly disambiguates Rails 8's comments: [[:body, :author]] syntax from regular nested hashes, allowing accurate generation of array :comments do ... end.
|
|
||
| match = EXPECT_ENVELOPE.match(root) | ||
| result.root ||= match[1].to_sym | ||
| split_args(match[2]).each { |inner| classify_arg(result, inner) } |
Owner
Author
There was a problem hiding this comment.
Keeping route arguments (like :id alongside user: [...]) in result.unparsed with clear # TODO comments ensures developers are aware of route params without generating invalid body field definitions.
The generator is the adoption on-ramp, and it was blind to the syntax Rails 8 apps actually use. A modern controller has no params.permit calls to scan, so the draft fell back to columns alone and lost everything the app already knew about its own params: the root, the permitted key list, and which keys are not columns at all. params.expect calls are now scanned into the same Scan and merged with any permit calls in the same controller. The parser stays as conservative as the permit one — brackets and newlines are fine, a parenthesis means a method call in the arguments and the whole call is skipped rather than half-read. Two things expect says that permit could not, so the draft is now better than a permit-derived one rather than merely equivalent: * `key: [[:a]]` is unambiguously an array of hashes, where permit's `key: [:a]` could be either. That draft now emits `array :key do` with no "this may be an array of hashes" TODO, and Scan carries a nested_arrays member alongside nested. * A route param next to the envelope — params.expect(:id, user: [...]) — is a routing key, not body input, so it stays visible in a TODO instead of being drafted as a field. So does a second envelope, which belongs under a different root: than one rooted contract can express. A rootless params.expect(:q, :page) still drafts as scalars, since there is no envelope for it to be a sibling of. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
VSN2015
force-pushed
the
feature/generator-expect
branch
from
September 11, 2026 22:01
d8612df to
8923b52
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The gap
permittable:generateis the adoption on-ramp — and it was blind to the syntax Rails 8 apps actually use. A modern controller has noparams.permitcalls to scan, so the draft silently fell back to columns alone and lost everything the app already knew about its own params: theroot:, the permitted key list, and which keys aren't columns at all.docs/comparison.mdpositions squarely againstparams.expect, so being unable to read one was a conspicuous hole.The fix
is now scanned into the same
Scan, merged with any permit calls in the same controller. The parser stays exactly as conservative as the permit one — brackets and newlines are fine, a parenthesis means a method call in the arguments and the whole call is skipped rather than half-read.Two things
expectsays thatpermitcould notThis is where the draft gets better than a permit-derived one rather than merely equivalent.
1. An array of hashes is unambiguous.
permit'skey: [:a]could be a nested hash or an array of hashes, which is why that draft has always carried a TODO.expectspells them differently:address: [:city]optional :address do ... end(+ TODO: may be an array of hashes)line_items: [[:sku]]array :line_items do ... end— no TODOScancarries a newnested_arraysmember alongsidenested.2. A route param is not a field. In
params.expect(:id, user: [:name])the:idis a routing key, not body input. Drafting it as a contract field would be wrong, so it stays visible in a TODO instead:So does a second envelope (
params.expect(user: [...], address: [...])), which belongs under a differentroot:than one rooted contract can express — the recipe there is a rootless contract with a nested block per key, which the generator can't decide for you.A rootless
params.expect(:q, :page)still drafts as scalars, since there's no envelope for those to be a sibling of. Andtag_names: []isn't mistaken for an envelope.Verification
class_eval'd into a real contract and validates a nested + array-of-hashes requestString#scanyields a one-element Array rather than auto-splatting the way the two-groupPERMIT_CALLdoes