Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
<!-- CHANGELOG.md -->

## Unreleased

### Fixed
- **`permittable:generate` read commented-out code as if it ran.** A controller keeping a `# params.require(:admin).permit(:superuser)` line for reference had `:admin` drafted as the contract's `root:` and `:superuser` drafted as a permitted field — a wrong suggestion, and a security-flavoured one, from a line that does not execute. The same applied to `=begin`/`=end` blocks and to trailing comments on live lines. Comments are now removed before scanning, using `Ripper` (stdlib, no new dependency) rather than a regexp, because `#` is only sometimes a comment: a permit call inside `#{'#{...}'}` interpolation **is** live code and is still read, and string **content** is deliberately kept because `permit("name")` is a supported spelling whose keys live in string tokens. A file `Ripper` cannot lex falls back to the raw source, so a syntactically odd controller scans exactly as it did before rather than not at all.

## 0.6.0 (2026-09-08)
<!-- title: nullable fields, :json, and strict dates -->

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,7 @@ The generator's one rule is **draft, don't guess** — everything it cannot know

- Drafts come out in **monitor mode**, so pasting one changes nothing until you flip it.
- A permitted key that isn't a column becomes `virtual: true` with a TODO; a column type with no scalar equivalent (`json`, `binary`) becomes a TODO comment; a permit argument the conservative parser can't read (`*dynamic_keys`) is kept verbatim in a TODO instead of dropped.
- **Comments are not code.** A commented-out `params.require(:admin).permit(:superuser)` kept for reference is skipped, so it can't contribute a root or a field to the draft. The source is tokenised with `Ripper` for this, because `#` is only sometimes a comment — a permit call inside `#{'#{...}'}` interpolation is live code and is still read, and quoted keys like `permit("name")` still work.
- A database default is noted in a comment but **not** copied into `default:` — a contract default is injected on every request that omits the field, which would overwrite columns on partial updates. The database already handles creation.
- `key: [:a, :b]` in a permit call drafts as a nested block, with a TODO noting it may be an array of hashes.

Expand Down
28 changes: 27 additions & 1 deletion lib/permittable/generator.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require "ripper"

module Permittable
# Drafts a permit_params contract from what the app already knows: the
# model's columns (types, NOT NULL, database defaults) and, when the
Expand Down Expand Up @@ -54,14 +56,38 @@ def found?
ARRAY_ARG = /\A(\w+):\s*\[\s*\]\z/m
NESTED_ARG = /\A(\w+):\s*\[([^\[\]]*)\]\z/m

# Comment tokens. Ripper (stdlib) is used rather than a regexp because `#`
# is only a comment sometimes — it also appears inside string literals and
# `#{}` interpolation, and a permit call inside interpolation IS live code.
# String CONTENT is deliberately kept: `permit("name")` is a supported
# spelling, and its keys live in string tokens.
COMMENT_TOKENS = %i[on_comment on_embdoc on_embdoc_beg on_embdoc_end].freeze

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.

Targeting on_comment, on_embdoc, on_embdoc_beg, and on_embdoc_end handles both standard # comments and =begin/=end documentation blocks.


module_function

# `source` with its comments removed. A controller keeping a commented-out
# `params.require(:admin).permit(:superuser)` for reference had :admin
# drafted as its root and :superuser as a permitted field — a wrong
# suggestion, and a security-flavoured one, from a line that does not run.
#
# Anything Ripper cannot lex falls back to the source unchanged, so a
# syntactically odd file scans exactly as it did before rather than not at
# all.
def executable_source(source)
tokens = Ripper.lex(source)
return source if tokens.nil? || tokens.empty?

tokens.reject { |token| COMMENT_TOKENS.include?(token[1]) }.map { |token| token[2] }.join

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.

Rescuing StandardError and falling back to raw source ensures that files with unusual syntax errors still draft as best as possible rather than failing outright.

rescue StandardError
source
end

# Merge every permit call found in `source` into one Scan. The first
# `.require(:root)` seen wins, matching how a controller normally sticks
# to one envelope across actions.
def scan(source)
result = Scan.new(root: nil, scalars: [], arrays: [], nested: {}, unparsed: [], calls: 0)
(source || "").scan(PERMIT_CALL) do |root, args|
executable_source(source.to_s).scan(PERMIT_CALL) do |root, args|
result.calls += 1
result.root ||= root&.to_sym
split_args(args).each { |arg| classify_arg(result, arg) }
Expand Down
51 changes: 51 additions & 0 deletions spec/generator_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,57 @@ def update
end
end

describe ".scan and things that are not code" do
it "does not read a commented-out permit call" do
scan = described_class.scan(<<~RUBY)
def create
# Legacy, kept for reference:
# params.require(:admin).permit(:superuser, :impersonate_id)
params.require(:user).permit(:name)
end
RUBY
expect(scan.root).to eq(:user)
expect(scan.scalars).to eq(%i[name])
expect(scan.calls).to eq(1)
end

it "does not read a trailing comment on a live line" do
scan = described_class.scan('params.permit(:name) # was params.permit(:admin)')
expect(scan.scalars).to eq(%i[name])
expect(scan.calls).to eq(1)
end

it "does not read a permit call inside an =begin/=end block" do
scan = described_class.scan(<<~RUBY)
=begin
params.require(:old).permit(:legacy)
=end
params.permit(:name)
RUBY
expect(scan.root).to be_nil
expect(scan.scalars).to eq(%i[name])
end

it "keeps a `#` that is part of a string or interpolation, not a comment" do
scan = described_class.scan(<<~RUBY)
LABEL = "tracking #1"
def create = params.require(:user).permit(:name)
RUBY
expect(scan.root).to eq(:user)
expect(scan.scalars).to eq(%i[name])
end

it "still reads quoted permit keys, which live in string tokens" do
scan = described_class.scan(%(params.permit("name", 'email', :age)))
expect(scan.scalars).to eq(%i[name email age])
end

it "falls back to the raw source when the file cannot be lexed" do
scan = described_class.scan("def broken( ; params.permit(:name)")
expect(scan.scalars).to eq(%i[name])
end
end

describe ".draft from a model's columns" do
before do
ActiveRecord::Schema.define do
Expand Down