Skip to content

Teach the generator to read Rails 8 params.expect - #17

Open
VSN2015 wants to merge 1 commit into
masterfrom
feature/generator-expect
Open

Teach the generator to read Rails 8 params.expect#17
VSN2015 wants to merge 1 commit into
masterfrom
feature/generator-expect

Conversation

@VSN2015

@VSN2015 VSN2015 commented Sep 4, 2026

Copy link
Copy Markdown
Owner

The gap

permittable:generate 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 silently 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 aren't columns at all.

docs/comparison.md positions squarely against params.expect, so being unable to read one was a conspicuous hole.

The fix

params.expect(user: [:name, tag_names: [], address: [:city, :zip], line_items: [[:sku, :quantity]]])

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 expect says that permit could not

This is where the draft gets better than a permit-derived one rather than merely equivalent.

1. An array of hashes is unambiguous. permit's key: [:a] could be a nested hash or an array of hashes, which is why that draft has always carried a TODO. expect spells them differently:

Source Draft
address: [:city] optional :address do ... end (+ TODO: may be an array of hashes)
line_items: [[:sku]] array :line_items do ... endno TODO

Scan carries a new nested_arrays member alongside nested.

2. A route param is not a field. In params.expect(:id, user: [:name]) the :id is a routing key, not body input. Drafting it as a contract field would be wrong, so it stays visible in a TODO instead:

scan = Permittable::Generator.scan("params.expect(:id, user: [:name])")
scan.root      # => :user
scan.scalars   # => [:name]
scan.unparsed  # => [":id"]

So does a second envelope (params.expect(user: [...], address: [...])), which belongs under a different root: 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. And tag_names: [] isn't mistaken for an envelope.

Verification

  • 210 examples, 0 failures (12 new, written before the implementation), including a draft that is class_eval'd into a real contract and validates a nested + array-of-hashes request
  • RuboCop clean
  • One bug caught by the specs and worth noting: single-capture String#scan yields a one-element Array rather than auto-splatting the way the two-group PERMIT_CALL does

@VSN2015 VSN2015 left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of PR #17: Support for Rails 8 params.expect in Permittable::Generator modernizes contract drafting for Rails 8+ codebases.

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

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
VSN2015 force-pushed the feature/generator-expect branch from d8612df to 8923b52 Compare September 11, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant