Skip to content

Name a class written from the top level as Ruby does, with --experimental-parser - #82

Merged
dduugg merged 5 commits into
mainfrom
experimental-top-level-names
Oct 13, 2026
Merged

dduugg merged 5 commits into
mainfrom
experimental-top-level-names

Conversation

@dduugg

@dduugg dduugg commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

With --experimental-parser, pks named a class or module written from the top level as if it nested in the namespaces around it. class ::Taco was defined as ::::Taco, class ::Burrito inside module Outer as ::Outer::::Burrito, and a constant assigned in one, as ::::Taco::FILLING. The resolver built its guesses the same way, so a reference to one of these was found only from inside another class ::Taco written the same way, and then under the :::: name. The rest were missed. So was Shell written inside class ::Taco, looked up as ::::Taco::Shell and then ::Shell, when another pack defines ::Taco::Shell as class Taco::Shell.

  • nest_constant_name starts the name again from the top level at a namespace that begins with ::, as Ruby does.
  • The experimental parser names its definitions with it, and the experimental resolver makes its guesses with it. Inside module Outer; class ::Foo, Bar is now looked up as ::Foo::Bar, then ::Outer::Bar, then ::Bar, the order of Ruby's Module.nesting.
  • A reference that was found under a :::: name now has the plain one, as ::Taco::FILLING. A violation that a package_todo.yml records under the :::: name is reported again under the new name, and the old entry as stale, until pks update is run. The CHANGELOG says so.
  • Without --experimental-parser, nothing changes. packwerk names class ::Taco ::::Taco among a file's local definitions too, and its constant_resolver looks Shell up as ::Taco::Shell against names that have no leading ::, so it misses the same references, or takes Shell for a top-level ::Shell. The packwerk parser and the Zeitwerk resolver keep that lookup, and the CHANGELOG says so.
  • A class reopened this way, as class ::String inside module CoreExt, is now ::String, so with --experimental-parser, expose-monkey-patches lists it, and a reference to String from another pack is a reference to the pack that reopens it, as for a class String at the top level of a file. The CHANGELOG says this too.

The cache key includes pks's version, so a release doesn't serve the old names from the cache.

Test plan

  • cargo test (378 passed, and 379 on a merge with main, which adds one), cargo fmt --check, and cargo clippy --all-targets --all-features -- -Dwarnings.
  • definitions_from_the_top_level and resolve_in_a_namespace_from_the_top_level cover the parser's names and the resolver's lookup, and test_list_references_to_a_class_defined_from_the_top_level lists a reference to class ::Taco inside module Outer in another pack. All three fail when nest_constant_name joins the old way.
  • definitions_from_the_top_level also has a module ::Foo and nestings like module A; class ::B; module C, and test_list_references_inside_a_class_defined_from_the_top_level looks Shell up inside module Outer; class ::Taco. They fail when visit_module_node names a module the old way, when only the resolver joins the old way, and when nest_constant_name starts again at the first :: namespace in place of the last. No test caught those before.
  • On a copy of simple_app with class ::Taco and module Outer; class ::Burrito, -e list-definitions gives ::Taco, ::Taco::FILLING and ::Burrito, and -e list-references now lists ::Taco, ::Taco::FILLING, ::Burrito, and ::Taco::Shell from inside class ::Taco. The default parser's output is unchanged.
  • With class ::Taco; FILLING = 1; end in packs/bar and FILLING written inside a class ::Taco in packs/foo, the binary before this change lists ::::Taco::FILLING, and this one ::Taco::FILLING. After pks -e update with the old binary, pks -e check with this one reports the two violations under the new name and the old todo entry as stale.

…ntal-parser

The experimental parser joined a class or module's name to the
namespaces around it with `::`, so `class ::Taco` was defined as
`::::Taco`, and `class ::Burrito` inside `module Outer` as
`::Outer::::Burrito`. The constants assigned in them got the same
prefix, as `::::Taco::FILLING`. No reference resolves to a name with
`::::`, so references to these constants were missed. The experimental
resolver built its guesses the same way, so `Shell` written inside
`class ::Taco` was looked up as `::::Taco::Shell` and then `::Shell`,
and a `::Taco::Shell` that another pack defines was missed too.

`nest_constant_name` starts the name again from the top level at a
namespace that begins with `::`, as Ruby does. The experimental parser
names its definitions with it, and the experimental resolver makes its
guesses with it, so inside `module Outer; class ::Foo`, `Bar` is
`::Foo::Bar`, then `::Outer::Bar`, then `::Bar`, the order of Ruby's
`Module.nesting`.

The packwerk parser and the Zeitwerk resolver keep
`combine_namespace_with_constant_name` and `get_definition_from`, which
match packwerk. packwerk names `class ::Taco` `::::Taco` among a file's
local definitions too, and its constant_resolver looks `Shell` up as
`::Taco::Shell` against names that have no leading `::`, so it misses
the same references. The CHANGELOG says so.

The cache key includes pks's version, so a release doesn't serve the
old names from the cache.
@dduugg
dduugg requested a review from a team as a code owner October 13, 2026 22:05
The test cases and `nest_constant_name` already say what they did.
The first commit says that no reference resolves to a name with `::::`.
One written inside another `class ::Taco`, as the definition is, did.
The resolver built its guess the same way, so `FILLING` there resolved
to `::::Taco::FILLING`. The rest were missed. That reference is now
named `::Taco::FILLING`, so a violation that a package_todo.yml records
under the `::::` name is reported again under the new name, and the old
entry as stale, until `pks update` is run. The CHANGELOG now says so.
No test caught the experimental parser naming `module ::Foo` the old
way, or `nest_constant_name` starting again at the first namespace
written from the top level in place of the last, and the integration
test didn't reach the resolver. `definitions_from_the_top_level` now
has a `module ::Foo`, and nestings with a namespace from the top level
in the middle, as `module A; class ::B; module C`. A new integration
test looks up `Shell` inside `module Outer; class ::Taco`, and finds
`::Taco::Shell` in another pack.

`nest_constant_name` builds the name in one String with a loop, in
place of a fold that made a String for each part. Its doc comment says
that a constant assigned from the top level starts again too, and the
resolver's comment on its lookup order covers a namespace from the top
level.
With `class ::String` inside `module CoreExt` now named `::String`,
`expose-monkey-patches` lists it, and a reference to `String` from
another pack is a reference to the pack that reopens it, as it is for a
`class String` at the top level of a file. The CHANGELOG says so, and
that `::BAR = 1` inside `module Outer` was `::Outer::::BAR`.

Without `--experimental-parser`, a constant written inside `class ::Foo`
isn't only missed: packwerk, and pks with it, can take `Bar` there for
a top-level `::Bar`. The CHANGELOG says that in place of "misses".
@dduugg
dduugg merged commit 7810fb4 into main Oct 13, 2026
21 checks passed
@dduugg
dduugg deleted the experimental-top-level-names branch October 13, 2026 22:47
@github-project-automation github-project-automation Bot moved this from Triage to Done in Modularity Oct 13, 2026
dduugg added a commit that referenced this pull request Oct 14, 2026
bc0ad33 said that only a definition written from the top level, at the
top level of a file, has a name that starts with `::::`, so nothing else
changes. That isn't so. The constants defined in such a class, as
`::::Taco::Shell` in `class ::Taco; class Shell`, start with it too, as
does the `::::Taco` pks takes from `class ::Taco::Shell`, and a `Taco`
written inside `class ::Taco` was dropped the same way. pks now counts
each of these as a reference, as packwerk 3.3.1 does, and the CHANGELOG
says so. Tests cover a `Taco` inside `class ::Taco`, `::Taco = 1` at the
top level of a file, and `class ::Burrito` in `module Outer` and
`class ::Cee` in `module Aa; module Bb`, from the file to the references
pks lists.

e9a6092 said that for a reference written `Object::Bar`, the resolver
looks `Object` up as Ruby does. Inside `module CoreExt` it tries
`::CoreExt::Object::Bar` first, as Ruby would, but where `CoreExt`
defines an `Object` with no `Bar`, Ruby raises a NameError, and pks goes
on to `::Bar`, as it does for `Foo::Bar` when `CoreExt::Foo` has no
`Bar`. New tests name `module Object::Bar`, the constants in
`class Object::Bar`, `::Object::Object::BAR` and `ObjectSpace::Bar`, and
find constants defined through `Object` from another pack.

Comments now say that only the experimental parser and resolver drop a
leading `::Object`, and that the empty names come off the end as in
constant_resolver.
dduugg added a commit that referenced this pull request Oct 14, 2026
…ough `Object`, and match packwerk on `::` names and `class_eval` (#83)

* Take no parent for `Outer::`, as packwerk does

packwerk names `class ::Burrito` inside `module Outer`
`::Outer::::Burrito`, and so does pks. When no file defines a constant,
both look for it in the file of its parent, which for
`Outer::::Burrito` is `Outer::`. packwerk takes the parent with Ruby's
`String#split`, which drops the empty name at the end, so `Outer::` has
no parent, and packwerk finds no constant. Rust's `split` keeps it, so
pks went on to `Outer`, and reported `::Outer::::Burrito` against the
pack that defines `Outer`. In `module Aa; module Bb; class ::Cee`,
packwerk goes from `Aa::Bb::` to `Aa`, where pks stopped at `Aa::Bb`.
The Zeitwerk resolver now drops the empty names at the end too.

* Count a constant after `class ::Taco` as packwerk does

packwerk names `class ::Taco` at the top level of a file `::::Taco`
among the file's own definitions, so a `Taco` or `::Taco` written later
in the file, which it looks for as `::Taco`, isn't one of them, and is
a reference. pks looked for `::` and the name, `::::Taco`, as well, so
it took them for the file's own and dropped them. The packwerk parser
now looks for the name alone. Only a definition written from the top
level, at the top level of a file, has a name that starts with `::::`,
so nothing else changes.

* Look up an association from its model's name, with --experimental-parser

Rails looks up the class of `has_many :tacos` from the name of the
model that declares it: in `Outer::Foo`, as `Outer::Foo::Taco`,
`Outer::Taco`, then `Taco`. The experimental parser gave the reference
the namespaces it's written in, so in `class Outer::Foo` pks looked for
`::Outer::Foo::Taco`, then `::Taco`, and in `module Outer; class ::Foo`
for `::Foo::Taco`, `::Outer::Taco`, then `::Taco`. The reference now has
the namespaces of the model's name, `["Outer", "Foo"]` and `["Foo"]`,
so the resolver looks where Rails does. Without `--experimental-parser`,
pks looks it up as packwerk does, as before.

* Name a constant written through `Object` as Ruby does, with --experimental-parser

The constants of `Object` are the top level's, so `class Object::Bar`
inside `module CoreExt` defines `::Bar`, as does `class Bar` inside a
`class Object` at the top level of a file. The experimental parser
named them `::CoreExt::Object::Bar` and `::Object::Bar`.
`nest_constant_name` now drops a leading `::Object` from a name, and
the experimental parser takes a class, module or constant defined as
`Object::Bar` for `::Bar`. So both are named `::Bar`, and the
experimental resolver finds `::Bar` for a reference written
`::Object::Bar`.

For a reference written `Object::Bar`, the resolver looks `Object` up
as Ruby does, so inside `module CoreExt` it tries
`::CoreExt::Object::Bar` before `::Bar`. The parser can't look it up
for a definition, so it takes it that no namespace defines an `Object`
of its own. A `class Object` inside a namespace is still the
namespace's own, as Ruby makes it.

* Say what the follow-ups to #82 change in the CHANGELOG

The association lookup and the names written through `Object` change
what `--experimental-parser` finds. Without it, pks now matches packwerk
around a name written from the top level, so `pks check` may report a
violation it didn't, or no longer report one, and leave a todo stale.

* Try `::Taco` first for `has_many :tacos` in a model named `Taco`

ActiveRecord's `_klass` (reflection.rb) looks up the class of
`belongs_to :company` in a model whose name ends in `Company` as
`::Company` first, and from the model's name only if that isn't defined.
a81a247 gave the reference the namespaces of the model's name, so with
`--experimental-parser`, in `class Payroll::Company`, pks looked for
`::Payroll::Company::Company`, then `::Payroll::Company`, and found the
model itself, where it had found `::Company`. So it missed a violation
it used to report. In `module Payroll; class Company`, it found the
model itself before a81a247 too.

When the association's class has the model's own last name, the
reference is now to `::Company`, so pks finds what Rails does in both.
Where no `::Company` is defined, Rails goes on to find
`Payroll::Company::Company` or the model itself, and pks finds nothing,
so it misses a reference only where a `Payroll::Company::Company` is
defined.

A test also covers a model in `module Outer; class Foo` and in
`class Object::Foo`. In a module, as a concern, pks can't know the model
that includes it, and takes the module's name, and `model_namespaces`
now says so.

* Correct what the follow-ups to #82 say, and test what they change

bc0ad33 said that only a definition written from the top level, at the
top level of a file, has a name that starts with `::::`, so nothing else
changes. That isn't so. The constants defined in such a class, as
`::::Taco::Shell` in `class ::Taco; class Shell`, start with it too, as
does the `::::Taco` pks takes from `class ::Taco::Shell`, and a `Taco`
written inside `class ::Taco` was dropped the same way. pks now counts
each of these as a reference, as packwerk 3.3.1 does, and the CHANGELOG
says so. Tests cover a `Taco` inside `class ::Taco`, `::Taco = 1` at the
top level of a file, and `class ::Burrito` in `module Outer` and
`class ::Cee` in `module Aa; module Bb`, from the file to the references
pks lists.

e9a6092 said that for a reference written `Object::Bar`, the resolver
looks `Object` up as Ruby does. Inside `module CoreExt` it tries
`::CoreExt::Object::Bar` first, as Ruby would, but where `CoreExt`
defines an `Object` with no `Bar`, Ruby raises a NameError, and pks goes
on to `::Bar`, as it does for `Foo::Bar` when `CoreExt::Foo` has no
`Bar`. New tests name `module Object::Bar`, the constants in
`class Object::Bar`, `::Object::Object::BAR` and `ObjectSpace::Bar`, and
find constants defined through `Object` from another pack.

Comments now say that only the experimental parser and resolver drop a
leading `::Object`, and that the empty names come off the end as in
constant_resolver.

* Look up associations and `Object` from the whole app, with --experimental-parser

With `--experimental-parser`, two things that pks looked up from one
file depend on the rest of the app, so they now wait until every file is
read.

Rails looks up the class of an association in a module, as a concern's
`included` block, from the name of each model that includes the module.
a81a247 and 15b614c took the module's name for the model's. pks now
records the `include` and `prepend` calls in each class and module, and
looks up the class from the name of each class that includes the module,
directly or through other modules, so one association can reference a
class for each. Where no class includes the module, the module's name
stands in, as before. When the class has the model's own name and no
`::Taco` is defined, Rails goes on to look it up from the model's name,
as `Outer::Taco::Taco`; 15b614c looked for `::Taco` only. From the
model's name, Rails takes only a class of the very name it looks for
(`compute_type` in inheritance.rb), and pks now does too. In a
controller, inherited_resources looks up the class of `belongs_to`
from the controller's namespaces, and one given as `class_name:` from
the top level, so pks no longer looks in the controller itself, and an
ActiveRecord model whose name ends in `Controller` is looked up as any
other.

`class Object::Charge` in `module Payments` defines
`::Payments::Object::Charge` where the app has a `Payments::Object`,
even one that defines nothing. e9a6092 took it for `::Charge` whatever
the app defines. Where some namespace has its own `Object`, pks now
reads a file that defines a constant through `Object` in a namespace
again, knowing which namespaces do, and names it as Ruby does. Since
`check-contents` reads its file from stdin, which can be read only once,
pks keeps what it read there.

Separately, Ruby finds a constant of `Kernel` or `BasicObject`, which
`Object` includes and inherits from, from anywhere, after the top
level's own, and the experimental resolver now looks there last.

* Name a class defined in `class_eval` or `Class.new` as packwerk does

packwerk names the reference that a class or module definition makes to
itself from every class, module, `class_eval` block on a constant, and
constant assigned `Class.new` or `Module.new` around it
(`parent_module_name` in node_helpers.rb). pks named it from the classes
and modules alone, so in `module Aa; Bb.class_eval do; class ::Cee` it
left out `Bb`. c081104 made the Zeitwerk resolver drop the empty name at
the end of a parent, as packwerk does, and that turned the gap into a
regression there: pks found no reference where packwerk finds one to
`Aa`, and in `module Aa; module Bb; Cc.class_eval do; class ::Dee` it
found one to `Aa` where packwerk's is to `Aa::Bb`.

The packwerk parser now keeps a second stack of names for this, with
the receiver of `Foo.class_eval do` and the constant of
`Foo = Class.new do`, and names the reference from it. A block that
takes `_1` or `it`, which packwerk's parser makes a `numblock` or
`itblock`, adds nothing, as in packwerk. Where packwerk can't name the
class, in a block passed with `&.` or to `super`, pks no longer counts a
reference either. The file's own definitions, and the namespaces each
reference is looked up in, still come from the classes and modules
alone, as in packwerk.

On a copy of `simple_app` with 66 such files, list-references and
packwerk 3.3.1 now differ on one line: a reference packwerk counts to
the scope `Kk` of `Kk::Ll = 1`, which pks has never counted.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

1 participant